From ef6d27e80fb71ee3957db6fa706f1811dc6a0eb2 Mon Sep 17 00:00:00 2001 From: Baron Luca Date: Wed, 19 Aug 2026 13:49:13 +0200 Subject: [PATCH 1/5] feat(charts): add chart creator foundation --- docs/README.md | 1 + docs/development/chart-creator.md | 49 +++ .../Runtime/Gameplay/ChartCreator.cs | 363 ++++++++++++++++++ .../Runtime/Gameplay/ChartCreator.cs.meta | 11 + .../Gameplay/GameplayHighwayController.cs | 137 ++++++- .../Gameplay/GameplaySessionDefinition.cs | 48 ++- .../Runtime/MainMenu/MainMenuController.cs | 24 ++ .../Tests/EditMode/ChartCreatorTests.cs | 174 +++++++++ .../Tests/EditMode/ChartCreatorTests.cs.meta | 11 + .../Tests/PlayMode/MainMenuPlayModeTests.cs | 24 ++ .../HitTheKit/UI/Gameplay/GameplayHighway.uss | 7 + .../UI/Gameplay/GameplayHighway.uxml | 11 + .../Assets/HitTheKit/UI/MainMenu/MainMenu.uss | 22 +- .../HitTheKit/UI/MainMenu/MainMenu.uxml | 5 +- 14 files changed, 874 insertions(+), 13 deletions(-) create mode 100644 docs/development/chart-creator.md create mode 100644 src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs create mode 100644 src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta create mode 100644 src/HitTheKit.Unity/Assets/HitTheKit/Tests/EditMode/ChartCreatorTests.cs create mode 100644 src/HitTheKit.Unity/Assets/HitTheKit/Tests/EditMode/ChartCreatorTests.cs.meta diff --git a/docs/README.md b/docs/README.md index 5fc5e9d..850419a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,7 @@ without creating an unreviewed second source of truth. - [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) diff --git a/docs/development/chart-creator.md b/docs/development/chart-creator.md new file mode 100644 index 0000000..e6dccec --- /dev/null +++ b/docs/development/chart-creator.md @@ -0,0 +1,49 @@ +# 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. Add or select a playable song in the Song Library. +2. Choose its difficulty and practice speed. +3. Select **Record chart**. +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-hit count and save the raw timing, + or quantize 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`. + +The publish is atomic and both documents are parsed by the production loaders +before the folder becomes visible. The manifest declares chart availability but +keeps audio as `missing`. Chart Creator never copies, embeds, downloads, or +redistributes the source audio. To play or share the take, the user must add an +audio file they are entitled to use and update the local binding explicitly. + +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. + +## Current foundation limits + +- A playable Song Library entry is required; audio import/file-picker UI is not + part of this first foundation. +- Editing individual notes is not yet available. Raw/1/8/1/16 save choices are + the initial review tools. +- The schema currently stores pad and time. Velocity and articulation remain in + the in-memory take but schema v1 does not serialize them. +- Exported takes are intentionally non-playable until authorized audio is bound. diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs new file mode 100644 index 0000000..d6d2883 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Text; +using HitTheKit.Core; +using HitTheKit.Unity.Charts; +using HitTheKit.Unity.Input; + +namespace HitTheKit.Unity.Gameplay +{ + public enum ChartQuantization + { + None, + EighthNote, + SixteenthNote + } + + public sealed class RecordedChartHit + { + internal RecordedChartHit(DrumInputEvent input, int recordingIndex) + { + Pad = input.Pad; + Velocity = input.Velocity; + TimeSeconds = input.SongTimeSeconds; + Source = input.Source; + RecordingIndex = recordingIndex; + } + + public DrumPad Pad { get; } + public int Velocity { get; } + public double TimeSeconds { get; } + public DrumInputSource Source { get; } + internal int RecordingIndex { get; } + } + + public sealed class ChartRecordingDraft + { + internal ChartRecordingDraft(double durationSeconds, IReadOnlyList hits) + { + DurationSeconds = durationSeconds; + var copy = new RecordedChartHit[hits.Count]; + for (int index = 0; index < hits.Count; index++) copy[index] = hits[index]; + Hits = Array.AsReadOnly(copy); + } + + public double DurationSeconds { get; } + public IReadOnlyList Hits { get; } + } + + public sealed class ChartRecordingSession + { + public const int MaximumHits = 100000; + private readonly double durationSeconds; + private readonly double outputTimeScale; + private readonly List hits = new List(); + + public ChartRecordingSession(double durationSeconds, double outputTimeScale = 1.0) + { + if (!IsFinite(durationSeconds) || durationSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(durationSeconds)); + if (!IsFinite(outputTimeScale) || outputTimeScale <= 0) + throw new ArgumentOutOfRangeException(nameof(outputTimeScale)); + this.durationSeconds = durationSeconds; + this.outputTimeScale = outputTimeScale; + } + + public bool IsRecording { get; private set; } = true; + public int HitCount => hits.Count; + public int IgnoredCount { get; private set; } + + public bool Record(DrumInputEvent input) + { + if (!IsRecording) return false; + if (input.SongTimeSeconds < 0 || input.SongTimeSeconds > durationSeconds) + { + IgnoredCount++; + return false; + } + if (hits.Count >= MaximumHits) + throw new InvalidOperationException($"A chart recording cannot exceed {MaximumHits} hits."); + + hits.Add(new RecordedChartHit(input.WithSongTime(input.SongTimeSeconds * outputTimeScale), hits.Count)); + return true; + } + + public ChartRecordingDraft Finish() + { + IsRecording = false; + return new ChartRecordingDraft(durationSeconds * outputTimeScale, hits); + } + + public void Restart() + { + hits.Clear(); + IgnoredCount = 0; + IsRecording = true; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartCreatorMetadata + { + public ChartCreatorMetadata( + string sourceSongId, + string title, + string artist, + string difficulty, + double bpm, + int bars, + int beatsPerBar) + { + ValidateIdentifier(sourceSongId, nameof(sourceSongId)); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title)); + if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist)); + if (!Contains(ChartLoader.SupportedDifficulties, difficulty)) + throw new ArgumentOutOfRangeException(nameof(difficulty)); + if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm)); + if (bars <= 0) throw new ArgumentOutOfRangeException(nameof(bars)); + if (beatsPerBar <= 0) throw new ArgumentOutOfRangeException(nameof(beatsPerBar)); + + SourceSongId = sourceSongId; + Title = title.Trim(); + Artist = artist.Trim(); + Difficulty = difficulty; + Bpm = bpm; + Bars = bars; + BeatsPerBar = beatsPerBar; + } + + public string SourceSongId { get; } + public string Title { get; } + public string Artist { get; } + public string Difficulty { get; } + public double Bpm { get; } + public int Bars { get; } + public int BeatsPerBar { get; } + + internal static void ValidateIdentifier(string value, string parameter) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 80) + throw new ArgumentException("Song ID is required and must not exceed 80 characters.", parameter); + for (int index = 0; index < value.Length; index++) + { + char character = value[index]; + if (!(character >= 'a' && character <= 'z') && + !(character >= '0' && character <= '9') && character != '-') + throw new ArgumentException("Song ID must use lowercase letters, numbers, and hyphens.", parameter); + } + } + + private static bool Contains(IReadOnlyList values, string value) + { + for (int index = 0; index < values.Count; index++) + if (string.Equals(values[index], value, StringComparison.Ordinal)) return true; + return false; + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public static class ChartCreatorJson + { + public static string Serialize( + ChartRecordingDraft draft, + string difficulty, + double bpm, + ChartQuantization quantization) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + if (!Enum.IsDefined(typeof(ChartQuantization), quantization)) + throw new ArgumentOutOfRangeException(nameof(quantization)); + if (!IsFinite(bpm) || bpm <= 0) throw new ArgumentOutOfRangeException(nameof(bpm)); + bool supported = false; + for (int index = 0; index < ChartLoader.SupportedDifficulties.Count; index++) + if (string.Equals(ChartLoader.SupportedDifficulties[index], difficulty, StringComparison.Ordinal)) + supported = true; + if (!supported) throw new ArgumentOutOfRangeException(nameof(difficulty)); + + var projected = new List(draft.Hits.Count); + for (int index = 0; index < draft.Hits.Count; index++) + { + RecordedChartHit hit = draft.Hits[index]; + double time = Quantize(hit.TimeSeconds, bpm, quantization); + time = Math.Max(0, Math.Min(draft.DurationSeconds, time)); + projected.Add(new ProjectedHit(time, hit.Pad, hit.RecordingIndex)); + } + projected.Sort((left, right) => + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.RecordingIndex.CompareTo(right.RecordingIndex); + }); + + var json = new StringBuilder(128 + projected.Count * 48); + json.Append("{\n \"version\": 1,\n \"offsetSeconds\": 0,\n \"difficulties\": {\n \"") + .Append(difficulty) + .Append("\": ["); + for (int index = 0; index < projected.Count; index++) + { + ProjectedHit hit = projected[index]; + json.Append(index == 0 ? "\n " : ",\n ") + .Append("{ \"time\": ") + .Append(hit.TimeSeconds.ToString("0.#########", CultureInfo.InvariantCulture)) + .Append(", \"pad\": \"") + .Append(PadId(hit.Pad)) + .Append("\" }"); + } + if (projected.Count > 0) json.Append('\n').Append(" "); + json.Append("]\n }\n}\n"); + + string result = json.ToString(); + new ChartLoader().Load(result, difficulty); + return result; + } + + private static double Quantize(double value, double bpm, ChartQuantization quantization) + { + int subdivisions; + switch (quantization) + { + case ChartQuantization.None: return value; + case ChartQuantization.EighthNote: subdivisions = 2; break; + case ChartQuantization.SixteenthNote: subdivisions = 4; break; + default: throw new ArgumentOutOfRangeException(nameof(quantization)); + } + double step = 60.0 / bpm / subdivisions; + return Math.Round(value / step, MidpointRounding.AwayFromZero) * step; + } + + private static string PadId(DrumPad pad) + { + switch (pad) + { + case DrumPad.Kick: return "kick"; + case DrumPad.Snare: return "snare"; + case DrumPad.HiHat: return "hiHat"; + case DrumPad.Tom1: return "tom1"; + case DrumPad.Tom2: return "tom2"; + case DrumPad.FloorTom: return "floorTom"; + case DrumPad.Crash: return "crash"; + case DrumPad.Ride: return "ride"; + default: throw new ArgumentOutOfRangeException(nameof(pad)); + } + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private sealed class ProjectedHit + { + public ProjectedHit(double timeSeconds, DrumPad pad, int recordingIndex) + { + TimeSeconds = timeSeconds; + Pad = pad; + RecordingIndex = recordingIndex; + } + public double TimeSeconds { get; } + public DrumPad Pad { get; } + public int RecordingIndex { get; } + } + } + + public sealed class ChartCreatorExportResult + { + internal ChartCreatorExportResult(string songId, string folderPath, string chartPath, string manifestPath) + { + SongId = songId; + FolderPath = folderPath; + ChartPath = chartPath; + ManifestPath = manifestPath; + } + public string SongId { get; } + public string FolderPath { get; } + public string ChartPath { get; } + public string ManifestPath { get; } + } + + public sealed class ChartCreatorExporter + { + private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false); + + public ChartCreatorExportResult ExportChartOnly( + ChartRecordingDraft draft, + ChartCreatorMetadata metadata, + ChartQuantization quantization, + string libraryRoot, + DateTimeOffset createdAtUtc) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + if (draft.Hits.Count == 0) + throw new InvalidOperationException("A recorded chart must contain at least one hit."); + if (metadata == null) throw new ArgumentNullException(nameof(metadata)); + if (string.IsNullOrWhiteSpace(libraryRoot)) throw new ArgumentException("Library root is required.", nameof(libraryRoot)); + if (createdAtUtc.Offset != TimeSpan.Zero) throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc)); + + string root = Path.GetFullPath(libraryRoot); + Directory.CreateDirectory(root); + string prefix = metadata.SourceSongId.Length <= 48 ? metadata.SourceSongId : metadata.SourceSongId.Substring(0, 48).TrimEnd('-'); + string baseId = $"{prefix}-take-{createdAtUtc:yyyyMMdd-HHmmss}"; + string songId = UniqueSongId(root, baseId); + string destination = Path.Combine(root, songId); + string temporary = Path.Combine(root, $".hitthekit-chart-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temporary); + try + { + string chartJson = ChartCreatorJson.Serialize(draft, metadata.Difficulty, metadata.Bpm, quantization); + string chartPath = Path.Combine(temporary, "notes.json"); + string manifestPath = Path.Combine(temporary, "song.json"); + File.WriteAllText(chartPath, chartJson, Utf8WithoutBom); + File.WriteAllText(manifestPath, Manifest(songId, metadata), Utf8WithoutBom); + + // Validate both public formats before the atomic publish into HTKSongs. + new ChartLoader().Load(chartJson, metadata.Difficulty); + new SongLibraryDiscovery().Parse(File.ReadAllText(manifestPath), temporary, SongLibraryOrigin.UserFolder); + Directory.Move(temporary, destination); + return new ChartCreatorExportResult( + songId, + destination, + Path.Combine(destination, "notes.json"), + Path.Combine(destination, "song.json")); + } + catch + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw; + } + } + + private static string UniqueSongId(string root, string baseId) + { + string value = baseId; + int suffix = 2; + while (Directory.Exists(Path.Combine(root, value))) value = $"{baseId}-{suffix++}"; + ChartCreatorMetadata.ValidateIdentifier(value, nameof(baseId)); + return value; + } + + private static string Manifest(string songId, ChartCreatorMetadata metadata) + { + return "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"id\": \"{Escape(songId)}\",\n" + + $" \"title\": \"{Escape(metadata.Title + " · Recorded Take")}\",\n" + + $" \"artist\": \"{Escape(metadata.Artist)}\",\n" + + $" \"bpm\": {metadata.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" + + $" \"bars\": {metadata.Bars},\n" + + $" \"beatsPerBar\": {metadata.BeatsPerBar},\n" + + " \"difficultyHint\": \"Recorded performance · review before sharing\",\n" + + " \"sortOrder\": 0,\n" + + " \"audioAvailability\": \"missing\",\n" + + " \"chartAvailability\": \"available\",\n" + + " \"chartFile\": \"notes.json\"\n" + + "}\n"; + } + + private static string Escape(string value) => value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta new file mode 100644 index 0000000..ea60eb3 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f78758e52aef4c5b8c593dc737ae4a4d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs index 4f14249..eb4ed72 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -75,6 +75,12 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultBreakdownLabel; private Label resultPracticeLabel; private Label resultCalibrationLabel; + private VisualElement chartCreatorResults; + private Label chartCreatorSummaryLabel; + private Label chartCreatorStatusLabel; + private Button chartSaveRawButton; + private Button chartSaveEighthButton; + private Button chartSaveSixteenthButton; private GameplayHighwaySurface surface; private GameplayKitSurface kitSurface; private bool showInstructionalKit; @@ -93,6 +99,8 @@ public sealed class GameplayHighwayController : MonoBehaviour private AudioClip metronomeClip; private bool metronomeScheduled; private bool resultRecorded; + private ChartRecordingSession chartRecording; + private ChartRecordingDraft chartDraft; public event Action ThemeChanged; @@ -114,6 +122,8 @@ public sealed class GameplayHighwayController : MonoBehaviour public GameplaySessionDefinition CurrentSession => sessionCoordinator?.Session ?? GameplaySessionContext.Current; public double CurrentAttemptPracticeSeconds => practiceTimer.CurrentAttemptSeconds; + public int RecordedChartHitCount => chartRecording?.HitCount ?? chartDraft?.Hits.Count ?? 0; + public string LastChartExportPath { get; private set; } private void Awake() { @@ -141,6 +151,7 @@ private void Start() BindNavigation(); Subscribe(); InitializeAudioFeedback(); + InitializeChartCreator(); SetTheme(CurrentSession.Theme); RefreshSessionCopy(); } @@ -264,6 +275,12 @@ private void BindView() resultBreakdownLabel = root.Q