Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ without creating an unreviewed second source of truth.
- [Branching and release channels](development/branching-and-releases.md)
- [DSP song clock](development/dsp-song-clock.md)
- [Chart timeline](development/chart-timeline.md)
- [Chart Creator foundation](development/chart-creator.md)
- [Demo-song vertical slice](development/demo-song-vertical-slice.md)
- [Keyboard hit matching](development/keyboard-hit-matching.md)
- [Pad visuals](development/pad-visuals.md)
Expand Down
101 changes: 101 additions & 0 deletions docs/development/chart-creator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Chart Creator foundation

Chart Creator turns a performance from the existing keyboard/CoreMIDI gameplay
input into a reviewable schema-v1 chart draft. It deliberately reuses the real
gameplay scene rather than maintaining a second audio clock, input mapper, or
timeline.

## Workflow

1. Select **Import audio & create** in the Song Library and choose a local WAV
or OGG file, or select an existing playable song.
2. For new audio, enter title, artist, verified BPM, bar count and beats per bar.
HitTheKit copies the selected audio into a private local authoring folder; it
never modifies the source file.
3. Choose practice speed and select **Record chart**. A new audio-only source
starts with a real empty schema-v1 timeline; an existing song keeps its
current chart only as a playback reference.
4. Play during the normal count-in and backing track. Hits before song time zero
or beyond the declared song duration are ignored.
5. At the result screen, review the captured notes against the bounded waveform.
Drag its playhead to scrub the source timeline, zoom around a passage, and
preview audio from the selected time. Select a note to change its time, drum
pad, velocity or physical articulation, add missing notes, or delete unwanted
notes. The original recorded take remains unchanged in memory.
6. Save the edited timing as recorded, or quantize the edited draft
non-destructively to an eighth- or sixteenth-note grid.

Keyboard and MIDI events reach the recorder through `HitMatchingPrototype`'s
`InputProcessed` boundary. Consequently, the existing per-source timing offset
is applied before recording. A take made at a reduced practice speed is scaled
back to the source song's original timeline before it is serialized.

## Output and rights boundary

The exporter creates a new, never-overwritten folder under
`~/Documents/HTKSongs` containing only:

- `song.json`;
- `notes.json`.

It also creates one portable `<song-id>.htksong` file alongside the folder.
The package is a ZIP-compatible, chart-only container with exactly three
entries:

- `htksong-version` (`1`);
- `song.json`;
- `notes.json`.

To transfer a take, copy only the `.htksong` file into
`Documents/HTKSongs` on the other computer and refresh the Song Library. The
game validates and atomically imports it. Existing song folders are never
overwritten. Select the imported entry, choose **Bind local audio**, select
your own authorized WAV/OGG copy, and confirm the local-only binding. The game
copies that audio into the imported song folder and makes the entry playable;
the source file and portable package are not modified.

The publish is atomic and both documents are parsed by the production loaders
before the folder or package becomes visible. When the recording used a local
WAV/OGG source, the private exported folder receives its own local audio copy and
is immediately playable. The `.htksong` manifest still declares audio as
`missing`: Chart Creator never embeds, downloads, or redistributes source audio
in the portable package. A recipient supplies their own authorized local copy.

Import is fail-closed. Unknown/archive entries, audio declarations, symbolic
links, duplicate names, unsupported versions, malformed JSON, invalid charts,
oversized data and path traversal are rejected before extraction. Version 1 is
intentionally chart-only; adding optional distributable audio requires a future
explicit package version and rights-aware UX.

The exported title is marked `Recorded Take` and the difficulty hint says that
the performance must be reviewed before sharing. This is a captured performance,
not a claim of authoritative transcription.

## Note expression

Schema v1 remains backward compatible: `velocity` and `articulation` are
optional note properties. A legacy note without them keeps unknown velocity and
the `default` articulation wildcard. Newly recorded/exported notes preserve
velocity; non-default articulations use explicit identifiers such as `rim`,
`bell`, `bow`, `edge`, `open`, or `pedal`.

Known articulations are validated against the selected pad. An explicit Ride
Bell target, for example, matches only a bell hit and highlights the bell zone
of the instructional kit. Changing a note to an incompatible pad resets its
articulation to the backward-compatible default rather than inventing a zone.

