Skip to content

Commit ddd8991

Browse files
committed
feat(charts): add note editing and local audio binding
1 parent 82dad46 commit ddd8991

16 files changed

Lines changed: 824 additions & 22 deletions

docs/development/chart-creator.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@ timeline.
1717
current chart only as a playback reference.
1818
4. Play during the normal count-in and backing track. Hits before song time zero
1919
or beyond the declared song duration are ignored.
20-
5. At the result screen, review the captured-hit count and save the raw timing,
21-
or quantize non-destructively to an eighth- or sixteenth-note grid.
20+
5. At the result screen, review the captured notes in the visual editor. Select
21+
a note to change its time or drum pad, add missing notes, or delete unwanted
22+
notes. The original recorded take remains unchanged in memory.
23+
6. Save the edited timing as recorded, or quantize the edited draft
24+
non-destructively to an eighth- or sixteenth-note grid.
2225

2326
Keyboard and MIDI events reach the recorder through `HitMatchingPrototype`'s
2427
`InputProcessed` boundary. Consequently, the existing per-source timing offset
@@ -44,7 +47,10 @@ entries:
4447
To transfer a take, copy only the `.htksong` file into
4548
`Documents/HTKSongs` on the other computer and refresh the Song Library. The
4649
game validates and atomically imports it. Existing song folders are never
47-
overwritten.
50+
overwritten. Select the imported entry, choose **Bind local audio**, select
51+
your own authorized WAV/OGG copy, and confirm the local-only binding. The game
52+
copies that audio into the imported song folder and makes the entry playable;
53+
the source file and portable package are not modified.
4854

4955
The publish is atomic and both documents are parsed by the production loaders
5056
before the folder or package becomes visible. When the recording used a local
@@ -65,13 +71,14 @@ not a claim of authoritative transcription.
6571

6672
## Current foundation limits
6773

68-
- Editing individual notes is not yet available. Raw/1/8/1/16 save choices are
69-
the initial review tools.
74+
- The visual editor supports individual note time/pad changes, additions and
75+
deletions. Waveform scrubbing and articulation/velocity authoring are future
76+
editing tools.
7077
- The schema currently stores pad and time. Velocity and articulation remain in
7178
the in-memory take but schema v1 does not serialize them.
7279
- The native picker currently targets macOS. WAV and OGG are supported; MP3 is
7380
intentionally rejected by the production loader.
7481
- The author must enter BPM, bars and meter explicitly. Unknown timing never
7582
receives a hidden default.
76-
- Portable packages remain chart-only; imported packages require an authorized
77-
local audio binding on the receiving computer.
83+
- Portable packages remain chart-only; the receiving computer must explicitly
84+
bind an authorized local WAV/OGG copy before the imported chart is playable.

