diff --git a/docs/README.md b/docs/README.md index 58ebcea..a973ece 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/development/chart-creator.md b/docs/development/chart-creator.md new file mode 100644 index 0000000..07753a0 --- /dev/null +++ b/docs/development/chart-creator.md @@ -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 `.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. diff --git a/docs/development/song-library.md b/docs/development/song-library.md index 10485f6..81cbfa1 100644 --- a/docs/development/song-library.md +++ b/docs/development/song-library.md @@ -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. diff --git a/src/HitTheKit.Core/ChartNote.cs b/src/HitTheKit.Core/ChartNote.cs index 36d09f2..cba7952 100644 --- a/src/HitTheKit.Core/ChartNote.cs +++ b/src/HitTheKit.Core/ChartNote.cs @@ -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) { @@ -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); diff --git a/src/HitTheKit.Core/DrumArticulation.cs b/src/HitTheKit.Core/DrumArticulation.cs new file mode 100644 index 0000000..c4bac2c --- /dev/null +++ b/src/HitTheKit.Core/DrumArticulation.cs @@ -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}'."); + } + } +} diff --git a/src/HitTheKit.Core/DrumHit.cs b/src/HitTheKit.Core/DrumHit.cs index 88c8acf..1514ad7 100644 --- a/src/HitTheKit.Core/DrumHit.cs +++ b/src/HitTheKit.Core/DrumHit.cs @@ -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)) { @@ -20,9 +24,11 @@ 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; } @@ -30,5 +36,7 @@ public DrumHit(DrumPad pad, double timeSeconds, int velocity = 127) public double TimeSeconds { get; } public int Velocity { get; } + + public DrumArticulation Articulation { get; } } } diff --git a/src/HitTheKit.Core/HitMatcher.cs b/src/HitTheKit.Core/HitMatcher.cs index 55468b6..32cdef9 100644 --- a/src/HitTheKit.Core/HitMatcher.cs +++ b/src/HitTheKit.Core/HitMatcher.cs @@ -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; } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs index 3735fbd..9c6c4ed 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Audio/DspSongClockPrototype.cs @@ -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( @@ -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(); diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs index 44880b2..e95a9b7 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartLoader.cs @@ -75,7 +75,17 @@ public LoadedChart Load(string json, string difficulty) } DrumPad pad = ParsePad(noteDto.pad, index); - notes.Add(new IndexedChartNote(new ChartNote(noteDto.time, pad), index)); + int? velocity = noteDto.velocity == int.MinValue ? (int?)null : noteDto.velocity; + DrumArticulation articulation = ParseArticulation(noteDto.articulation, pad, index); + try + { + notes.Add(new IndexedChartNote( + new ChartNote(noteDto.time, pad, velocity, articulation), index)); + } + catch (ArgumentException exception) + { + throw new ChartLoadException($"Chart note at index {index} is invalid: {exception.Message}", exception); + } } notes.Sort((left, right) => @@ -161,6 +171,34 @@ private static DrumPad ParsePad(string pad, int index) } } + private static DrumArticulation ParseArticulation(string value, DrumPad pad, int index) + { + DrumArticulation articulation; + switch (value) + { + case null: + case "default": articulation = DrumArticulation.Default; break; + case "head": articulation = DrumArticulation.Head; break; + case "rim": articulation = DrumArticulation.Rim; break; + case "bow": articulation = DrumArticulation.Bow; break; + case "edge": articulation = DrumArticulation.Edge; break; + case "bell": articulation = DrumArticulation.Bell; break; + case "closed": articulation = DrumArticulation.Closed; break; + case "halfOpen": articulation = DrumArticulation.HalfOpen; break; + case "open": articulation = DrumArticulation.Open; break; + case "pedal": articulation = DrumArticulation.Pedal; break; + case "choke": articulation = DrumArticulation.Choke; break; + default: + throw new ChartLoadException( + $"Chart note at index {index} has unknown articulation '{value}'."); + } + + if (!DrumArticulationValidator.IsValid(pad, articulation)) + throw new ChartLoadException( + $"Chart note at index {index} articulation '{value}' is invalid for pad '{pad}'."); + return articulation; + } + private static bool IsFinite(double value) { return !double.IsNaN(value) && !double.IsInfinity(value); @@ -190,6 +228,8 @@ private sealed class ChartNoteDto { public double time = double.NaN; public string pad; + public int velocity = int.MinValue; + public string articulation; } } } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs index cbbc55b..f287acf 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Charts/ChartTimelinePrototype.cs @@ -67,7 +67,14 @@ public IReadOnlyList CreateMatchingNotes() if (Timeline == null) throw new InvalidOperationException("The chart timeline has not started."); var result = new ChartNote[Timeline.Notes.Count]; for (int index = 0; index < result.Length; index++) - result[index] = new ChartNote(Timeline.Notes[index].EffectiveTimeSeconds, Timeline.Notes[index].Note.Pad); + { + ChartNote note = Timeline.Notes[index].Note; + result[index] = new ChartNote( + Timeline.Notes[index].EffectiveTimeSeconds, + note.Pad, + note.Velocity, + note.Articulation); + } return Array.AsReadOnly(result); } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs index e4f544b..c9541d9 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Devices/MidiKitMappingEngine.cs @@ -232,8 +232,28 @@ public MvpDrumInputMappingStatus Map( pad, hit.Velocity, hit.TimestampSeconds.Value, - DrumInputSource.Midi); + DrumInputSource.Midi, + ToDrumArticulation(hit.Articulation)); return MvpDrumInputMappingStatus.Mapped; } + + private static DrumArticulation ToDrumArticulation(KitArticulation articulation) + { + switch (articulation) + { + case KitArticulation.Default: return DrumArticulation.Default; + case KitArticulation.Head: return DrumArticulation.Head; + case KitArticulation.Rim: return DrumArticulation.Rim; + case KitArticulation.Bow: return DrumArticulation.Bow; + case KitArticulation.Edge: return DrumArticulation.Edge; + case KitArticulation.Bell: return DrumArticulation.Bell; + case KitArticulation.Closed: return DrumArticulation.Closed; + case KitArticulation.HalfOpen: return DrumArticulation.HalfOpen; + case KitArticulation.Open: return DrumArticulation.Open; + case KitArticulation.Pedal: return DrumArticulation.Pedal; + case KitArticulation.Choke: return DrumArticulation.Choke; + default: throw new ArgumentOutOfRangeException(nameof(articulation)); + } + } } } diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs new file mode 100644 index 0000000..00b0d11 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs @@ -0,0 +1,240 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; +using UnityEngine; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class ChartAuthoringAudioRequest + { + public ChartAuthoringAudioRequest( + string sourceAudioPath, + string title, + string artist, + double bpm, + int bars, + int beatsPerBar) + { + if (string.IsNullOrWhiteSpace(sourceAudioPath)) + throw new ArgumentException("An audio file is required.", nameof(sourceAudioPath)); + 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 (!IsFinite(bpm) || bpm <= 0 || bpm > 400) throw new ArgumentOutOfRangeException(nameof(bpm)); + if (bars <= 0 || bars > 10000) throw new ArgumentOutOfRangeException(nameof(bars)); + if (beatsPerBar <= 0 || beatsPerBar > 32) throw new ArgumentOutOfRangeException(nameof(beatsPerBar)); + + SourceAudioPath = Path.GetFullPath(sourceAudioPath); + Title = title.Trim(); + Artist = artist.Trim(); + Bpm = bpm; + Bars = bars; + BeatsPerBar = beatsPerBar; + } + + public string SourceAudioPath { get; } + public string Title { get; } + public string Artist { get; } + public double Bpm { get; } + public int Bars { get; } + public int BeatsPerBar { get; } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartAuthoringAudioImportResult + { + internal ChartAuthoringAudioImportResult(SongLibraryEntry song, string sourcePath) + { + Song = song; + SourcePath = sourcePath; + } + + public SongLibraryEntry Song { get; } + public string SourcePath { get; } + } + + public sealed class ChartAuthoringAudioImportException : Exception + { + public ChartAuthoringAudioImportException(string message) : base(message) { } + public ChartAuthoringAudioImportException(string message, Exception innerException) : base(message, innerException) { } + } + + public sealed class ChartAuthoringAudioImporter + { + public const long MaximumAudioBytes = 1024L * 1024 * 1024; + private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false); + + public ChartAuthoringAudioImportResult Import( + ChartAuthoringAudioRequest request, + string libraryRoot, + DateTimeOffset createdAtUtc) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (string.IsNullOrWhiteSpace(libraryRoot)) + throw new ArgumentException("Song-library root is required.", nameof(libraryRoot)); + if (createdAtUtc.Offset != TimeSpan.Zero) + throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc)); + + string source = ValidateSource(request.SourceAudioPath); + string root = Path.GetFullPath(libraryRoot); + Directory.CreateDirectory(root); + string baseId = BuildId(request.Artist, request.Title, createdAtUtc); + string songId = UniqueSongId(root, baseId); + string destination = Path.Combine(root, songId); + string temporary = Path.Combine(root, $".hitthekit-audio-{Guid.NewGuid():N}"); + string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant(); + Directory.CreateDirectory(temporary); + + try + { + string copiedAudio = Path.Combine(temporary, audioFileName); + File.Copy(source, copiedAudio, false); + string manifest = Manifest(songId, request, audioFileName); + File.WriteAllText(Path.Combine(temporary, SongLibraryDiscovery.ManifestFileName), manifest, Utf8WithoutBom); + SongLibraryEntry validated = new SongLibraryDiscovery().Parse( + manifest, + temporary, + SongLibraryOrigin.UserFolder); + if (!validated.CanAuthorChart || validated.IsPlayable) + throw new ChartAuthoringAudioImportException("The imported audio did not produce a valid authoring source."); + + Directory.Move(temporary, destination); + SongLibraryEntry published = new SongLibraryDiscovery().Parse( + File.ReadAllText(Path.Combine(destination, SongLibraryDiscovery.ManifestFileName)), + destination, + SongLibraryOrigin.UserFolder); + return new ChartAuthoringAudioImportResult(published, source); + } + catch (ChartAuthoringAudioImportException) + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw; + } + catch (Exception exception) + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + throw new ChartAuthoringAudioImportException("The selected audio could not be imported.", exception); + } + } + + public static string ValidateSource(string sourceAudioPath) + { + if (string.IsNullOrWhiteSpace(sourceAudioPath)) + throw new ChartAuthoringAudioImportException("Select a WAV or OGG audio file."); + string source = Path.GetFullPath(sourceAudioPath); + var info = new FileInfo(source); + if (!info.Exists || info.Length <= 0 || info.Length > MaximumAudioBytes) + throw new ChartAuthoringAudioImportException("Audio must be between 1 byte and 1 GiB."); + if ((File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0) + throw new ChartAuthoringAudioImportException("The selected audio cannot be a symbolic link."); + string extension = Path.GetExtension(source); + if (!string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".ogg", StringComparison.OrdinalIgnoreCase)) + throw new ChartAuthoringAudioImportException("Chart Creator supports WAV and OGG audio."); + return source; + } + + private static string UniqueSongId(string root, string baseId) + { + string candidate = baseId; + int suffix = 2; + while (Directory.Exists(Path.Combine(root, candidate)) || + File.Exists(Path.Combine(root, candidate + HtkSongPackageService.Extension))) + candidate = $"{baseId}-{suffix++}"; + ChartCreatorMetadata.ValidateIdentifier(candidate, nameof(baseId)); + return candidate; + } + + private static string BuildId(string artist, string title, DateTimeOffset createdAtUtc) + { + string text = (artist + "-" + title).Normalize(NormalizationForm.FormD); + var id = new StringBuilder(64); + bool separator = false; + for (int index = 0; index < text.Length && id.Length < 48; index++) + { + char value = char.ToLowerInvariant(text[index]); + if (value >= 'a' && value <= 'z' || value >= '0' && value <= '9') + { + id.Append(value); + separator = false; + } + else if (id.Length > 0 && !separator) + { + id.Append('-'); + separator = true; + } + } + string prefix = id.ToString().Trim('-'); + if (prefix.Length == 0) prefix = "local-song"; + return $"{prefix}-authoring-{createdAtUtc:yyyyMMdd-HHmmss}"; + } + + private static string Manifest( + string songId, + ChartAuthoringAudioRequest request, + string audioFileName) + { + return "{\n" + + " \"schemaVersion\": 1,\n" + + $" \"id\": \"{Escape(songId)}\",\n" + + $" \"title\": \"{Escape(request.Title)}\",\n" + + $" \"artist\": \"{Escape(request.Artist)}\",\n" + + $" \"bpm\": {request.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" + + $" \"bars\": {request.Bars},\n" + + $" \"beatsPerBar\": {request.BeatsPerBar},\n" + + " \"difficultyHint\": \"New local chart\",\n" + + " \"sortOrder\": 0,\n" + + " \"audioAvailability\": \"available\",\n" + + $" \"audioFile\": \"{Escape(audioFileName)}\",\n" + + " \"chartAvailability\": \"unavailable\"\n" + + "}\n"; + } + + private static string Escape(string value) => value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); + } + + public interface IChartAuthoringAudioPicker + { + string PickAudioFile(); + } + + public sealed class MacOsChartAuthoringAudioPicker : IChartAuthoringAudioPicker + { + public string PickAudioFile() + { +#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX + const string script = + "const app = Application.currentApplication(); " + + "app.includeStandardAdditions = true; " + + "const chosen = app.chooseFile({withPrompt: 'Choose a WAV or OGG backing track'}); " + + "Path(chosen).toString();"; + var start = new ProcessStartInfo + { + FileName = "/usr/bin/osascript", + Arguments = "-l JavaScript -e \"" + + script.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + using (Process process = Process.Start(start)) + { + if (process == null) throw new InvalidOperationException("The macOS audio picker could not start."); + string output = process.StandardOutput.ReadToEnd(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); + return process.ExitCode == 0 ? output.Trim() : null; + } +#else + throw new PlatformNotSupportedException("The native audio picker currently supports macOS."); +#endif + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta new file mode 100644 index 0000000..f8f807d --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3e8f45ac662e4b50b9fd231315fd1419 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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..1b5634f --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartCreator.cs @@ -0,0 +1,448 @@ +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; + Articulation = input.Articulation; + RecordingIndex = recordingIndex; + } + + public DrumPad Pad { get; } + public int Velocity { get; } + public double TimeSeconds { get; } + public DrumInputSource Source { get; } + public DrumArticulation Articulation { 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.Velocity, + hit.Articulation, + 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("\", \"velocity\": ") + .Append(hit.Velocity.ToString(CultureInfo.InvariantCulture)); + if (hit.Articulation != DrumArticulation.Default) + json.Append(", \"articulation\": \"") + .Append(ArticulationId(hit.Articulation)) + .Append('"'); + json.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 string ArticulationId(DrumArticulation articulation) + { + switch (articulation) + { + case DrumArticulation.Default: return "default"; + case DrumArticulation.Head: return "head"; + case DrumArticulation.Rim: return "rim"; + case DrumArticulation.Bow: return "bow"; + case DrumArticulation.Edge: return "edge"; + case DrumArticulation.Bell: return "bell"; + case DrumArticulation.Closed: return "closed"; + case DrumArticulation.HalfOpen: return "halfOpen"; + case DrumArticulation.Open: return "open"; + case DrumArticulation.Pedal: return "pedal"; + case DrumArticulation.Choke: return "choke"; + default: throw new ArgumentOutOfRangeException(nameof(articulation)); + } + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + + private sealed class ProjectedHit + { + public ProjectedHit( + double timeSeconds, + DrumPad pad, + int velocity, + DrumArticulation articulation, + int recordingIndex) + { + TimeSeconds = timeSeconds; + Pad = pad; + Velocity = velocity; + Articulation = articulation; + RecordingIndex = recordingIndex; + } + public double TimeSeconds { get; } + public DrumPad Pad { get; } + public int Velocity { get; } + public DrumArticulation Articulation { get; } + public int RecordingIndex { get; } + } + } + + public sealed class ChartCreatorExportResult + { + internal ChartCreatorExportResult( + string songId, + string folderPath, + string chartPath, + string manifestPath, + string packagePath, + string localAudioPath) + { + SongId = songId; + FolderPath = folderPath; + ChartPath = chartPath; + ManifestPath = manifestPath; + PackagePath = packagePath; + LocalAudioPath = localAudioPath; + } + public string SongId { get; } + public string FolderPath { get; } + public string ChartPath { get; } + public string ManifestPath { get; } + public string PackagePath { get; } + public string LocalAudioPath { get; } + public bool IsLocallyPlayable => !string.IsNullOrWhiteSpace(LocalAudioPath); + } + + 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, + string sourceAudioPath = null) + { + 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 packagePath = Path.Combine(root, songId + HtkSongPackageService.Extension); + string temporary = Path.Combine(root, $".hitthekit-chart-{Guid.NewGuid():N}"); + Directory.CreateDirectory(temporary); + bool packagePublished = false; + 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, null), Utf8WithoutBom); + + // First validate and publish the shareable chart-only package. Local audio is + // added only to the private folder afterwards and never enters .htksong v1. + new ChartLoader().Load(chartJson, metadata.Difficulty); + new SongLibraryDiscovery().Parse(File.ReadAllText(manifestPath), temporary, SongLibraryOrigin.UserFolder); + new HtkSongPackageService().CreateChartOnlyPackage(temporary, packagePath); + packagePublished = true; + + string localAudioPath = null; + if (!string.IsNullOrWhiteSpace(sourceAudioPath)) + { + string source = ChartAuthoringAudioImporter.ValidateSource(sourceAudioPath); + string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant(); + localAudioPath = Path.Combine(temporary, audioFileName); + File.Copy(source, localAudioPath, false); + File.WriteAllText(manifestPath, Manifest(songId, metadata, audioFileName), Utf8WithoutBom); + SongLibraryEntry local = new SongLibraryDiscovery().Parse( + File.ReadAllText(manifestPath), + temporary, + SongLibraryOrigin.UserFolder); + if (!local.IsPlayable) + throw new InvalidOperationException("The local recorded take did not become playable."); + } + + Directory.Move(temporary, destination); + return new ChartCreatorExportResult( + songId, + destination, + Path.Combine(destination, "notes.json"), + Path.Combine(destination, "song.json"), + packagePath, + localAudioPath == null ? null : Path.Combine(destination, Path.GetFileName(localAudioPath))); + } + catch + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + if (Directory.Exists(destination)) Directory.Delete(destination, true); + if (packagePublished && File.Exists(packagePath)) File.Delete(packagePath); + throw; + } + } + + private static string UniqueSongId(string root, string baseId) + { + string value = baseId; + int suffix = 2; + while (Directory.Exists(Path.Combine(root, value)) || + File.Exists(Path.Combine(root, value + HtkSongPackageService.Extension))) + value = $"{baseId}-{suffix++}"; + ChartCreatorMetadata.ValidateIdentifier(value, nameof(baseId)); + return value; + } + + private static string Manifest(string songId, ChartCreatorMetadata metadata, string audioFileName) + { + string audio = string.IsNullOrWhiteSpace(audioFileName) + ? " \"audioAvailability\": \"missing\",\n" + : " \"audioAvailability\": \"available\",\n" + + $" \"audioFile\": \"{Escape(audioFileName)}\",\n"; + 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" + + audio + + " \"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/ChartDraftEditor.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs new file mode 100644 index 0000000..6634fe6 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using HitTheKit.Core; +using HitTheKit.Unity.Input; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class EditableChartNote + { + internal EditableChartNote( + int identity, + DrumPad pad, + int velocity, + DrumArticulation articulation, + double timeSeconds, + DrumInputSource source) + { + Identity = identity; + Pad = pad; + Velocity = velocity; + Articulation = articulation; + TimeSeconds = timeSeconds; + Source = source; + } + + public int Identity { get; } + public DrumPad Pad { get; internal set; } + public int Velocity { get; internal set; } + public DrumArticulation Articulation { get; internal set; } + public double TimeSeconds { get; internal set; } + public DrumInputSource Source { get; } + } + + public sealed class ChartDraftEditor + { + private readonly double durationSeconds; + private readonly List notes; + private readonly ReadOnlyCollection readOnlyNotes; + private int nextIdentity; + + public ChartDraftEditor(ChartRecordingDraft draft) + { + if (draft == null) throw new ArgumentNullException(nameof(draft)); + durationSeconds = draft.DurationSeconds; + notes = new List(draft.Hits.Count); + for (int index = 0; index < draft.Hits.Count; index++) + { + RecordedChartHit hit = draft.Hits[index]; + notes.Add(new EditableChartNote( + nextIdentity++, hit.Pad, hit.Velocity, hit.Articulation, hit.TimeSeconds, hit.Source)); + } + Sort(); + readOnlyNotes = notes.AsReadOnly(); + } + + public double DurationSeconds => durationSeconds; + public IReadOnlyList Notes => readOnlyNotes; + + public int Update(int index, double timeSeconds, DrumPad pad) + { + EditableChartNote note = At(index); + DrumArticulation articulation = DrumArticulationValidator.IsValid(pad, note.Articulation) + ? note.Articulation + : DrumArticulation.Default; + return Update(index, timeSeconds, pad, note.Velocity, articulation); + } + + public int Update( + int index, + double timeSeconds, + DrumPad pad, + int velocity, + DrumArticulation articulation) + { + EditableChartNote note = At(index); + ValidateTime(timeSeconds); + ValidatePad(pad); + ValidateVelocity(velocity); + DrumArticulationValidator.EnsureValid(pad, articulation); + note.TimeSeconds = timeSeconds; + note.Pad = pad; + note.Velocity = velocity; + note.Articulation = articulation; + Sort(); + return notes.IndexOf(note); + } + + public int Add( + double timeSeconds, + DrumPad pad, + int velocity = 100, + DrumArticulation articulation = DrumArticulation.Default) + { + ValidateTime(timeSeconds); + ValidatePad(pad); + ValidateVelocity(velocity); + DrumArticulationValidator.EnsureValid(pad, articulation); + if (notes.Count >= ChartRecordingSession.MaximumHits) + throw new InvalidOperationException($"A chart cannot exceed {ChartRecordingSession.MaximumHits} notes."); + var note = new EditableChartNote( + nextIdentity++, pad, velocity, articulation, timeSeconds, DrumInputSource.Test); + notes.Add(note); + Sort(); + return notes.IndexOf(note); + } + + public void Delete(int index) => notes.RemoveAt(ValidatedIndex(index)); + + public ChartRecordingDraft BuildDraft() + { + var hits = new RecordedChartHit[notes.Count]; + for (int index = 0; index < notes.Count; index++) + { + EditableChartNote note = notes[index]; + hits[index] = new RecordedChartHit( + new DrumInputEvent( + note.Pad, + note.Velocity, + note.TimeSeconds, + note.Source, + note.Articulation), + index); + } + return new ChartRecordingDraft(durationSeconds, hits); + } + + private EditableChartNote At(int index) => notes[ValidatedIndex(index)]; + + private int ValidatedIndex(int index) + { + if (index < 0 || index >= notes.Count) throw new ArgumentOutOfRangeException(nameof(index)); + return index; + } + + private void ValidateTime(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value) || value < 0 || value > durationSeconds) + throw new ArgumentOutOfRangeException(nameof(value), $"Note time must be between 0 and {durationSeconds:0.###} seconds."); + } + + private static void ValidatePad(DrumPad pad) + { + if (!Enum.IsDefined(typeof(DrumPad), pad)) throw new ArgumentOutOfRangeException(nameof(pad)); + } + + private static void ValidateVelocity(int velocity) + { + if (velocity < 1 || velocity > 127) throw new ArgumentOutOfRangeException(nameof(velocity)); + } + + private void Sort() => notes.Sort((left, right) => + { + int byTime = left.TimeSeconds.CompareTo(right.TimeSeconds); + return byTime != 0 ? byTime : left.Identity.CompareTo(right.Identity); + }); + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta new file mode 100644 index 0000000..66036f9 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartDraftEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1f6bc7d8a244151b6712fdd0bf0fc77 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs new file mode 100644 index 0000000..849bd1f --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs @@ -0,0 +1,257 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace HitTheKit.Unity.Gameplay +{ + public sealed class ChartWaveformModel + { + private readonly float[] peaks; + + public ChartWaveformModel(float[] sourcePeaks, double durationSeconds) + { + if (sourcePeaks == null || sourcePeaks.Length < 2) + throw new ArgumentException("A waveform requires at least two peak values.", nameof(sourcePeaks)); + if (!IsFinite(durationSeconds) || durationSeconds <= 0) + throw new ArgumentOutOfRangeException(nameof(durationSeconds)); + peaks = new float[sourcePeaks.Length]; + for (int index = 0; index < peaks.Length; index++) + { + float value = sourcePeaks[index]; + if (float.IsNaN(value) || float.IsInfinity(value)) + throw new ArgumentException("Waveform peaks must be finite.", nameof(sourcePeaks)); + peaks[index] = Mathf.Clamp01(Mathf.Abs(value)); + } + DurationSeconds = durationSeconds; + ResetZoom(); + } + + public double DurationSeconds { get; } + public double ViewStartSeconds { get; private set; } + public double ViewEndSeconds { get; private set; } + public double SelectedTimeSeconds { get; private set; } + public int PeakCount => peaks.Length; + public float PeakAt(int index) => peaks[index]; + + public double Scrub(double normalizedPosition) + { + if (!IsFinite(normalizedPosition)) throw new ArgumentOutOfRangeException(nameof(normalizedPosition)); + double clamped = Math.Max(0, Math.Min(1, normalizedPosition)); + SelectedTimeSeconds = ViewStartSeconds + (ViewEndSeconds - ViewStartSeconds) * clamped; + return SelectedTimeSeconds; + } + + public void Select(double timeSeconds) + { + if (!IsFinite(timeSeconds)) throw new ArgumentOutOfRangeException(nameof(timeSeconds)); + SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, timeSeconds)); + } + + public void Zoom(double factor) + { + if (!IsFinite(factor) || factor <= 0) throw new ArgumentOutOfRangeException(nameof(factor)); + double current = ViewEndSeconds - ViewStartSeconds; + double minimum = Math.Min(DurationSeconds, 0.5); + double next = Math.Max(minimum, Math.Min(DurationSeconds, current / factor)); + double anchor = SelectedTimeSeconds; + double ratio = current > 0 ? (anchor - ViewStartSeconds) / current : 0.5; + double start = anchor - next * ratio; + start = Math.Max(0, Math.Min(DurationSeconds - next, start)); + ViewStartSeconds = start; + ViewEndSeconds = start + next; + } + + public void ResetZoom() + { + ViewStartSeconds = 0; + ViewEndSeconds = DurationSeconds; + SelectedTimeSeconds = Math.Max(0, Math.Min(DurationSeconds, SelectedTimeSeconds)); + } + + public static ChartWaveformModel FromInterleaved( + float[] samples, + int channels, + double durationSeconds, + int pointCount = 512) + { + if (samples == null || samples.Length == 0) throw new ArgumentException("Samples are required.", nameof(samples)); + if (channels <= 0 || samples.Length % channels != 0) throw new ArgumentOutOfRangeException(nameof(channels)); + if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount)); + var result = new float[pointCount]; + int frames = samples.Length / channels; + for (int point = 0; point < pointCount; point++) + { + int startFrame = (int)((long)point * frames / pointCount); + int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * frames / pointCount)); + endFrame = Math.Min(frames, endFrame); + float peak = 0; + for (int frame = startFrame; frame < endFrame; frame++) + for (int channel = 0; channel < channels; channel++) + peak = Math.Max(peak, Math.Abs(samples[frame * channels + channel])); + result[point] = peak; + } + return new ChartWaveformModel(result, durationSeconds); + } + + public static ChartWaveformModel FromAudioClip( + AudioClip clip, + int pointCount = 512, + int framesPerRead = 4096) + { + if (clip == null) throw new ArgumentNullException(nameof(clip)); + if (clip.samples <= 0 || clip.channels <= 0 || clip.length <= 0) + throw new ArgumentException("Audio clip is empty.", nameof(clip)); + if (pointCount < 2 || pointCount > 4096) throw new ArgumentOutOfRangeException(nameof(pointCount)); + if (framesPerRead <= 0 || framesPerRead > 32768) + throw new ArgumentOutOfRangeException(nameof(framesPerRead)); + + int boundedRead = Math.Min(framesPerRead, clip.samples); + var buffer = new float[boundedRead * clip.channels]; + var result = new float[pointCount]; + for (int point = 0; point < pointCount; point++) + { + int startFrame = (int)((long)point * clip.samples / pointCount); + int endFrame = Math.Max(startFrame + 1, (int)((long)(point + 1) * clip.samples / pointCount)); + endFrame = Math.Min(clip.samples, endFrame); + float peak = 0; + for (int offset = startFrame; offset < endFrame; offset += boundedRead) + { + int frames = Math.Min(boundedRead, endFrame - offset); + if (!clip.GetData(buffer, offset)) + throw new InvalidOperationException("Audio samples could not be read."); + int sampleCount = frames * clip.channels; + for (int index = 0; index < sampleCount; index++) + peak = Math.Max(peak, Math.Abs(buffer[index])); + } + result[point] = peak; + } + return new ChartWaveformModel(result, clip.length); + } + + private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value); + } + + public sealed class ChartWaveformView : VisualElement + { + private bool dragging; + private ChartWaveformModel model; + + public ChartWaveformView() + { + focusable = true; + generateVisualContent += Draw; + RegisterCallback(OnPointerDown); + RegisterCallback(OnPointerMove); + RegisterCallback(OnPointerUp); + RegisterCallback(_ => dragging = false); + } + + public event Action Scrubbed; + public ChartWaveformModel Model => model; + + public void SetModel(ChartWaveformModel value) + { + model = value; + MarkDirtyRepaint(); + } + + public void SetSelectedTime(double value) + { + model?.Select(value); + MarkDirtyRepaint(); + } + + public void Zoom(double factor) + { + model?.Zoom(factor); + MarkDirtyRepaint(); + } + + public void ResetZoom() + { + model?.ResetZoom(); + MarkDirtyRepaint(); + } + + private void OnPointerDown(PointerDownEvent evt) + { + if (evt.button != 0 || model == null) return; + dragging = true; + this.CapturePointer(evt.pointerId); + Scrub(evt.localPosition.x); + evt.StopPropagation(); + } + + private void OnPointerMove(PointerMoveEvent evt) + { + if (!dragging || model == null) return; + Scrub(evt.localPosition.x); + evt.StopPropagation(); + } + + private void OnPointerUp(PointerUpEvent evt) + { + dragging = false; + if (this.HasPointerCapture(evt.pointerId)) this.ReleasePointer(evt.pointerId); + } + + private void Scrub(float x) + { + double width = Math.Max(1, contentRect.width); + double selected = model.Scrub(x / width); + MarkDirtyRepaint(); + Scrubbed?.Invoke(selected); + } + + private void Draw(MeshGenerationContext context) + { + Painter2D painter = context.painter2D; + Rect rect = contentRect; + painter.fillColor = new Color(0.01f, 0.04f, 0.075f, 0.98f); + painter.BeginPath(); + painter.MoveTo(new Vector2(0, 0)); + painter.LineTo(new Vector2(rect.width, 0)); + painter.LineTo(new Vector2(rect.width, rect.height)); + painter.LineTo(new Vector2(0, rect.height)); + painter.ClosePath(); + painter.Fill(); + if (model == null || rect.width <= 1 || rect.height <= 1) return; + + float center = rect.height * 0.5f; + painter.strokeColor = new Color(0.22f, 0.55f, 0.68f, 0.45f); + painter.lineWidth = 1; + painter.BeginPath(); + painter.MoveTo(new Vector2(0, center)); + painter.LineTo(new Vector2(rect.width, center)); + painter.Stroke(); + + double viewDuration = model.ViewEndSeconds - model.ViewStartSeconds; + int first = Math.Max(0, (int)Math.Floor(model.ViewStartSeconds / model.DurationSeconds * (model.PeakCount - 1))); + int last = Math.Min(model.PeakCount - 1, (int)Math.Ceiling(model.ViewEndSeconds / model.DurationSeconds * (model.PeakCount - 1))); + painter.strokeColor = new Color(0.24f, 0.86f, 1f, 0.9f); + painter.lineWidth = Math.Max(1, rect.width / Math.Max(1, last - first + 1)); + for (int index = first; index <= last; index++) + { + double time = index / (double)(model.PeakCount - 1) * model.DurationSeconds; + float x = (float)((time - model.ViewStartSeconds) / viewDuration * rect.width); + float amplitude = model.PeakAt(index) * (rect.height * 0.42f); + painter.BeginPath(); + painter.MoveTo(new Vector2(x, center - amplitude)); + painter.LineTo(new Vector2(x, center + amplitude)); + painter.Stroke(); + } + + if (model.SelectedTimeSeconds >= model.ViewStartSeconds && + model.SelectedTimeSeconds <= model.ViewEndSeconds) + { + float x = (float)((model.SelectedTimeSeconds - model.ViewStartSeconds) / viewDuration * rect.width); + painter.strokeColor = new Color(1f, 0.67f, 0.18f, 1f); + painter.lineWidth = 2.5f; + painter.BeginPath(); + painter.MoveTo(new Vector2(x, 0)); + painter.LineTo(new Vector2(x, rect.height)); + painter.Stroke(); + } + } + } +} diff --git a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta new file mode 100644 index 0000000..d4d7a02 --- /dev/null +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartWaveform.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b0d8b53ae8a42c2bfc569bf322e7f35 +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..46d353f 100644 --- a/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs +++ b/src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/GameplayHighwayController.cs @@ -1,6 +1,8 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; +using System.IO; using HitTheKit.Core; using HitTheKit.Unity.Audio; using HitTheKit.Unity.Charts; @@ -75,6 +77,32 @@ public sealed class GameplayHighwayController : MonoBehaviour private Label resultBreakdownLabel; private Label resultPracticeLabel; private Label resultCalibrationLabel; + private VisualElement resultPerformancePanel; + private VisualElement chartCreatorResults; + private Label chartCreatorSummaryLabel; + private Label chartCreatorStatusLabel; + private Button chartSaveRawButton; + private Button chartSaveEighthButton; + private Button chartSaveSixteenthButton; + private ListView chartNoteList; + private Label chartNoteSelectionLabel; + private TextField chartNoteTimeField; + private DropdownField chartNotePadField; + private TextField chartNoteVelocityField; + private DropdownField chartNoteArticulationField; + private Button chartNoteAddButton; + private Button chartNoteApplyButton; + private Button chartNoteDeleteButton; + private Label chartNoteStatusLabel; + private VisualElement chartWaveformHost; + private ChartWaveformView chartWaveformView; + private Label chartWaveformTimeLabel; + private Button chartWaveformZoomInButton; + private Button chartWaveformZoomOutButton; + private Button chartWaveformResetButton; + private Button chartWaveformPreviewButton; + private Button chartWaveformStopButton; + private readonly List chartArticulationChoices = new List(); private GameplayHighwaySurface surface; private GameplayKitSurface kitSurface; private bool showInstructionalKit; @@ -93,6 +121,9 @@ public sealed class GameplayHighwayController : MonoBehaviour private AudioClip metronomeClip; private bool metronomeScheduled; private bool resultRecorded; + private ChartRecordingSession chartRecording; + private ChartRecordingDraft chartDraft; + private ChartDraftEditor chartDraftEditor; public event Action ThemeChanged; @@ -114,6 +145,10 @@ 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; } + public string LastChartPackagePath { get; private set; } + public int EditableChartNoteCount => chartDraftEditor?.Notes.Count ?? 0; private void Awake() { @@ -141,6 +176,7 @@ private void Start() BindNavigation(); Subscribe(); InitializeAudioFeedback(); + InitializeChartCreator(); SetTheme(CurrentSession.Theme); RefreshSessionCopy(); } @@ -165,6 +201,7 @@ private void OnDisable() private void OnDestroy() { FlushPracticeTime(); + songClock?.StopPreview(); ReleaseAudioFeedback(); } @@ -264,6 +301,32 @@ private void BindView() resultBreakdownLabel = root.Q