Skip to content

Commit 82dad46

Browse files
committed
feat(charts): import local audio for authoring
1 parent 798c6c2 commit 82dad46

15 files changed

Lines changed: 885 additions & 33 deletions

docs/development/chart-creator.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ timeline.
77

88
## Workflow
99

10-
1. Add or select a playable song in the Song Library.
11-
2. Choose its difficulty and practice speed.
12-
3. Select **Record chart**.
10+
1. Select **Import audio & create** in the Song Library and choose a local WAV
11+
or OGG file, or select an existing playable song.
12+
2. For new audio, enter title, artist, verified BPM, bar count and beats per bar.
13+
HitTheKit copies the selected audio into a private local authoring folder; it
14+
never modifies the source file.
15+
3. Choose practice speed and select **Record chart**. A new audio-only source
16+
starts with a real empty schema-v1 timeline; an existing song keeps its
17+
current chart only as a playback reference.
1318
4. Play during the normal count-in and backing track. Hits before song time zero
1419
or beyond the declared song duration are ignored.
1520
5. At the result screen, review the captured-hit count and save the raw timing,
@@ -42,10 +47,11 @@ game validates and atomically imports it. Existing song folders are never
4247
overwritten.
4348

4449
The publish is atomic and both documents are parsed by the production loaders
45-
before the folder or package becomes visible. The manifest declares chart availability but
46-
keeps audio as `missing`. Chart Creator never copies, embeds, downloads, or
47-
redistributes the source audio. To play or share the take, the user must add an
48-
audio file they are entitled to use and update the local binding explicitly.
50+
before the folder or package becomes visible. When the recording used a local
51+
WAV/OGG source, the private exported folder receives its own local audio copy and
52+
is immediately playable. The `.htksong` manifest still declares audio as
53+
`missing`: Chart Creator never embeds, downloads, or redistributes source audio
54+
in the portable package. A recipient supplies their own authorized local copy.
4955

5056
Import is fail-closed. Unknown/archive entries, audio declarations, symbolic
5157
links, duplicate names, unsupported versions, malformed JSON, invalid charts,
@@ -59,10 +65,13 @@ not a claim of authoritative transcription.
5965

6066
## Current foundation limits
6167

62-
- A playable Song Library entry is required; audio import/file-picker UI is not
63-
part of this first foundation.
6468
- Editing individual notes is not yet available. Raw/1/8/1/16 save choices are
6569
the initial review tools.
6670
- The schema currently stores pad and time. Velocity and articulation remain in
6771
the in-memory take but schema v1 does not serialize them.
68-
- Exported takes are intentionally non-playable until authorized audio is bound.
72+
- The native picker currently targets macOS. WAV and OGG are supported; MP3 is
73+
intentionally rejected by the production loader.
74+
- The author must enter BPM, bars and meter explicitly. Unknown timing never
75+
receives a hidden default.
76+
- Portable packages remain chart-only; imported packages require an authorized
77+
local audio binding on the receiving computer.

docs/development/song-library.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ The refresh button performs an explicit rescan. Discovery does not watch the
9595
filesystem continuously, allocate every frame, or access folders outside the
9696
two configured roots.
9797

98+
## Local audio authoring
99+
100+
The Song Library's **Import audio & create** action opens the macOS file picker
101+
for WAV/OGG content. After the player supplies title, artist and verified timing
102+
metadata, the importer copies the selected file into a new, never-overwritten
103+
folder under `~/Documents/HTKSongs`. The source file is not changed and its
104+
absolute path is not persisted in `song.json`.
105+
106+
The resulting entry is intentionally audio-only: it is not playable as a normal
107+
song, but it can start Chart Creator with the existing DSP clock and an empty
108+
timeline. Once a take is saved, the local folder contains authorized audio plus
109+
the new chart and is playable immediately. Its sibling `.htksong` remains
110+
chart-only for safe transfer.
111+
98112
## Portable `.htksong` chart packages
99113