docs/development/song-library.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ copied to another computer; subsequent refreshes are idempotent.
121121
Package schema version 1 contains no audio and rejects any audio declaration or
122122
extra archive entry. Imports are bounded to 5 MiB, reject links, duplicate or
123123
case-colliding names, malformed ZIP/JSON/chart data, unsupported versions and
124-
path traversal, and never replace an existing song folder. An imported chart is
125-
therefore visible but unavailable until the player explicitly supplies audio
126-
they are entitled to use through the ordinary local song binding.
124+
path traversal, and never replace an existing song folder.
125+
126+
An imported chart is therefore visible but unavailable until the player
127+
selects **Bind local audio** and supplies a WAV/OGG file they are entitled to
128+
use. The confirmation panel identifies the selected song and local filename;
129+
on confirmation the game copies the audio into that song's direct user-library
130+
folder, atomically updates its manifest, and refreshes the entry as playable.
131+
The source audio and `.htksong` package are never modified, and no absolute
132+
machine path is persisted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using HitTheKit.Core;
5+
using HitTheKit.Unity.Input;
6+
7+
namespace HitTheKit.Unity.Gameplay
8+
{
9+
public sealed class EditableChartNote
10+
{
11+
internal EditableChartNote(
12+
int identity,
13+
DrumPad pad,
14+
int velocity,
15+
double timeSeconds,
16+
DrumInputSource source)
17+
{
18+
Identity = identity;
19+
Pad = pad;
20+
Velocity = velocity;
21+
TimeSeconds = timeSeconds;
22+
Source = source;
23+
}
24+
25+
public int Identity { get; }
26+
public DrumPad Pad { get; internal set; }
27+
public int Velocity { get; }
28+
public double TimeSeconds { get; internal set; }
29+
public DrumInputSource Source { get; }
30+
}
31+
32+
public sealed class ChartDraftEditor
33+
{
34+
private readonly double durationSeconds;
35+
private readonly List<EditableChartNote> notes;
36+
private readonly ReadOnlyCollection<EditableChartNote> readOnlyNotes;
37+
private int nextIdentity;
38+
39+
public ChartDraftEditor(ChartRecordingDraft draft)
40+
{
41+
if (draft == null) throw new ArgumentNullException(nameof(draft));
42+
durationSeconds = draft.DurationSeconds;
43+
notes = new List<EditableChartNote>(draft.Hits.Count);
44+
for (int index = 0; index < draft.Hits.Count; index++)
45+
{
46+
RecordedChartHit hit = draft.Hits[index];
47+
notes.Add(new EditableChartNote(nextIdentity++, hit.Pad, hit.Velocity, hit.TimeSeconds, hit.Source));
48+
}
49+
Sort();
50+
readOnlyNotes = notes.AsReadOnly();
51+
}
52+
53+
public double DurationSeconds => durationSeconds;
54+
public IReadOnlyList<EditableChartNote> Notes => readOnlyNotes;
55+
56+
public int Update(int index, double timeSeconds, DrumPad pad)
57+
{
58+
EditableChartNote note = At(index);
59+
ValidateTime(timeSeconds);
60+
ValidatePad(pad);
61+
note.TimeSeconds = timeSeconds;
62+
note.Pad = pad;
63+
Sort();
64+
return notes.IndexOf(note);
65+
}
66+
67+
public int Add(double timeSeconds, DrumPad pad, int velocity = 100)
68+
{
69+
ValidateTime(timeSeconds);
70+
ValidatePad(pad);
71+
if (velocity < 0 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity));
72+
if (notes.Count >= ChartRecordingSession.MaximumHits)
73+
throw new InvalidOperationException($"A chart cannot exceed {ChartRecordingSession.MaximumHits} notes.");
74+
var note = new EditableChartNote(nextIdentity++, pad, velocity, timeSeconds, DrumInputSource.Test);
75+
notes.Add(note);
76+
Sort();
77+
return notes.IndexOf(note);
78+
}
79+
80+
public void Delete(int index) => notes.RemoveAt(ValidatedIndex(index));
81+
82+
public ChartRecordingDraft BuildDraft()
83+
{
84+
var hits = new RecordedChartHit[notes.Count];
85+
for (int index = 0; index < notes.Count; index++)
86+
{
87+
EditableChartNote note = notes[index];
88+
hits[index] = new RecordedChartHit(
89+
new DrumInputEvent(note.Pad, note.Velocity, note.TimeSeconds, note.Source),
90+
index);
91+
}
92+
return new ChartRecordingDraft(durationSeconds, hits);
93+
}
94+
95+
private EditableChartNote At(int index) => notes[ValidatedIndex(index)];
96+
97+
private int ValidatedIndex(int index)
98+
{
99+
if (index < 0 || index >= notes.Count) throw new ArgumentOutOfRangeException(nameof(index));
100+
return index;
101+
}
102+
103+
private void ValidateTime(double value)
104+
{
105+
if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > durationSeconds)
106+
throw new ArgumentOutOfRangeException(nameof(value), $"Note time must be between 0 and {durationSeconds:0.###} seconds.");
107+
}
108+
109+
private static void ValidatePad(DrumPad pad)
110+
{
111+
if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad));
112+
}
113+
114+
private void Sort() => notes.Sort((left, right) =>
115+
{
116+
int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds);
117+
return byTime != 0 ? byTime : left.Identity.CompareTo(right.Identity);
118+
});
119+
}
120+
}

src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)