The waveform is an editor envelope, not a second clock or audio importer. It is
sampled from the already-loaded `AudioClip` into a bounded 512-point model, and
preview playback reuses the existing `DspSongClockPrototype` audio source. The
chart timing remains source-time based and the recorded take remains immutable.

## Current foundation limits

- The waveform provides envelope scrubbing, zoom and preview; it is not yet a
sample-accurate destructive audio editor.
- The native picker currently targets macOS. WAV and OGG are supported; MP3 is
intentionally rejected by the production loader.
- The author must enter BPM, bars and meter explicitly. Unknown timing never
receives a hidden default.
- Portable packages remain chart-only; the receiving computer must explicitly
bind an authorized local WAV/OGG copy before the imported chart is playable.
36 changes: 36 additions & 0 deletions docs/development/song-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,39 @@ machine-specific path into bundled `song.json`.
The refresh button performs an explicit rescan. Discovery does not watch the
filesystem continuously, allocate every frame, or access folders outside the
two configured roots.

## Local audio authoring

The Song Library's **Import audio & create** action opens the macOS file picker
for WAV/OGG content. After the player supplies title, artist and verified timing
metadata, the importer copies the selected file into a new, never-overwritten
folder under `~/Documents/HTKSongs`. The source file is not changed and its
absolute path is not persisted in `song.json`.

The resulting entry is intentionally audio-only: it is not playable as a normal
song, but it can start Chart Creator with the existing DSP clock and an empty
timeline. Once a take is saved, the local folder contains authorized audio plus
the new chart and is playable immediately. Its sibling `.htksong` remains
chart-only for safe transfer.

## Portable `.htksong` chart packages

Chart Creator writes a portable chart-only package next to each recorded take.
Copy a `.htksong` file directly into `~/Documents/HTKSongs` and select
**Refresh library**. Before normal folder discovery, the game validates the
container and atomically installs its `song.json` and `notes.json` into a folder
named after the validated song ID. The package remains in place so it can be
copied to another computer; subsequent refreshes are idempotent.

Package schema version 1 contains no audio and rejects any audio declaration or
extra archive entry. Imports are bounded to 5 MiB, reject links, duplicate or
case-colliding names, malformed ZIP/JSON/chart data, unsupported versions and
path traversal, and never replace an existing song folder.