100114
Chart Creator writes a portable chart-only package next to each recorded take.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System;
2+
using System.Diagnostics;
3+
using System.Globalization;
4+
using System.IO;
5+
using System.Text;
6+
using UnityEngine;
7+
8+
namespace HitTheKit.Unity.Gameplay
9+
{
10+
public sealed class ChartAuthoringAudioRequest
11+
{
12+
public ChartAuthoringAudioRequest(
13+
string sourceAudioPath,
14+
string title,
15+
string artist,
16+
double bpm,
17+
int bars,
18+
int beatsPerBar)
19+
{
20+
if (string.IsNullOrWhiteSpace(sourceAudioPath))
21+
throw new ArgumentException("An audio file is required.", nameof(sourceAudioPath));
22+
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Title is required.", nameof(title));
23+
if (string.IsNullOrWhiteSpace(artist)) throw new ArgumentException("Artist is required.", nameof(artist));
24+
if (!IsFinite(bpm) || bpm <= 0 || bpm > 400) throw new ArgumentOutOfRangeException(nameof(bpm));
25+
if (bars <= 0 || bars > 10000) throw new ArgumentOutOfRangeException(nameof(bars));
26+
if (beatsPerBar <= 0 || beatsPerBar > 32) throw new ArgumentOutOfRangeException(nameof(beatsPerBar));
27+
28+
SourceAudioPath = Path.GetFullPath(sourceAudioPath);
29+
Title = title.Trim();
30+
Artist = artist.Trim();
31+
Bpm = bpm;
32+
Bars = bars;
33+
BeatsPerBar = beatsPerBar;
34+
}
35+
36+
public string SourceAudioPath { get; }
37+
public string Title { get; }
38+
public string Artist { get; }
39+
public double Bpm { get; }
40+
public int Bars { get; }
41+
public int BeatsPerBar { get; }
42+
43+
private static bool IsFinite(double value) => !double.IsNaN(value) && !double.IsInfinity(value);
44+
}
45+
46+
public sealed class ChartAuthoringAudioImportResult
47+
{
48+
internal ChartAuthoringAudioImportResult(SongLibraryEntry song, string sourcePath)
49+
{
50+
Song = song;
51+
SourcePath = sourcePath;
52+
}
53+
54+
public SongLibraryEntry Song { get; }
55+
public string SourcePath { get; }
56+
}
57+
58+
public sealed class ChartAuthoringAudioImportException : Exception
59+
{
60+
public ChartAuthoringAudioImportException(string message) : base(message) { }
61+
public ChartAuthoringAudioImportException(string message, Exception innerException) : base(message, innerException) { }
62+
}
63+
64+
public sealed class ChartAuthoringAudioImporter
65+
{
66+
public const long MaximumAudioBytes = 1024L * 1024 * 1024;
67+
private static readonly UTF8Encoding Utf8WithoutBom = new UTF8Encoding(false);
68+
69+
public ChartAuthoringAudioImportResult Import(
70+
ChartAuthoringAudioRequest request,
71+
string libraryRoot,
72+
DateTimeOffset createdAtUtc)
73+
{
74+
if (request == null) throw new ArgumentNullException(nameof(request));
75+
if (string.IsNullOrWhiteSpace(libraryRoot))
76+
throw new ArgumentException("Song-library root is required.", nameof(libraryRoot));
77+
if (createdAtUtc.Offset != TimeSpan.Zero)
78+
throw new ArgumentException("Creation time must be UTC.", nameof(createdAtUtc));
79+
80+
string source = ValidateSource(request.SourceAudioPath);
81+
string root = Path.GetFullPath(libraryRoot);
82+
Directory.CreateDirectory(root);
83+
string baseId = BuildId(request.Artist, request.Title, createdAtUtc);
84+
string songId = UniqueSongId(root, baseId);
85+
string destination = Path.Combine(root, songId);
86+
string temporary = Path.Combine(root, $".hitthekit-audio-{Guid.NewGuid():N}");
87+
string audioFileName = "source-audio" + Path.GetExtension(source).ToLowerInvariant();
88+
Directory.CreateDirectory(temporary);
89+
90+
try
91+
{
92+
string copiedAudio = Path.Combine(temporary, audioFileName);
93+
File.Copy(source, copiedAudio, false);
94+
string manifest = Manifest(songId, request, audioFileName);
95+
File.WriteAllText(Path.Combine(temporary, SongLibraryDiscovery.ManifestFileName), manifest, Utf8WithoutBom);
96+
SongLibraryEntry validated = new SongLibraryDiscovery().Parse(
97+
manifest,
98+
temporary,
99+
SongLibraryOrigin.UserFolder);
100+
if (!validated.CanAuthorChart || validated.IsPlayable)
101+
throw new ChartAuthoringAudioImportException("The imported audio did not produce a valid authoring source.");
102+
103+
Directory.Move(temporary, destination);
104+
SongLibraryEntry published = new SongLibraryDiscovery().Parse(
105+
File.ReadAllText(Path.Combine(destination, SongLibraryDiscovery.ManifestFileName)),
106+
destination,
107+
SongLibraryOrigin.UserFolder);
108+
return new ChartAuthoringAudioImportResult(published, source);
109+
}
110+
catch (ChartAuthoringAudioImportException)
111+
{
112+
if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
113+
throw;
114+
}
115+
catch (Exception exception)
116+
{
117+
if (Directory.Exists(temporary)) Directory.Delete(temporary, true);
118+
throw new ChartAuthoringAudioImportException("The selected audio could not be imported.", exception);
119+
}
120+
}
121+
122+
public static string ValidateSource(string sourceAudioPath)
123+
{
124+
if (string.IsNullOrWhiteSpace(sourceAudioPath))
125+
throw new ChartAuthoringAudioImportException("Select a WAV or OGG audio file.");
126+
string source = Path.GetFullPath(sourceAudioPath);
127+
var info = new FileInfo(source);
128+
if (!info.Exists || info.Length <= 0 || info.Length > MaximumAudioBytes)
129+
throw new ChartAuthoringAudioImportException("Audio must be between 1 byte and 1 GiB.");
130+
if ((File.GetAttributes(source) & FileAttributes.ReparsePoint) != 0)
131+
throw new ChartAuthoringAudioImportException("The selected audio cannot be a symbolic link.");
132+
string extension = Path.GetExtension(source);
133+
if (!string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase) &&
134+
!string.Equals(extension, ".ogg", StringComparison.OrdinalIgnoreCase))
135+
throw new ChartAuthoringAudioImportException("Chart Creator supports WAV and OGG audio.");
136+
return source;
137+
}
138+
139+
private static string UniqueSongId(string root, string baseId)
140+
{
141+
string candidate = baseId;
142+
int suffix = 2;
143+
while (Directory.Exists(Path.Combine(root, candidate)) ||
144+
File.Exists(Path.Combine(root, candidate + HtkSongPackageService.Extension)))
145+
candidate = $"{baseId}-{suffix++}";
146+
ChartCreatorMetadata.ValidateIdentifier(candidate, nameof(baseId));
147+
return candidate;
148+
}
149+
150+
private static string BuildId(string artist, string title, DateTimeOffset createdAtUtc)
151+
{
152+
string text = (artist + "-" + title).Normalize(NormalizationForm.FormD);
153+
var id = new StringBuilder(64);
154+
bool separator = false;
155+
for (int index = 0; index < text.Length && id.Length < 48; index++)
156+
{
157+
char value = char.ToLowerInvariant(text[index]);
158+
if (value >= 'a' && value <= 'z' || value >= '0' && value <= '9')
159+
{
160+
id.Append(value);
161+
separator = false;
162+
}
163+
else if (id.Length > 0 && !separator)
164+
{
165+
id.Append('-');
166+
separator = true;
167+
}
168+
}
169+
string prefix = id.ToString().Trim('-');
170+
if (prefix.Length == 0) prefix = "local-song";
171+
return $"{prefix}-authoring-{createdAtUtc:yyyyMMdd-HHmmss}";
172+
}
173+
174+
private static string Manifest(
175+
string songId,
176+
ChartAuthoringAudioRequest request,
177+
string audioFileName)
178+
{
179+
return "{\n" +
180+
" \"schemaVersion\": 1,\n" +
181+
$" \"id\": \"{Escape(songId)}\",\n" +
182+
$" \"title\": \"{Escape(request.Title)}\",\n" +
183+
$" \"artist\": \"{Escape(request.Artist)}\",\n" +
184+
$" \"bpm\": {request.Bpm.ToString("0.#########", CultureInfo.InvariantCulture)},\n" +
185+
$" \"bars\": {request.Bars},\n" +
186+
$" \"beatsPerBar\": {request.BeatsPerBar},\n" +
187+
" \"difficultyHint\": \"New local chart\",\n" +
188+
" \"sortOrder\": 0,\n" +
189+
" \"audioAvailability\": \"available\",\n" +
190+
$" \"audioFile\": \"{Escape(audioFileName)}\",\n" +
191+
" \"chartAvailability\": \"unavailable\"\n" +
192+
"}\n";
193+
}
194+
195+
private static string Escape(string value) => value
196+
.Replace("\\", "\\\\")
197+
.Replace("\"", "\\\"")
198+
.Replace("\r", "\\r")
199+
.Replace("\n", "\\n");
200+
}
201+
202+
public interface IChartAuthoringAudioPicker
203+
{
204+
string PickAudioFile();
205+
}
206+
207+
public sealed class MacOsChartAuthoringAudioPicker : IChartAuthoringAudioPicker
208+
{
209+
public string PickAudioFile()
210+
{
211+
#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
212+
const string script =
213+
"const app = Application.currentApplication(); " +
214+
"app.includeStandardAdditions = true; " +
215+
"const chosen = app.chooseFile({withPrompt: 'Choose a WAV or OGG backing track'}); " +
216+
"Path(chosen).toString();";
217+
var start = new ProcessStartInfo
218+
{
219+
FileName = "/usr/bin/osascript",
220+
Arguments = "-l JavaScript -e \"" +
221+
script.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"",
222+
UseShellExecute = false,
223+
RedirectStandardOutput = true,
224+
RedirectStandardError = true,
225+
CreateNoWindow = true
226+
};
227+
using (Process process = Process.Start(start))
228+
{
229+
if (process == null) throw new InvalidOperationException("The macOS audio picker could not start.");
230+
string output = process.StandardOutput.ReadToEnd();
231+
process.StandardError.ReadToEnd();
232+
process.WaitForExit();
233+
return process.ExitCode == 0 ? output.Trim() : null;
234+
}
235+
#else
236+
throw new PlatformNotSupportedException("The native audio picker currently supports macOS.");
237+
#endif
238+
}
239+
}
240+
}

src/HitTheKit.Unity/Assets/HitTheKit/Runtime/Gameplay/ChartAuthoringAudio.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)