An imported chart is therefore visible but unavailable until the player
selects **Bind local audio** and supplies a WAV/OGG file they are entitled to
use. The confirmation panel identifies the selected song and local filename;
on confirmation the game copies the audio into that song's direct user-library
folder, atomically updates its manifest, and refreshes the entry as playable.
The source audio and `.htksong` package are never modified, and no absolute
machine path is persisted.
16 changes: 15 additions & 1 deletion src/HitTheKit.Core/ChartNote.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ namespace HitTheKit.Core
{
public sealed class ChartNote
{
public ChartNote(double timeSeconds, DrumPad pad)
public ChartNote(
double timeSeconds,
DrumPad pad,
int? velocity = null,
DrumArticulation articulation = DrumArticulation.Default)
{
if (!IsFinite(timeSeconds) || timeSeconds < 0)
{
Expand All @@ -13,14 +17,24 @@ public ChartNote(double timeSeconds, DrumPad pad)
"A chart note time must be finite and non-negative.");
}

if (velocity.HasValue && (velocity.Value < 1 || velocity.Value > 127))
throw new ArgumentOutOfRangeException(nameof(velocity), "Target velocity must be between 1 and 127.");
DrumArticulationValidator.EnsureValid(pad, articulation);

TimeSeconds = timeSeconds;
Pad = pad;
Velocity = velocity;
Articulation = articulation;
}

public double TimeSeconds { get; }

public DrumPad Pad { get; }

public int? Velocity { get; }

public DrumArticulation Articulation { get; }

private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
Expand Down
66 changes: 66 additions & 0 deletions src/HitTheKit.Core/DrumArticulation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;

namespace HitTheKit.Core
{
public enum DrumArticulation
{
Default,
Head,
Rim,
Bow,
Edge,
Bell,
Closed,
HalfOpen,
Open,
Pedal,
Choke
}

public static class DrumArticulationValidator
{
public static bool IsValid(DrumPad pad, DrumArticulation articulation)
{
if (!Enum.IsDefined(typeof(DrumPad), pad) ||
!Enum.IsDefined(typeof(DrumArticulation), articulation))
return false;
if (articulation == DrumArticulation.Default) return true;

switch (pad)
{
case DrumPad.Kick:
return false;
case DrumPad.Snare:
case DrumPad.Tom1:
case DrumPad.Tom2:
case DrumPad.FloorTom:
return articulation == DrumArticulation.Head || articulation == DrumArticulation.Rim;
case DrumPad.HiHat:
return articulation == DrumArticulation.Bow ||
articulation == DrumArticulation.Edge ||
articulation == DrumArticulation.Closed ||
articulation == DrumArticulation.HalfOpen ||
articulation == DrumArticulation.Open ||
articulation == DrumArticulation.Pedal ||
articulation == DrumArticulation.Choke;
case DrumPad.Crash:
return articulation == DrumArticulation.Bow ||
articulation == DrumArticulation.Edge ||
articulation == DrumArticulation.Choke;
case DrumPad.Ride:
return articulation == DrumArticulation.Bow ||
articulation == DrumArticulation.Edge ||
articulation == DrumArticulation.Bell ||
articulation == DrumArticulation.Choke;
default:
return false;
}
}

public static void EnsureValid(DrumPad pad, DrumArticulation articulation)
{
if (!IsValid(pad, articulation))
throw new ArgumentException($"Articulation '{articulation}' is not valid for drum pad '{pad}'.");
}
}
}
10 changes: 9 additions & 1 deletion src/HitTheKit.Core/DrumHit.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ namespace HitTheKit.Core
{
public readonly struct DrumHit
{
public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127)
public DrumHit(
DrumPad pad,
double timeSeconds,
int velocity = 127,
DrumArticulation articulation = DrumArticulation.Default)
{
if (double.IsNaN(timeSeconds) || double.IsInfinity(timeSeconds))
{
Expand All @@ -20,15 +24,19 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127)
"Velocity must be between 0 and 127.");
}

DrumArticulationValidator.EnsureValid(pad, articulation);
Pad = pad;
TimeSeconds = timeSeconds;
Velocity = velocity;
Articulation = articulation;
}

public DrumPad Pad { get; }

public double TimeSeconds { get; }

public int Velocity { get; }

public DrumArticulation Articulation { get; }
}
}
3 changes: 2 additions & 1 deletion src/HitTheKit.Core/HitMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public bool TryMatch(
"The candidate list cannot contain null notes.",
nameof(notes));

if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad)
if (_resolvedNotes.Contains(note) || note.Pad != hit.Pad ||
note.Articulation != DrumArticulation.Default && note.Articulation != hit.Articulation)
{
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public sealed class DspSongClockPrototype : MonoBehaviour
public double CountInSeconds => countInBeats * 60.0 / bpm;
public string ExternalAudioPath => externalAudioPath;
public double AudioPlaybackSpeed => audioPlaybackSpeed;
public bool IsPreviewing { get; private set; }
public string LoadError { get; private set; }

public void Configure(
Expand Down Expand Up @@ -153,8 +154,31 @@ public void RestartPlayback()
completedLogged = false;
}

public void PreviewFromSourceTime(double sourceTimeSeconds, double speed = 1.0)
{
if (generatedClip == null || audioSource == null)
throw new InvalidOperationException("Song audio is not loaded.");
if (double.IsNaN(sourceTimeSeconds) || double.IsInfinity(sourceTimeSeconds) ||
sourceTimeSeconds < 0 || sourceTimeSeconds >= generatedClip.length)
throw new ArgumentOutOfRangeException(nameof(sourceTimeSeconds));
if (double.IsNaN(speed) || double.IsInfinity(speed) || speed <= 0 || speed > 3)
throw new ArgumentOutOfRangeException(nameof(speed));
audioSource.Stop();
audioSource.pitch = (float)speed;
audioSource.time = (float)sourceTimeSeconds;
audioSource.Play();
IsPreviewing = true;
}

public void StopPreview()
{
if (audioSource != null) audioSource.Stop();
IsPreviewing = false;
}

private void SchedulePlayback()
{
IsPreviewing = false;
audioSource.clip = generatedClip;
audioSource.pitch = (float)audioPlaybackSpeed;
var timeSource = new UnityDspTimeSource();
Expand Down
Loading
Loading