JacobLinCool commited on
Commit
dfa1fb3
·
verified ·
1 Parent(s): 4bfd79c

Replace the preview kit with calibrated modal synthesis

Browse files
README.md CHANGED
@@ -29,11 +29,11 @@ The Space uses Gradio Server mode with a fully custom, responsive interface.
29
  Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
30
  full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
31
  synchronized WAV mix that lets you hear the generated chart performed over
32
- the source music immediately. The original, arcade-tuned kit is built from
33
- hash-pinned CC0 head and rim recordings from the
34
- [Chinese Bass Drum Dagu Oneshots](https://freesound.org/people/sazanami12/packs/45781/)
35
- pack. SoftChart transforms those recordings offline and does not contain audio
36
- taken from a commercial game.
37
 
38
  The generation API streams the real processing stages to the interface:
39
  audio analysis, beat-grid fitting, song-level planning, note generation,
 
29
  Upload a song, pick a difficulty, and get a full-song mel/plan preview, a
30
  full TJA chart image, a `.tja` playable in TJAPlayer3 / OpenTaiko, and a
31
  synchronized WAV mix that lets you hear the generated chart performed over
32
+ the source music immediately. Its Don, Big Don, and Katsu voices are original
33
+ modal-resonator syntheses built from seeded excitation noise, decaying drum
34
+ modes, pitch sweeps, and band-limited stick transients. Reference recordings
35
+ are used only for offline envelope and level calibration; they are not copied,
36
+ mixed, embedded, or required by the Space.
37
 
38
  The generation API streams the real processing stages to the interface:
39
  audio analysis, beat-grid fitting, song-level planning, note generation,
scripts/build_taiko_bank.py DELETED
@@ -1,285 +0,0 @@
1
- # /// script
2
- # requires-python = ">=3.11"
3
- # dependencies = ["numpy<2.1", "scipy", "soundfile"]
4
- # ///
5
- """Build SoftChart's redistributable arcade Taiko one-shot bank.
6
-
7
- The input directory must contain the pinned CC0 Freesound HQ OGG previews
8
- listed in ``SOURCES``. This script is intentionally networkless: it verifies
9
- every source hash, renders deterministic mono PCM16 assets, and writes a
10
- provenance manifest. Commercial-game reference audio is never an input.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import argparse
16
- import hashlib
17
- import json
18
- import math
19
- import wave
20
- from dataclasses import dataclass
21
- from fractions import Fraction
22
- from pathlib import Path
23
-
24
- import numpy as np
25
- import soundfile as sf
26
- from scipy.signal import butter, resample_poly, sosfilt
27
-
28
-
29
- SAMPLE_RATE = 44_100
30
- PACK_URL = "https://freesound.org/people/sazanami12/packs/45781/"
31
- CC0_URL = "https://creativecommons.org/publicdomain/zero/1.0/"
32
-
33
-
34
- @dataclass(frozen=True)
35
- class SourceSpec:
36
- sound_id: int
37
- label: str
38
- sha256: str
39
- preview_url: str
40
-
41
-
42
- SOURCES = {
43
- 856550: SourceSpec(856550, "center-soft", "78ed9dea56102480811e90a9653c08fe20a0eca5793c9c936f1c2592042b9a46", "https://cdn.freesound.org/previews/856/856550_17110092-hq.ogg"),
44
- 856552: SourceSpec(856552, "edge-soft", "85600ed1ddfab1c56fe07268f5fd68073706da8cd9a2f0cf7f16bde30bb8bb3e", "https://cdn.freesound.org/previews/856/856552_17110092-hq.ogg"),
45
- 856557: SourceSpec(856557, "rim-right", "6c24ee738d5995121fa85a432223a2afadb585f2a67b253747e2db0b3796bca0", "https://cdn.freesound.org/previews/856/856557_17110092-hq.ogg"),
46
- 856558: SourceSpec(856558, "rim-left", "5ac219e4254f27c4a3b5b581f5c67e7c72fe91fa3d0fb2e03d021793febc1872", "https://cdn.freesound.org/previews/856/856558_17110092-hq.ogg"),
47
- 856559: SourceSpec(856559, "rim-both-loud", "840a559e5d979e483cec2afb64e4f71a27d31c753c1bb138395303c5bd0d8253", "https://cdn.freesound.org/previews/856/856559_17110092-hq.ogg"),
48
- }
49
-
50
-
51
- def _sha256(path: Path) -> str:
52
- digest = hashlib.sha256()
53
- with path.open("rb") as handle:
54
- for block in iter(lambda: handle.read(1 << 20), b""):
55
- digest.update(block)
56
- return digest.hexdigest()
57
-
58
-
59
- def _load_source(source_dir: Path, sound_id: int) -> np.ndarray:
60
- spec = SOURCES[sound_id]
61
- path = source_dir / f"{sound_id}.ogg"
62
- if not path.is_file():
63
- raise FileNotFoundError(f"missing pinned CC0 source: {path}")
64
- actual_hash = _sha256(path)
65
- if actual_hash != spec.sha256:
66
- raise ValueError(
67
- f"source hash mismatch for {path.name}: {actual_hash} != {spec.sha256}"
68
- )
69
- audio, sample_rate = sf.read(path, dtype="float64", always_2d=True)
70
- if sample_rate != SAMPLE_RATE or audio.shape[1] != 2:
71
- raise ValueError(f"{path.name} must decode as 44.1 kHz stereo")
72
- mono = np.mean(audio, axis=1)
73
- peak = float(np.max(np.abs(mono)))
74
- onset_candidates = np.flatnonzero(np.abs(mono) >= peak * 10.0 ** (-30.0 / 20.0))
75
- if len(onset_candidates) == 0:
76
- raise ValueError(f"{path.name} has no measurable onset")
77
- start = max(0, int(onset_candidates[0]) - int(round(0.001 * SAMPLE_RATE)))
78
- mono = mono[start:].copy()
79
- mono -= float(np.mean(mono[: min(100, len(mono))]))
80
- return mono
81
-
82
-
83
- def _highpass(signal: np.ndarray, cutoff_hz: float) -> np.ndarray:
84
- sos = butter(3, cutoff_hz, btype="highpass", fs=SAMPLE_RATE, output="sos")
85
- return sosfilt(sos, signal)
86
-
87
-
88
- def _pitch(signal: np.ndarray, factor: float) -> np.ndarray:
89
- ratio = Fraction(1.0 / factor).limit_denominator(1_000)
90
- return resample_poly(signal, ratio.numerator, ratio.denominator)
91
-
92
-
93
- def _fit(signal: np.ndarray, frames: int) -> np.ndarray:
94
- if len(signal) < frames:
95
- return np.pad(signal, (0, frames - len(signal)))
96
- return signal[:frames].copy()
97
-
98
-
99
- def _normalize(signal: np.ndarray, peak: float = 1.0) -> np.ndarray:
100
- measured = float(np.max(np.abs(signal)))
101
- if measured <= 1e-12:
102
- raise ValueError("cannot normalize a silent sample")
103
- return signal * (peak / measured)
104
-
105
-
106
- def _smooth_ramp(frames: int, seconds: float) -> np.ndarray:
107
- position = np.arange(frames, dtype=np.float64) / SAMPLE_RATE
108
- unit = np.clip(position / seconds, 0.0, 1.0)
109
- return 0.06 + 0.94 * (unit * unit * (3.0 - 2.0 * unit))
110
-
111
-
112
- def _tail_fade(signal: np.ndarray, start_seconds: float, end_seconds: float) -> np.ndarray:
113
- start = int(round(start_seconds * SAMPLE_RATE))
114
- end = min(len(signal), int(round(end_seconds * SAMPLE_RATE)))
115
- if not 0 <= start < end <= len(signal):
116
- raise ValueError("invalid tail fade")
117
- envelope = np.ones(len(signal), dtype=np.float64)
118
- envelope[start:end] = 0.5 + 0.5 * np.cos(np.linspace(0.0, np.pi, end - start))
119
- envelope[end:] = 0.0
120
- return signal * envelope
121
-
122
-
123
- def _transient(source: dict[int, np.ndarray]) -> np.ndarray:
124
- rim = _highpass(_pitch(source[856559], 2.15), 450.0)
125
- search = rim[: int(round(0.100 * SAMPLE_RATE))]
126
- peak = int(np.argmax(np.abs(search)))
127
- start = max(0, peak - int(round(0.002 * SAMPLE_RATE)))
128
- return _normalize(_fit(rim[start:], int(round(0.040 * SAMPLE_RATE))))
129
-
130
-
131
- def _don_body(source: dict[int, np.ndarray], sound_id: int, frames: int,
132
- ramp_seconds: float) -> np.ndarray:
133
- body = _fit(_normalize(_highpass(source[sound_id], 35.0)), frames)
134
- return body * _smooth_ramp(frames, ramp_seconds)
135
-
136
-
137
- def _make_don(source: dict[int, np.ndarray], sound_id: int,
138
- fade_start: float, fade_end: float) -> np.ndarray:
139
- frames = int(round(0.830 * SAMPLE_RATE))
140
- body = _don_body(source, sound_id, frames, 0.060)
141
- hit = body + 0.15 * _fit(_transient(source), frames)
142
- hit = np.tanh(_tail_fade(hit, fade_start, fade_end))
143
- return _normalize(hit, 0.90)
144
-
145
-
146
- def _make_big_don(source: dict[int, np.ndarray]) -> np.ndarray:
147
- frames = int(round(0.895 * SAMPLE_RATE))
148
- center = _don_body(source, 856550, frames, 0.020)
149
- hit = center + 0.60 * _fit(_transient(source), frames)
150
- hit = np.tanh(_tail_fade(hit, 0.380, 0.800))
151
- return _normalize(hit, 0.94)
152
-
153
-
154
- def _make_ka(source: dict[int, np.ndarray], primary_id: int,
155
- secondary_id: int, secondary_gain: float) -> np.ndarray:
156
- frames = int(round(0.407 * SAMPLE_RATE))
157
- primary_raw = _pitch(source[primary_id], 2.15)
158
- primary = _normalize(_highpass(primary_raw, 450.0))
159
- secondary = _normalize(_highpass(_pitch(source[secondary_id], 0.85), 450.0))
160
- hit = _fit(primary, frames) + secondary_gain * _fit(secondary, frames)
161
- bright = _normalize(_highpass(_pitch(source[primary_id], 4.30), 1_800.0))
162
- search = bright[: int(round(0.050 * SAMPLE_RATE))]
163
- bright_peak = int(np.argmax(np.abs(search)))
164
- bright_start = max(0, bright_peak - int(round(0.001 * SAMPLE_RATE)))
165
- bright = _fit(bright[bright_start:], int(round(0.015 * SAMPLE_RATE)))
166
- hit += 0.90 * _fit(bright, frames)
167
- hit = _tail_fade(hit, 0.230, 0.407)
168
- return _normalize(hit, 0.94)
169
-
170
-
171
- def _short_hit(signal: np.ndarray, duration: float, fade_start: float,
172
- peak: float) -> np.ndarray:
173
- frames = int(round(duration * SAMPLE_RATE))
174
- hit = _fit(signal, frames)
175
- hit = _tail_fade(hit, fade_start, duration)
176
- return _normalize(hit, peak)
177
-
178
-
179
- def _build_assets(source: dict[int, np.ndarray]) -> dict[str, tuple[str, int, np.ndarray, list[int]]]:
180
- don_1 = _make_don(source, 856552, 0.280, 0.710)
181
- don_2 = _make_don(source, 856550, 0.220, 0.600)
182
- don_big = _make_big_don(source)
183
- ka_1 = _make_ka(source, 856559, 856557, 0.20)
184
- ka_2 = _make_ka(source, 856557, 856558, 0.15)
185
- ka_big = _normalize(0.70 * ka_1 + 0.70 * ka_2, 0.96)
186
- roll_1 = _short_hit(don_1, 0.150, 0.065, 0.56)
187
- roll_2 = _short_hit(don_2, 0.150, 0.065, 0.56)
188
- roll_big_1 = _short_hit(don_big, 0.175, 0.080, 0.66)
189
- roll_big_2 = _short_hit(0.72 * don_big + 0.28 * _fit(don_1, len(don_big)), 0.175, 0.080, 0.66)
190
- balloon_1 = _short_hit(don_1, 0.135, 0.055, 0.54)
191
- balloon_2 = _short_hit(don_2, 0.135, 0.055, 0.54)
192
- return {
193
- "don-01.wav": ("don", 1, don_1, [856552, 856559]),
194
- "don-02.wav": ("don", 2, don_2, [856550, 856559]),
195
- "don-big.wav": ("don_big", 1, don_big, [856550, 856559]),
196
- "ka-01.wav": ("ka", 1, ka_1, [856559, 856557]),
197
- "ka-02.wav": ("ka", 2, ka_2, [856557, 856558]),
198
- "ka-big.wav": ("ka_big", 1, ka_big, [856559, 856557, 856558]),
199
- "roll-01.wav": ("roll", 1, roll_1, [856552, 856559]),
200
- "roll-02.wav": ("roll", 2, roll_2, [856550, 856559]),
201
- "roll-big-01.wav": ("roll_big", 1, roll_big_1, [856550, 856559]),
202
- "roll-big-02.wav": ("roll_big", 2, roll_big_2, [856550, 856552, 856559]),
203
- "balloon-01.wav": ("balloon", 1, balloon_1, [856552, 856559]),
204
- "balloon-02.wav": ("balloon", 2, balloon_2, [856550, 856559]),
205
- }
206
-
207
-
208
- def _profile(signal: np.ndarray) -> dict[str, float]:
209
- energy = signal * signal
210
- cumulative = np.cumsum(energy)
211
- spectrum = np.abs(np.fft.rfft(signal * np.hanning(len(signal)))) ** 2
212
- frequency = np.fft.rfftfreq(len(signal), 1.0 / SAMPLE_RATE)
213
- spectrum /= max(float(np.sum(spectrum)), 1e-15)
214
- attack = signal[: int(round(0.010 * SAMPLE_RATE))]
215
- attack_spectrum = np.abs(np.fft.rfft(attack * np.hanning(len(attack)))) ** 2
216
- attack_frequency = np.fft.rfftfreq(len(attack), 1.0 / SAMPLE_RATE)
217
- attack_spectrum /= max(float(np.sum(attack_spectrum)), 1e-15)
218
- return {
219
- "centroid_hz": round(float(frequency @ spectrum), 3),
220
- "attack_centroid_hz": round(float(attack_frequency @ attack_spectrum), 3),
221
- "t90_seconds": round(float(np.searchsorted(cumulative, 0.90 * cumulative[-1]) / SAMPLE_RATE), 6),
222
- "t99_seconds": round(float(np.searchsorted(cumulative, 0.99 * cumulative[-1]) / SAMPLE_RATE), 6),
223
- "crest_db": round(float(20.0 * math.log10(np.max(np.abs(signal)) / np.sqrt(np.mean(energy)))), 3),
224
- }
225
-
226
-
227
- def _write_pcm16(path: Path, signal: np.ndarray) -> tuple[str, str, int]:
228
- pcm = np.rint(np.clip(signal, -1.0, 1.0) * 32767.0).astype("<i2")
229
- with wave.open(str(path), "wb") as output:
230
- output.setnchannels(1)
231
- output.setsampwidth(2)
232
- output.setframerate(SAMPLE_RATE)
233
- output.writeframes(pcm.tobytes())
234
- return _sha256(path), hashlib.sha256(pcm.tobytes()).hexdigest(), len(pcm)
235
-
236
-
237
- def build(source_dir: Path, output_dir: Path) -> None:
238
- source = {sound_id: _load_source(source_dir, sound_id) for sound_id in SOURCES}
239
- output_dir.mkdir(parents=True, exist_ok=True)
240
- files = []
241
- for filename, (kind, round_robin, signal, source_ids) in _build_assets(source).items():
242
- file_hash, pcm_hash, frames = _write_pcm16(output_dir / filename, signal)
243
- peak = float(np.max(np.abs(signal)))
244
- anchor = int(np.flatnonzero(np.abs(signal) >= peak * 10.0 ** (-30.0 / 20.0))[0])
245
- files.append({
246
- "filename": filename,
247
- "kind": kind,
248
- "round_robin": round_robin,
249
- "sha256": file_hash,
250
- "pcm_sha256": pcm_hash,
251
- "sample_rate": SAMPLE_RATE,
252
- "channels": 1,
253
- "frames": frames,
254
- "anchor_frame": anchor,
255
- "source_sound_ids": source_ids,
256
- "profile": _profile(signal),
257
- })
258
- manifest = {
259
- "schema_version": 1,
260
- "kit_id": "softchart-arcade-taiko-cc0",
261
- "license": "CC0-1.0",
262
- "license_url": CC0_URL,
263
- "source_pack_url": PACK_URL,
264
- "source_creator": "sazanami12",
265
- "source_instrument": "Chinese Huapengu / Dagu recorded in Taipei, Taiwan",
266
- "source_encoding": "public Freesound HQ OGG preview of the CC0 original WAV",
267
- "sources": [spec.__dict__ for spec in SOURCES.values()],
268
- "files": sorted(files, key=lambda item: item["filename"]),
269
- }
270
- (output_dir / "manifest.json").write_text(
271
- json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
272
- encoding="utf-8",
273
- )
274
-
275
-
276
- def main() -> None:
277
- parser = argparse.ArgumentParser()
278
- parser.add_argument("--source-dir", type=Path, required=True)
279
- parser.add_argument("--output-dir", type=Path, required=True)
280
- args = parser.parse_args()
281
- build(args.source_dir, args.output_dir)
282
-
283
-
284
- if __name__ == "__main__":
285
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/build_taiko_synth.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = ["numpy<2.1", "scipy"]
4
+ # ///
5
+ """Build SoftChart's three-voice modal-resonator Taiko preview bank.
6
+
7
+ The supplied reference WAVs are used offline for RMS-envelope and level
8
+ calibration only. Reference samples, paths, hashes, and envelope arrays are
9
+ never written to the output. Every output sample is generated from a seeded
10
+ noise/impulse excitation, modal resonators, and a decaying pitch sweep.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import hashlib
17
+ import json
18
+ import math
19
+ import wave
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+ from scipy.signal import butter, sosfilt
25
+
26
+
27
+ SAMPLE_RATE = 48_000
28
+ RANDOM_SEED = 20_260_713
29
+ BUILD_ORDER = ("Big Don", "Don", "Katsu")
30
+ EXPECTED_FRAMES = {"Big Don": 42_946, "Don": 39_836, "Katsu": 19_537}
31
+ SYNTH_FRAMES = EXPECTED_FRAMES
32
+ OUTPUT_NAMES = {
33
+ "Big Don": ("don-big.wav", "don_big"),
34
+ "Don": ("don.wav", "don"),
35
+ "Katsu": ("katsu.wav", "katsu"),
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ReferencePaths:
41
+ big_don: Path
42
+ don: Path
43
+ katsu: Path
44
+
45
+ def by_role(self) -> dict[str, Path]:
46
+ return {"Big Don": self.big_don, "Don": self.don, "Katsu": self.katsu}
47
+
48
+
49
+ def _sha256(path: Path) -> str:
50
+ digest = hashlib.sha256()
51
+ with path.open("rb") as handle:
52
+ for block in iter(lambda: handle.read(1 << 20), b""):
53
+ digest.update(block)
54
+ return digest.hexdigest()
55
+
56
+
57
+ def _read_reference(path: Path, role: str) -> np.ndarray:
58
+ if not path.is_file():
59
+ raise FileNotFoundError(f"missing {role} reference WAV: {path}")
60
+ with wave.open(str(path), "rb") as source:
61
+ sample_rate = source.getframerate()
62
+ channels = source.getnchannels()
63
+ width = source.getsampwidth()
64
+ frames = source.getnframes()
65
+ payload = source.readframes(frames)
66
+ if sample_rate != SAMPLE_RATE or channels != 1 or width != 2:
67
+ raise ValueError(f"{role} reference must be mono PCM16 at 48 kHz")
68
+ if frames != EXPECTED_FRAMES[role]:
69
+ raise ValueError(
70
+ f"{role} reference must contain {EXPECTED_FRAMES[role]} frames, got {frames}"
71
+ )
72
+ audio = np.frombuffer(payload, dtype="<i2").astype(np.float64) / 32768.0
73
+ if not np.all(np.isfinite(audio)) or float(np.max(np.abs(audio))) <= 1e-9:
74
+ raise ValueError(f"{role} reference must contain finite, non-silent audio")
75
+ return audio
76
+
77
+
78
+ def _resonator_bank(
79
+ excitation: np.ndarray,
80
+ frequencies: list[float],
81
+ gains: list[float],
82
+ t60s: list[float],
83
+ ) -> np.ndarray:
84
+ output = np.zeros_like(excitation)
85
+ for frequency, gain, t60 in zip(frequencies, gains, t60s, strict=True):
86
+ radius = 10.0 ** (-3.0 / (max(t60, 0.001) * SAMPLE_RATE))
87
+ a1 = 2.0 * radius * math.cos(2.0 * math.pi * frequency / SAMPLE_RATE)
88
+ a2 = -(radius * radius)
89
+ input_gain = (1.0 - radius) * 4.0
90
+ previous = second_previous = 0.0
91
+ mode = np.empty_like(excitation)
92
+ for index, sample in enumerate(excitation):
93
+ value = input_gain * sample + a1 * previous + a2 * second_previous
94
+ mode[index] = value
95
+ second_previous, previous = previous, value
96
+ output += gain * mode
97
+ return output
98
+
99
+
100
+ def _soft_normalize(audio: np.ndarray, peak: float = 0.95) -> np.ndarray:
101
+ shaped = np.tanh(audio * 1.15)
102
+ maximum = float(np.max(np.abs(shaped)))
103
+ if not math.isfinite(maximum) or maximum <= 1e-12:
104
+ raise ValueError("synthesizer produced silent or non-finite audio")
105
+ return shaped * (peak / maximum)
106
+
107
+
108
+ def _make_synth(role: str, rng: np.random.Generator) -> np.ndarray:
109
+ frame_count = SYNTH_FRAMES[role]
110
+ if role == "Big Don":
111
+ frequencies = [86, 125, 141, 176, 193, 240, 264, 291, 313, 344, 409,
112
+ 529, 578, 700, 788, 869, 957, 1207, 1650, 2200, 2770]
113
+ gains = [1.00, .92, .28, .22, .45, .38, .28, .22, .18, .12, .12,
114
+ .09, .07, .07, .08, .045, .04, .035, .018, .012, .010]
115
+ t60s = [.72, .82, .55, .50, .46, .40, .36, .34, .31, .28, .25,
116
+ .22, .19, .16, .14, .12, .11, .09, .07, .055, .045]
117
+ cutoff, noise_decay = 6_500.0, .018
118
+ sweep_start, sweep_end, sweep_decay = 302.0, 82.0, .11
119
+ direct_amount, body_amount, sweep_amount = .13, 1.60, .23
120
+ elif role == "Don":
121
+ frequencies = [86, 125, 141, 174, 193, 240, 247, 276, 292, 313, 368,
122
+ 386, 408, 487, 540, 578, 647, 694, 787, 870, 962, 2165]
123
+ gains = [1.0, .60, .27, .15, .34, .36, .23, .18, .20, .21, .10,
124
+ .15, .10, .08, .08, .07, .07, .12, .12, .05, .05, .018]
125
+ t60s = [.52, .48, .37, .34, .34, .31, .30, .27, .25, .24, .22,
126
+ .21, .20, .18, .17, .16, .14, .14, .12, .10, .09, .052]
127
+ cutoff, noise_decay = 7_500.0, .024
128
+ sweep_start, sweep_end, sweep_decay = 346.0, 86.0, .075
129
+ direct_amount, body_amount, sweep_amount = .17, 1.55, .16
130
+ elif role == "Katsu":
131
+ time = np.arange(frame_count) / SAMPLE_RATE
132
+ noise = rng.standard_normal(frame_count)
133
+ high = sosfilt(
134
+ butter(3, 300 / (SAMPLE_RATE / 2), btype="high", output="sos"), noise,
135
+ )
136
+ band = sosfilt(
137
+ butter(
138
+ 4, [450 / (SAMPLE_RATE / 2), 10_000 / (SAMPLE_RATE / 2)],
139
+ btype="band", output="sos",
140
+ ),
141
+ noise,
142
+ )
143
+ excitation = np.zeros(frame_count)
144
+ excitation[0] = 4.5
145
+ excitation += 1.6 * band * np.exp(-time / .018)
146
+ frequencies = [455, 560, 641, 735, 849, 1025, 1047, 1166, 1214, 1374,
147
+ 2188, 2231, 2279, 2933, 2994, 3083, 3123, 3856, 4006]
148
+ gains = [.25, .32, .38, .30, .26, .65, .52, .34, .26, .30, .85,
149
+ .72, .38, .38, .30, .40, .34, .30, .28]
150
+ t60s = [.23, .18, .16, .14, .13, .20, .18, .15, .14, .13, .17,
151
+ .16, .13, .12, .11, .12, .11, .10, .10]
152
+ body = _resonator_bank(excitation, frequencies, gains, t60s)
153
+ direct = .32 * high * np.exp(-time / .050) + .50 * band * np.exp(-time / .012)
154
+ click_frames = max(1, int(.0012 * SAMPLE_RATE))
155
+ click = np.zeros(frame_count)
156
+ click[:click_frames] = rng.standard_normal(click_frames) * np.hanning(click_frames)
157
+ synth = 1.2 * body + direct + .18 * click
158
+ lead = int(.018 * SAMPLE_RATE)
159
+ return _soft_normalize(np.concatenate([np.zeros(lead), synth])[:frame_count])
160
+ else:
161
+ raise ValueError(f"unsupported synth role: {role}")
162
+
163
+ time = np.arange(frame_count) / SAMPLE_RATE
164
+ noise = rng.standard_normal(frame_count)
165
+ low = sosfilt(
166
+ butter(4, cutoff / (SAMPLE_RATE / 2), btype="low", output="sos"), noise,
167
+ )
168
+ excitation = np.zeros(frame_count)
169
+ excitation[0] = 3.0
170
+ excitation += 1.2 * low * np.exp(-time / noise_decay)
171
+ body = _resonator_bank(excitation, frequencies, gains, t60s)
172
+ instantaneous_frequency = (
173
+ (sweep_start - sweep_end) * np.exp(-time / .025) + sweep_end
174
+ )
175
+ phase = 2.0 * math.pi * np.cumsum(instantaneous_frequency) / SAMPLE_RATE
176
+ sweep = np.sin(phase) * np.exp(-time / sweep_decay)
177
+ synth = (
178
+ body_amount * body
179
+ + sweep_amount * sweep
180
+ + direct_amount * low * np.exp(-time / noise_decay)
181
+ )
182
+ synth *= (
183
+ 1.0
184
+ + .03 * np.sin(2.0 * math.pi * 7.7 * time)
185
+ + .018 * np.sin(2.0 * math.pi * 14.9 * time + .5)
186
+ )
187
+ lead = int(.018 * SAMPLE_RATE)
188
+ return _soft_normalize(np.concatenate([np.zeros(lead), synth])[:frame_count])
189
+
190
+
191
+ def _attack_burst(
192
+ frame_count: int,
193
+ seed: int,
194
+ low_hz: float,
195
+ high_hz: float,
196
+ attack_seconds: float,
197
+ decay_seconds: float,
198
+ ) -> np.ndarray:
199
+ time = np.arange(frame_count) / SAMPLE_RATE
200
+ noise = np.random.default_rng(seed).standard_normal(frame_count)
201
+ band = sosfilt(
202
+ butter(
203
+ 3,
204
+ [low_hz / (SAMPLE_RATE / 2), high_hz / (SAMPLE_RATE / 2)],
205
+ btype="band",
206
+ output="sos",
207
+ ),
208
+ noise,
209
+ )
210
+ envelope = (
211
+ (1.0 - np.exp(-time / attack_seconds))
212
+ * np.exp(-time / decay_seconds)
213
+ )
214
+ return band * envelope
215
+
216
+
217
+ def _calibrate_spectrum(role: str, synth: np.ndarray) -> np.ndarray:
218
+ """Tune attack and modal balance while preserving the supplied method."""
219
+
220
+ lead = int(.018 * SAMPLE_RATE)
221
+ active_frames = len(synth) - lead
222
+ time = np.arange(active_frames) / SAMPLE_RATE
223
+ calibrated = synth.copy()
224
+
225
+ if role in {"Big Don", "Don"}:
226
+ seed = 9_201 if role == "Big Don" else 9_202
227
+ burst = _attack_burst(
228
+ active_frames, seed, 2_000.0, 14_000.0, .0006, .0045,
229
+ )
230
+ if role == "Big Don":
231
+ calibrated[lead:] += 2.05 * burst
232
+ calibrated[lead:] += (
233
+ .25 * np.sin(2.0 * math.pi * 86.0 * time) * np.exp(-time / .50)
234
+ )
235
+ else:
236
+ calibrated[lead:] += 2.40 * burst
237
+ calibrated[lead:] += (
238
+ .20 * np.sin(2.0 * math.pi * 125.0 * time) * np.exp(-time / .30)
239
+ )
240
+ return calibrated
241
+
242
+ if role == "Katsu":
243
+ low = sosfilt(
244
+ butter(4, 3_000.0 / (SAMPLE_RATE / 2), btype="low", output="sos"),
245
+ calibrated,
246
+ )
247
+ mid = sosfilt(
248
+ butter(
249
+ 3,
250
+ [1_400.0 / (SAMPLE_RATE / 2), 2_800.0 / (SAMPLE_RATE / 2)],
251
+ btype="band",
252
+ output="sos",
253
+ ),
254
+ calibrated,
255
+ )
256
+ burst = _attack_burst(
257
+ active_frames, 9_203, 1_000.0, 10_000.0, .0003, .004,
258
+ )
259
+ calibrated = low
260
+ calibrated[lead:] += 12.0 * mid[lead:] * np.exp(-time / .060)
261
+ calibrated[lead:] += 10.0 * burst
262
+ return calibrated
263
+
264
+ raise ValueError(f"unsupported synth role: {role}")
265
+
266
+
267
+ def _rms_envelope(audio: np.ndarray, milliseconds: float = 6.0) -> np.ndarray:
268
+ window = max(1, int(SAMPLE_RATE * milliseconds / 1_000.0))
269
+ return np.sqrt(
270
+ np.convolve(audio * audio, np.ones(window) / window, mode="same") + 1e-10
271
+ )
272
+
273
+
274
+ def _match_reference_envelope(
275
+ synth: np.ndarray,
276
+ reference: np.ndarray,
277
+ ) -> np.ndarray:
278
+ frame_count = min(len(synth), len(reference))
279
+ if frame_count <= 0:
280
+ raise ValueError("synthesis and reference must both contain audio")
281
+ reference = reference[:frame_count]
282
+ matched = synth[:frame_count].copy()
283
+ window = max(3, int(.012 * SAMPLE_RATE))
284
+ kernel = np.hanning(window)
285
+ kernel /= np.sum(kernel)
286
+ for _ in range(2):
287
+ ratio = _rms_envelope(reference) / (_rms_envelope(matched) + 1e-7)
288
+ log_ratio = np.log(np.clip(ratio, .03, 30.0))
289
+ matched *= np.exp(np.convolve(log_ratio, kernel, mode="same"))
290
+ target_rms = float(np.sqrt(np.mean(reference * reference)))
291
+ measured_rms = float(np.sqrt(np.mean(matched * matched)))
292
+ if measured_rms <= 1e-12:
293
+ raise ValueError("envelope calibration produced silence")
294
+ matched *= target_rms / measured_rms
295
+ peak_limit = min(.98, max(.90, float(np.max(np.abs(reference)))))
296
+ measured_peak = float(np.max(np.abs(matched)))
297
+ if measured_peak > peak_limit:
298
+ matched *= peak_limit / measured_peak
299
+ if not np.all(np.isfinite(matched)):
300
+ raise ValueError("envelope calibration produced non-finite audio")
301
+ return matched
302
+
303
+
304
+ def _anchor_frame(signal: np.ndarray) -> int:
305
+ peak = float(np.max(np.abs(signal)))
306
+ candidates = np.flatnonzero(np.abs(signal) >= peak * 10.0 ** (-30.0 / 20.0))
307
+ if len(candidates) == 0:
308
+ raise ValueError("synthesized voice has no measurable onset")
309
+ return int(candidates[0])
310
+
311
+
312
+ def _profile(signal: np.ndarray, anchor: int) -> dict[str, float]:
313
+ active = signal[anchor:]
314
+ energy = active * active
315
+ cumulative = np.cumsum(energy)
316
+ spectrum = np.abs(np.fft.rfft(active * np.hanning(len(active)))) ** 2
317
+ frequencies = np.fft.rfftfreq(len(active), 1.0 / SAMPLE_RATE)
318
+ spectrum /= max(float(np.sum(spectrum)), 1e-15)
319
+ attack = active[:int(round(.010 * SAMPLE_RATE))]
320
+ attack_spectrum = np.abs(np.fft.rfft(attack * np.hanning(len(attack)))) ** 2
321
+ attack_frequencies = np.fft.rfftfreq(len(attack), 1.0 / SAMPLE_RATE)
322
+ tail = active[int(round(.120 * SAMPLE_RATE)):]
323
+ tail_spectrum = np.abs(np.fft.rfft(tail * np.hanning(len(tail)))) ** 2
324
+ tail_frequencies = np.fft.rfftfreq(len(tail), 1.0 / SAMPLE_RATE)
325
+ return {
326
+ "centroid_hz": round(float(np.sum(frequencies * spectrum)), 3),
327
+ "attack_centroid_hz": round(float(
328
+ np.sum(attack_frequencies * attack_spectrum)
329
+ / max(float(np.sum(attack_spectrum)), 1e-15)
330
+ ), 3),
331
+ "tail_centroid_hz": round(float(
332
+ np.sum(tail_frequencies * tail_spectrum)
333
+ / max(float(np.sum(tail_spectrum)), 1e-15)
334
+ ), 3),
335
+ "peak_seconds_after_anchor": round(
336
+ float(np.argmax(np.abs(active)) / SAMPLE_RATE), 6,
337
+ ),
338
+ "t90_seconds_after_anchor": round(
339
+ float(np.searchsorted(cumulative, .90 * cumulative[-1]) / SAMPLE_RATE), 6,
340
+ ),
341
+ "t99_seconds_after_anchor": round(
342
+ float(np.searchsorted(cumulative, .99 * cumulative[-1]) / SAMPLE_RATE), 6,
343
+ ),
344
+ "crest_db": round(
345
+ float(20.0 * math.log10(
346
+ np.max(np.abs(active)) / np.sqrt(np.mean(energy))
347
+ )),
348
+ 3,
349
+ ),
350
+ }
351
+
352
+
353
+ def _write_pcm16(path: Path, signal: np.ndarray) -> tuple[str, str]:
354
+ clipped = np.clip(signal, -1.0, 1.0)
355
+ pcm = np.rint(clipped * 32767.0).astype("<i2")
356
+ with wave.open(str(path), "wb") as output:
357
+ output.setnchannels(1)
358
+ output.setsampwidth(2)
359
+ output.setframerate(SAMPLE_RATE)
360
+ output.writeframes(pcm.tobytes())
361
+ return _sha256(path), hashlib.sha256(pcm.tobytes()).hexdigest()
362
+
363
+
364
+ def build(reference_paths: ReferencePaths, output_dir: Path) -> None:
365
+ references = {
366
+ role: _read_reference(path, role)
367
+ for role, path in reference_paths.by_role().items()
368
+ }
369
+ output_dir.mkdir(parents=True, exist_ok=True)
370
+ for stale in output_dir.glob("*.wav"):
371
+ stale.unlink()
372
+
373
+ rng = np.random.default_rng(RANDOM_SEED)
374
+ files: list[dict[str, object]] = []
375
+ for role in BUILD_ORDER:
376
+ synth = _make_synth(role, rng)
377
+ synth = _calibrate_spectrum(role, synth)
378
+ synth = _match_reference_envelope(synth, references[role])
379
+ filename, kind = OUTPUT_NAMES[role]
380
+ path = output_dir / filename
381
+ file_hash, pcm_hash = _write_pcm16(path, synth)
382
+ anchor = _anchor_frame(synth)
383
+ files.append({
384
+ "filename": filename,
385
+ "kind": kind,
386
+ "sha256": file_hash,
387
+ "pcm_sha256": pcm_hash,
388
+ "sample_rate": SAMPLE_RATE,
389
+ "channels": 1,
390
+ "frames": len(synth),
391
+ "anchor_frame": anchor,
392
+ "profile": _profile(synth, anchor),
393
+ })
394
+
395
+ manifest = {
396
+ "schema_version": 2,
397
+ "kit_id": "softchart-modal-resonator-v1",
398
+ "license": "MIT",
399
+ "sample_rate": SAMPLE_RATE,
400
+ "generator": "scripts/build_taiko_synth.py",
401
+ "method": (
402
+ "seeded noise/impulse excitation, modal resonators, decaying pitch "
403
+ "sweep, band-limited transient calibration, and offline RMS-envelope "
404
+ "calibration"
405
+ ),
406
+ "random_seed": RANDOM_SEED,
407
+ "reference_policy": (
408
+ "Reference audio is used only for offline RMS-envelope and level "
409
+ "calibration; it is not copied, mixed, embedded, or required at runtime."
410
+ ),
411
+ "files": sorted(files, key=lambda item: str(item["filename"])),
412
+ }
413
+ (output_dir / "manifest.json").write_text(
414
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
415
+ encoding="utf-8",
416
+ )
417
+
418
+
419
+ def main() -> None:
420
+ parser = argparse.ArgumentParser()
421
+ parser.add_argument("--big-don-reference", type=Path, required=True)
422
+ parser.add_argument("--don-reference", type=Path, required=True)
423
+ parser.add_argument("--katsu-reference", type=Path, required=True)
424
+ parser.add_argument("--output-dir", type=Path, required=True)
425
+ args = parser.parse_args()
426
+ build(
427
+ ReferencePaths(
428
+ big_don=args.big_don_reference,
429
+ don=args.don_reference,
430
+ katsu=args.katsu_reference,
431
+ ),
432
+ args.output_dir,
433
+ )
434
+
435
+
436
+ if __name__ == "__main__":
437
+ main()
softchart/assets/taiko/README.md DELETED
@@ -1,17 +0,0 @@
1
- # SoftChart Arcade Taiko Kit
2
-
3
- These one-shots are original SoftChart transformations of the **Chinese Bass
4
- Drum Dagu Oneshots** pack recorded in Taipei by `sazanami12`:
5
-
6
- - Source: https://freesound.org/people/sazanami12/packs/45781/
7
- - License: CC0 1.0 — https://creativecommons.org/publicdomain/zero/1.0/
8
- - Build recipe: `scripts/build_taiko_bank.py` in both the source repository and
9
- the self-contained Space payload
10
- - Exact source and output hashes: `manifest.json`
11
-
12
- The source instrument is a Chinese Huapengu / Dagu, selected as a
13
- redistributable large East-Asian barrel-drum recording with isolated head and
14
- rim articulations. It is not represented as a Japanese wadaiko recording.
15
-
16
- No audio from Taiko no Tatsujin or any other commercial game is included,
17
- transformed, embedded, or required by the build.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/assets/taiko/balloon-01.wav DELETED
Binary file (12 kB)
 
softchart/assets/taiko/balloon-02.wav DELETED
Binary file (12 kB)
 
softchart/assets/taiko/don-01.wav DELETED
Binary file (73.3 kB)
 
softchart/assets/taiko/don-02.wav DELETED
Binary file (73.3 kB)
 
softchart/assets/taiko/don-big.wav DELETED
Binary file (79 kB)
 
softchart/assets/taiko/ka-01.wav DELETED
Binary file (35.9 kB)
 
softchart/assets/taiko/ka-02.wav DELETED
Binary file (35.9 kB)
 
softchart/assets/taiko/ka-big.wav DELETED
Binary file (35.9 kB)
 
softchart/assets/taiko/manifest.json DELETED
@@ -1,310 +0,0 @@
1
- {
2
- "schema_version": 1,
3
- "kit_id": "softchart-arcade-taiko-cc0",
4
- "license": "CC0-1.0",
5
- "license_url": "https://creativecommons.org/publicdomain/zero/1.0/",
6
- "source_pack_url": "https://freesound.org/people/sazanami12/packs/45781/",
7
- "source_creator": "sazanami12",
8
- "source_instrument": "Chinese Huapengu / Dagu recorded in Taipei, Taiwan",
9
- "source_encoding": "public Freesound HQ OGG preview of the CC0 original WAV",
10
- "sources": [
11
- {
12
- "sound_id": 856550,
13
- "label": "center-soft",
14
- "sha256": "78ed9dea56102480811e90a9653c08fe20a0eca5793c9c936f1c2592042b9a46",
15
- "preview_url": "https://cdn.freesound.org/previews/856/856550_17110092-hq.ogg"
16
- },
17
- {
18
- "sound_id": 856552,
19
- "label": "edge-soft",
20
- "sha256": "85600ed1ddfab1c56fe07268f5fd68073706da8cd9a2f0cf7f16bde30bb8bb3e",
21
- "preview_url": "https://cdn.freesound.org/previews/856/856552_17110092-hq.ogg"
22
- },
23
- {
24
- "sound_id": 856557,
25
- "label": "rim-right",
26
- "sha256": "6c24ee738d5995121fa85a432223a2afadb585f2a67b253747e2db0b3796bca0",
27
- "preview_url": "https://cdn.freesound.org/previews/856/856557_17110092-hq.ogg"
28
- },
29
- {
30
- "sound_id": 856558,
31
- "label": "rim-left",
32
- "sha256": "5ac219e4254f27c4a3b5b581f5c67e7c72fe91fa3d0fb2e03d021793febc1872",
33
- "preview_url": "https://cdn.freesound.org/previews/856/856558_17110092-hq.ogg"
34
- },
35
- {
36
- "sound_id": 856559,
37
- "label": "rim-both-loud",
38
- "sha256": "840a559e5d979e483cec2afb64e4f71a27d31c753c1bb138395303c5bd0d8253",
39
- "preview_url": "https://cdn.freesound.org/previews/856/856559_17110092-hq.ogg"
40
- }
41
- ],
42
- "files": [
43
- {
44
- "filename": "balloon-01.wav",
45
- "kind": "balloon",
46
- "round_robin": 1,
47
- "sha256": "241042aeb27bf7ca310c65fb1130554c3f5501b735d4178c9a65227a3c3962b7",
48
- "pcm_sha256": "d86c418410b1f009dbc0a1f1cbc81dbe6ac52b1707002f7595c48c973fd309c0",
49
- "sample_rate": 44100,
50
- "channels": 1,
51
- "frames": 5954,
52
- "anchor_frame": 0,
53
- "source_sound_ids": [
54
- 856552,
55
- 856559
56
- ],
57
- "profile": {
58
- "centroid_hz": 144.684,
59
- "attack_centroid_hz": 2085.291,
60
- "t90_seconds": 0.088254,
61
- "t99_seconds": 0.101973,
62
- "crest_db": 10.255
63
- }
64
- },
65
- {
66
- "filename": "balloon-02.wav",
67
- "kind": "balloon",
68
- "round_robin": 2,
69
- "sha256": "9e9b9ae2067e3b8c35d4bef011aea160ac6b2c955b861b48fcdd977e9784a96e",
70
- "pcm_sha256": "90a5802c2b25e55fdfd39b394f505e302aa13e4435418c494aff807a51bdfefd",
71
- "sample_rate": 44100,
72
- "channels": 1,
73
- "frames": 5954,
74
- "anchor_frame": 0,
75
- "source_sound_ids": [
76
- 856550,
77
- 856559
78
- ],
79
- "profile": {
80
- "centroid_hz": 154.871,
81
- "attack_centroid_hz": 2160.392,
82
- "t90_seconds": 0.087846,
83
- "t99_seconds": 0.101927,
84
- "crest_db": 11.393
85
- }
86
- },
87
- {
88
- "filename": "don-01.wav",
89
- "kind": "don",
90
- "round_robin": 1,
91
- "sha256": "b0ae8c300223ca2092da3ebac7d5318978926935cc5f5de63339ca06e4552c94",
92
- "pcm_sha256": "2fcc51eed16cf540288ad5b99153afea9f4ba599dc4921595852792cd4ee2163",
93
- "sample_rate": 44100,
94
- "channels": 1,
95
- "frames": 36603,
96
- "anchor_frame": 0,
97
- "source_sound_ids": [
98
- 856552,
99
- 856559
100
- ],
101
- "profile": {
102
- "centroid_hz": 106.922,
103
- "attack_centroid_hz": 2085.291,
104
- "t90_seconds": 0.279342,
105
- "t99_seconds": 0.424104,
106
- "crest_db": 13.942
107
- }
108
- },
109
- {
110
- "filename": "don-02.wav",
111
- "kind": "don",
112
- "round_robin": 2,
113
- "sha256": "668748702af4b40922954685d4b416f6402c905f1ef7e9172f13d1a201f05c14",
114
- "pcm_sha256": "5116770059c6891ce90b2f0eb39e15ee9cc37a851d9df10165c9e15638f18ad7",
115
- "sample_rate": 44100,
116
- "channels": 1,
117
- "frames": 36603,
118
- "anchor_frame": 0,
119
- "source_sound_ids": [
120
- 856550,
121
- 856559
122
- ],
123
- "profile": {
124
- "centroid_hz": 113.558,
125
- "attack_centroid_hz": 2160.392,
126
- "t90_seconds": 0.286984,
127
- "t99_seconds": 0.403673,
128
- "crest_db": 14.121
129
- }
130
- },
131
- {
132
- "filename": "don-big.wav",
133
- "kind": "don_big",
134
- "round_robin": 1,
135
- "sha256": "38d7bee2a772c45fe935bab378f7aedaf73746fb78972fc538cf37d8c81c20cd",
136
- "pcm_sha256": "29823b5ff47f93217360db5019f2c805bf5e36c0658b7fb8bdf9fec051636ecf",
137
- "sample_rate": 44100,
138
- "channels": 1,
139
- "frames": 39470,
140
- "anchor_frame": 0,
141
- "source_sound_ids": [
142
- 856550,
143
- 856559
144
- ],
145
- "profile": {
146
- "centroid_hz": 105.346,
147
- "attack_centroid_hz": 2440.045,
148
- "t90_seconds": 0.379365,
149
- "t99_seconds": 0.557619,
150
- "crest_db": 13.246
151
- }
152
- },
153
- {
154
- "filename": "ka-01.wav",
155
- "kind": "ka",
156
- "round_robin": 1,
157
- "sha256": "7091db93a0d8bcb9a58d08e00c1052c9c057784a72ed7a445e7e04e1fb854192",
158
- "pcm_sha256": "c7d95d820c95ecaa955b0a47739b1c7ab025b4a99f9c076bbef7a1b18f30215d",
159
- "sample_rate": 44100,
160
- "channels": 1,
161
- "frames": 17949,
162
- "anchor_frame": 0,
163
- "source_sound_ids": [
164
- 856559,
165
- 856557
166
- ],
167
- "profile": {
168
- "centroid_hz": 2083.859,
169
- "attack_centroid_hz": 4187.299,
170
- "t90_seconds": 0.061202,
171
- "t99_seconds": 0.123469,
172
- "crest_db": 20.018
173
- }
174
- },
175
- {
176
- "filename": "ka-02.wav",
177
- "kind": "ka",
178
- "round_robin": 2,
179
- "sha256": "6dfcaad357908e139e98e62d748659ef0484337b7f04b66db6a4f4c4071120a8",
180
- "pcm_sha256": "b8d50d62a9a71db0effbb34421958aa40776e44f0573baa271f32cb6881e96ce",
181
- "sample_rate": 44100,
182
- "channels": 1,
183
- "frames": 17949,
184
- "anchor_frame": 0,
185
- "source_sound_ids": [
186
- 856557,
187
- 856558
188
- ],
189
- "profile": {
190
- "centroid_hz": 1926.337,
191
- "attack_centroid_hz": 3862.82,
192
- "t90_seconds": 0.058141,
193
- "t99_seconds": 0.107732,
194
- "crest_db": 22.327
195
- }
196
- },
197
- {
198
- "filename": "ka-big.wav",
199
- "kind": "ka_big",
200
- "round_robin": 1,
201
- "sha256": "dd72eb84c579eaa9ec2f04fe1e78f1cc497b5bf425b32a75788398379b1f2964",
202
- "pcm_sha256": "40e876586926aecc06b91bfc60a1d00d33832fd34f334799d7c46e16921a6ea4",
203
- "sample_rate": 44100,
204
- "channels": 1,
205
- "frames": 17949,
206
- "anchor_frame": 0,
207
- "source_sound_ids": [
208
- 856559,
209
- 856557,
210
- 856558
211
- ],
212
- "profile": {
213
- "centroid_hz": 1986.528,
214
- "attack_centroid_hz": 4484.582,
215
- "t90_seconds": 0.061224,
216
- "t99_seconds": 0.125624,
217
- "crest_db": 19.782
218
- }
219
- },
220
- {
221
- "filename": "roll-01.wav",
222
- "kind": "roll",
223
- "round_robin": 1,
224
- "sha256": "12bdee9caf1c8b17f2c56cce90078f25837a9670c939ceaabdd4cc524ebd9100",
225
- "pcm_sha256": "19c20b070f3f23eab146d1a3b864a5827e68afe52224ccd3d7b23a2413f242a6",
226
- "sample_rate": 44100,
227
- "channels": 1,
228
- "frames": 6615,
229
- "anchor_frame": 0,
230
- "source_sound_ids": [
231
- 856552,
232
- 856559
233
- ],
234
- "profile": {
235
- "centroid_hz": 139.363,
236
- "attack_centroid_hz": 2085.291,
237
- "t90_seconds": 0.094785,
238
- "t99_seconds": 0.113311,
239
- "crest_db": 9.782
240
- }
241
- },
242
- {
243
- "filename": "roll-02.wav",
244
- "kind": "roll",
245
- "round_robin": 2,
246
- "sha256": "2abd129bdb4e8c73aa44d071c386a8fc643f613c9b0cec1c84a339073e8bc681",
247
- "pcm_sha256": "d4f9ca68263e903188eed4844a70041c38bb8f1c026331c28802771de606274b",
248
- "sample_rate": 44100,
249
- "channels": 1,
250
- "frames": 6615,
251
- "anchor_frame": 0,
252
- "source_sound_ids": [
253
- 856550,
254
- 856559
255
- ],
256
- "profile": {
257
- "centroid_hz": 154.227,
258
- "attack_centroid_hz": 2160.392,
259
- "t90_seconds": 0.096259,
260
- "t99_seconds": 0.114195,
261
- "crest_db": 11.106
262
- }
263
- },
264
- {
265
- "filename": "roll-big-01.wav",
266
- "kind": "roll_big",
267
- "round_robin": 1,
268
- "sha256": "23fea6acc0d2c554353af445ea8aa887555ce2df3268540256922f695158909a",
269
- "pcm_sha256": "7d3c290e8c18c786dd46633483d03c7ae3b83dfd83410c1262bd3e2d4dd8e82e",
270
- "sample_rate": 44100,
271
- "channels": 1,
272
- "frames": 7717,
273
- "anchor_frame": 0,
274
- "source_sound_ids": [
275
- 856550,
276
- 856559
277
- ],
278
- "profile": {
279
- "centroid_hz": 157.954,
280
- "attack_centroid_hz": 2440.045,
281
- "t90_seconds": 0.100317,
282
- "t99_seconds": 0.129501,
283
- "crest_db": 9.349
284
- }
285
- },
286
- {
287
- "filename": "roll-big-02.wav",
288
- "kind": "roll_big",
289
- "round_robin": 2,
290
- "sha256": "edfd7d6225ac7e14d554b7ec946455d1d942f87c00e370d0cae2b3e62a22e2fd",
291
- "pcm_sha256": "20289804cdb38f0b6dc6e300e7e8b0b7f32c7cc485e990db4a0a53a0055c47e5",
292
- "sample_rate": 44100,
293
- "channels": 1,
294
- "frames": 7717,
295
- "anchor_frame": 0,
296
- "source_sound_ids": [
297
- 856550,
298
- 856552,
299
- 856559
300
- ],
301
- "profile": {
302
- "centroid_hz": 153.15,
303
- "attack_centroid_hz": 2438.852,
304
- "t90_seconds": 0.094399,
305
- "t99_seconds": 0.125283,
306
- "crest_db": 9.633
307
- }
308
- }
309
- ]
310
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
softchart/assets/taiko/roll-01.wav DELETED
Binary file (13.3 kB)
 
softchart/assets/taiko/roll-02.wav DELETED
Binary file (13.3 kB)
 
softchart/assets/taiko/roll-big-01.wav DELETED
Binary file (15.5 kB)
 
softchart/assets/taiko/roll-big-02.wav DELETED
Binary file (15.5 kB)
 
softchart/assets/taiko_synth/README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SoftChart Modal-Resonator Taiko Kit
2
+
3
+ This directory contains three deterministic, synthesized one-shot voices:
4
+
5
+ - `don.wav` — membrane hit
6
+ - `don-big.wav` — independent large membrane hit
7
+ - `katsu.wav` — rim hit
8
+
9
+ The voices are generated at 48 kHz by `scripts/build_taiko_synth.py`. The
10
+ signal path uses seeded noise and impulse excitations, banks of decaying modal
11
+ resonators, low-frequency pitch sweeps, band-limited stick transients, and
12
+ offline RMS-envelope calibration.
13
+
14
+ Developer-supplied reference WAVs are used only during the offline build to
15
+ calibrate envelopes and overall levels. Reference samples, paths, hashes, and
16
+ envelope arrays are not copied, mixed, embedded, recorded in the manifest, or
17
+ required at runtime. The bundled WAVs contain only mathematical synthesis.
18
+
19
+ `manifest.json` records the generated files, PCM hashes, onset anchors, and
20
+ scalar acoustic profiles. The runtime validates the complete bank before use
21
+ and performs one high-quality 48 kHz to preview-rate conversion in memory.
22
+
23
+ The synthesized assets are distributed under the repository's MIT license.
softchart/assets/taiko_synth/don-big.wav ADDED
Binary file (85.9 kB). View file
 
softchart/assets/taiko_synth/don.wav ADDED
Binary file (79.7 kB). View file
 
softchart/assets/taiko_synth/katsu.wav ADDED
Binary file (39.1 kB). View file
 
softchart/assets/taiko_synth/manifest.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 2,
3
+ "kit_id": "softchart-modal-resonator-v1",
4
+ "license": "MIT",
5
+ "sample_rate": 48000,
6
+ "generator": "scripts/build_taiko_synth.py",
7
+ "method": "seeded noise/impulse excitation, modal resonators, decaying pitch sweep, band-limited transient calibration, and offline RMS-envelope calibration",
8
+ "random_seed": 20260713,
9
+ "reference_policy": "Reference audio is used only for offline RMS-envelope and level calibration; it is not copied, mixed, embedded, or required at runtime.",
10
+ "files": [
11
+ {
12
+ "filename": "don-big.wav",
13
+ "kind": "don_big",
14
+ "sha256": "9e0be6c32867328aef834d4021b6506a6c7174fe3ced9ecb264d58b50ae712de",
15
+ "pcm_sha256": "14e81e7f92a418f2e4950b989c9c93e5d25f9d5ed990c02838b127c46c23584f",
16
+ "sample_rate": 48000,
17
+ "channels": 1,
18
+ "frames": 42946,
19
+ "anchor_frame": 866,
20
+ "profile": {
21
+ "centroid_hz": 118.42,
22
+ "attack_centroid_hz": 1672.916,
23
+ "tail_centroid_hz": 112.667,
24
+ "peak_seconds_after_anchor": 0.006646,
25
+ "t90_seconds_after_anchor": 0.352333,
26
+ "t99_seconds_after_anchor": 0.508687,
27
+ "crest_db": 11.994
28
+ }
29
+ },
30
+ {
31
+ "filename": "don.wav",
32
+ "kind": "don",
33
+ "sha256": "b2ef1660a03f208c054dfb51c4e0778090d43a24dab592c60992c84a97e384f4",
34
+ "pcm_sha256": "62adc81419fb47950e1fd76e9c302085d680e8d404efd023e84b5a1d66266522",
35
+ "sample_rate": 48000,
36
+ "channels": 1,
37
+ "frames": 39836,
38
+ "anchor_frame": 866,
39
+ "profile": {
40
+ "centroid_hz": 101.648,
41
+ "attack_centroid_hz": 1842.387,
42
+ "tail_centroid_hz": 111.714,
43
+ "peak_seconds_after_anchor": 0.007833,
44
+ "t90_seconds_after_anchor": 0.248979,
45
+ "t99_seconds_after_anchor": 0.460375,
46
+ "crest_db": 12.25
47
+ }
48
+ },
49
+ {
50
+ "filename": "katsu.wav",
51
+ "kind": "katsu",
52
+ "sha256": "54e8c93363d9dd1fb4703397ba4f1b4156e64697108895edd2e8c57c743c7706",
53
+ "pcm_sha256": "20d38df7d06bb4519363108e1ccb61dbfe98749c41b17ec9d99f22f47af1d5ab",
54
+ "sample_rate": 48000,
55
+ "channels": 1,
56
+ "frames": 19537,
57
+ "anchor_frame": 866,
58
+ "profile": {
59
+ "centroid_hz": 1819.101,
60
+ "attack_centroid_hz": 2716.187,
61
+ "tail_centroid_hz": 1721.006,
62
+ "peak_seconds_after_anchor": 0.005896,
63
+ "t90_seconds_after_anchor": 0.074875,
64
+ "t99_seconds_after_anchor": 0.1385,
65
+ "crest_db": 20.199
66
+ }
67
+ }
68
+ ]
69
+ }
softchart/preview_audio.py CHANGED
@@ -1,9 +1,10 @@
1
- """Deterministic sample-based arcade Taiko preview mixing.
2
 
3
  The chart is the timing authority: :func:`parse_tja_events` converts the
4
  repo-emitted TJA subset to audio-clock events, including OFFSET, measure-local
5
- subdivisions, and piecewise BPM changes. Drum timbres come from a bundled,
6
- hash-validated CC0 large barrel-drum kit; no commercial-game audio is used.
 
7
  """
8
 
9
  from __future__ import annotations
@@ -25,7 +26,7 @@ _HIT_KIND = {"1": "don", "2": "ka", "3": "don_big", "4": "ka_big"}
25
  _SPAN_KIND = {"5": "roll", "6": "roll_big", "7": "balloon"}
26
  _ALLOWED_COMMANDS = {"#GOGOSTART", "#GOGOEND"}
27
  _PCM16_CEILING = int(math.floor(32767 * 10.0 ** (-1.0 / 20.0)))
28
- _ASSET_DIR = Path(__file__).with_name("assets") / "taiko"
29
 
30
 
31
  @dataclass(frozen=True)
@@ -53,8 +54,10 @@ class _OpenSpan:
53
  @dataclass(frozen=True)
54
  class _Strike:
55
  time: float
56
- kind: str
57
  pan: float
 
 
58
  duck_depth: float
59
  autoplay: bool
60
 
@@ -227,31 +230,47 @@ def parse_tja_events(tja_text: str) -> list[TaikoEvent]:
227
 
228
 
229
  _BANKS: dict[int, dict[str, tuple[_Voice, ...]]] = {}
230
- _REQUIRED_VOICES = {
231
- "don": 2,
232
- "ka": 2,
233
- "don_big": 1,
234
- "ka_big": 1,
235
- "roll": 2,
236
- "roll_big": 2,
237
- "balloon": 2,
238
- }
239
-
240
-
241
- def _load_pcm16_voice(path: Path, anchor_frame: int,
242
- sample_rate: int) -> _Voice:
 
 
 
 
 
 
 
 
243
  with wave.open(str(path), "rb") as source:
244
  if source.getnchannels() != 1 or source.getsampwidth() != 2:
245
  raise ValueError(f"Taiko asset must be mono PCM16: {path.name}")
246
- source_rate = source.getframerate()
247
- pcm = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
248
- if source_rate != 44_100:
249
- raise ValueError(f"Taiko asset must be 44.1 kHz: {path.name}")
 
 
 
 
 
250
  samples = pcm.astype(np.float32) / 32767.0
 
 
 
251
  if sample_rate != source_rate:
252
  ratio = Fraction(sample_rate, source_rate).limit_denominator()
253
  samples = resample_poly(samples, ratio.numerator, ratio.denominator).astype(np.float32)
254
- anchor_frame = int(round(anchor_frame * sample_rate / source_rate))
255
  if not 0 <= anchor_frame < len(samples):
256
  raise ValueError(f"Taiko asset anchor is outside the sample: {path.name}")
257
  return _Voice(np.ascontiguousarray(samples), anchor_frame)
@@ -266,11 +285,22 @@ def _sample_bank(sample_rate: int) -> dict[str, tuple[_Voice, ...]]:
266
  if not manifest_path.is_file():
267
  raise FileNotFoundError(f"Taiko asset manifest is missing: {manifest_path}")
268
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
269
- if manifest.get("schema_version") != 1 or manifest.get("license") != "CC0-1.0":
270
- raise ValueError("Taiko asset manifest must be schema 1 and CC0-1.0")
 
 
 
 
 
 
 
 
 
 
 
271
 
272
  grouped: dict[str, list[tuple[int, _Voice]]] = {kind: [] for kind in _REQUIRED_VOICES}
273
- for item in manifest.get("files", []):
274
  kind = item.get("kind")
275
  if kind not in grouped:
276
  raise ValueError(f"unsupported Taiko asset kind in manifest: {kind!r}")
@@ -283,8 +313,17 @@ def _sample_bank(sample_rate: int) -> dict[str, tuple[_Voice, ...]]:
283
  file_hash = hashlib.sha256(path.read_bytes()).hexdigest()
284
  if file_hash != item.get("sha256"):
285
  raise ValueError(f"Taiko asset hash mismatch: {filename}")
286
- voice = _load_pcm16_voice(path, int(item["anchor_frame"]), sample_rate)
287
- grouped[kind].append((int(item["round_robin"]), voice))
 
 
 
 
 
 
 
 
 
288
 
289
  bank = {}
290
  for kind, expected_count in _REQUIRED_VOICES.items():
@@ -293,8 +332,6 @@ def _sample_bank(sample_rate: int) -> dict[str, tuple[_Voice, ...]]:
293
  raise ValueError(
294
  f"Taiko kit requires {expected_count} {kind} voice(s), got {len(entries)}"
295
  )
296
- if [index for index, _ in entries] != list(range(1, expected_count + 1)):
297
- raise ValueError(f"Taiko {kind} round-robin indices must be contiguous from 1")
298
  bank[kind] = tuple(voice for _, voice in entries)
299
  _BANKS[sample_rate] = bank
300
  return bank
@@ -310,9 +347,20 @@ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
310
  for event in events:
311
  if event.kind in {"don", "ka", "don_big", "ka_big"}:
312
  pan_width = 0.08 if event.kind in {"ka", "ka_big"} else 0.015
313
- pan = -pan_width if ordinary_index % 2 == 0 else pan_width
 
 
 
 
 
 
 
 
 
314
  depth = 0.26 if event.kind.endswith("_big") else 0.22
315
- strikes.append(_Strike(event.time, event.kind, pan, depth, False))
 
 
316
  ordinary_index += 1
317
  continue
318
 
@@ -320,7 +368,9 @@ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
320
  times = event.time + np.arange(event.hit_count, dtype=np.float64) * (
321
  event.duration / event.hit_count
322
  )
323
- kind = "balloon"
 
 
324
  depth = 0.070
325
  else:
326
  interval = 0.074 if event.kind == "roll_big" else 0.086
@@ -329,12 +379,16 @@ def _expand_strikes(events: list[TaikoEvent]) -> tuple[list[_Strike], int]:
329
  times = times[times < event.time + event.duration - 0.008]
330
  if len(times) == 0:
331
  times = np.asarray([event.time])
332
- kind = event.kind
 
 
333
  depth = 0.085 if event.kind == "roll_big" else 0.060
334
  for index, time in enumerate(times):
335
- pan_width = 0.10 if kind == "roll_big" else 0.06
336
  pan = -pan_width if index % 2 == 0 else pan_width
337
- strikes.append(_Strike(float(time), kind, pan, depth, True))
 
 
338
  autoplay_count += 1
339
  strikes.sort(key=lambda strike: strike.time)
340
  return strikes, autoplay_count
@@ -379,13 +433,31 @@ def _place_strike(drums: np.ndarray, voice: _Voice, strike: _Strike,
379
  start = int(round(strike.time * sample_rate)) - voice.anchor_frame
380
  sample_start = max(0, -start)
381
  output_start = max(0, start)
382
- count = min(len(sample) - sample_start, len(drums) - output_start)
 
 
 
 
 
 
383
  if count <= 0:
384
  return False
385
 
386
  angle = (float(np.clip(strike.pan, -1.0, 1.0)) + 1.0) * np.pi / 4.0
387
  left_gain, right_gain = math.cos(angle), math.sin(angle)
388
  segment = sample[sample_start:sample_start + count]
 
 
 
 
 
 
 
 
 
 
 
 
389
  drums[output_start:output_start + count, 0] += segment * left_gain
390
  drums[output_start:output_start + count, 1] += segment * right_gain
391
  return True
@@ -468,11 +540,18 @@ def synthesize_taiko_preview(
468
  source_frames = len(music)
469
  output_frames = source_frames
470
  for index, strike in enumerate(strikes):
471
- voice = bank[strike.kind][index % len(bank[strike.kind])]
 
472
  start = int(round(strike.time * sample_rate)) - voice.anchor_frame
 
 
 
 
 
 
473
  output_frames = max(
474
  output_frames,
475
- max(0, start + len(voice.samples)),
476
  )
477
  if output_frames > source_frames:
478
  music = np.pad(music, ((0, output_frames - source_frames), (0, 0)))
@@ -480,7 +559,7 @@ def synthesize_taiko_preview(
480
  audible_strikes: list[_Strike] = []
481
 
482
  for index, strike in enumerate(strikes):
483
- voices = bank[strike.kind]
484
  voice = voices[index % len(voices)]
485
  if _place_strike(drums, voice, strike, sample_rate):
486
  audible_strikes.append(strike)
 
1
+ """Deterministic modal-resonator Taiko preview mixing.
2
 
3
  The chart is the timing authority: :func:`parse_tja_events` converts the
4
  repo-emitted TJA subset to audio-clock events, including OFFSET, measure-local
5
+ subdivisions, and piecewise BPM changes. Drum timbres are three bundled,
6
+ hash-validated synthesized voices: Don, Big Don, and Katsu. Private reference
7
+ audio is neither bundled nor required at runtime.
8
  """
9
 
10
  from __future__ import annotations
 
26
  _SPAN_KIND = {"5": "roll", "6": "roll_big", "7": "balloon"}
27
  _ALLOWED_COMMANDS = {"#GOGOSTART", "#GOGOEND"}
28
  _PCM16_CEILING = int(math.floor(32767 * 10.0 ** (-1.0 / 20.0)))
29
+ _ASSET_DIR = Path(__file__).with_name("assets") / "taiko_synth"
30
 
31
 
32
  @dataclass(frozen=True)
 
54
  @dataclass(frozen=True)
55
  class _Strike:
56
  time: float
57
+ voice_kind: str
58
  pan: float
59
+ gain: float
60
+ tail_seconds: float | None
61
  duck_depth: float
62
  autoplay: bool
63
 
 
230
 
231
 
232
  _BANKS: dict[int, dict[str, tuple[_Voice, ...]]] = {}
233
+ _REQUIRED_VOICES = {"don": 1, "don_big": 1, "katsu": 1}
234
+
235
+
236
+ def _measurable_anchor(samples: np.ndarray) -> int:
237
+ peak = float(np.max(np.abs(samples)))
238
+ if not math.isfinite(peak) or peak <= 1e-12:
239
+ raise ValueError("Taiko asset must contain finite, non-silent audio")
240
+ candidates = np.flatnonzero(np.abs(samples) >= peak * 10.0 ** (-30.0 / 20.0))
241
+ if len(candidates) == 0:
242
+ raise ValueError("Taiko asset has no measurable onset")
243
+ return int(candidates[0])
244
+
245
+
246
+ def _load_pcm16_voice(
247
+ path: Path,
248
+ anchor_frame: int,
249
+ sample_rate: int,
250
+ source_rate: int,
251
+ expected_frames: int,
252
+ expected_pcm_hash: str,
253
+ ) -> _Voice:
254
  with wave.open(str(path), "rb") as source:
255
  if source.getnchannels() != 1 or source.getsampwidth() != 2:
256
  raise ValueError(f"Taiko asset must be mono PCM16: {path.name}")
257
+ actual_rate = source.getframerate()
258
+ actual_frames = source.getnframes()
259
+ pcm = np.frombuffer(source.readframes(actual_frames), dtype="<i2")
260
+ if actual_rate != source_rate:
261
+ raise ValueError(f"Taiko asset sample-rate mismatch: {path.name}")
262
+ if actual_frames != expected_frames:
263
+ raise ValueError(f"Taiko asset frame-count mismatch: {path.name}")
264
+ if hashlib.sha256(pcm.tobytes()).hexdigest() != expected_pcm_hash:
265
+ raise ValueError(f"Taiko asset PCM hash mismatch: {path.name}")
266
  samples = pcm.astype(np.float32) / 32767.0
267
+ measured_anchor = _measurable_anchor(samples)
268
+ if measured_anchor != anchor_frame:
269
+ raise ValueError(f"Taiko asset onset-anchor mismatch: {path.name}")
270
  if sample_rate != source_rate:
271
  ratio = Fraction(sample_rate, source_rate).limit_denominator()
272
  samples = resample_poly(samples, ratio.numerator, ratio.denominator).astype(np.float32)
273
+ anchor_frame = _measurable_anchor(samples)
274
  if not 0 <= anchor_frame < len(samples):
275
  raise ValueError(f"Taiko asset anchor is outside the sample: {path.name}")
276
  return _Voice(np.ascontiguousarray(samples), anchor_frame)
 
285
  if not manifest_path.is_file():
286
  raise FileNotFoundError(f"Taiko asset manifest is missing: {manifest_path}")
287
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
288
+ if manifest.get("schema_version") != 2 or manifest.get("license") != "MIT":
289
+ raise ValueError("Taiko synth manifest must be schema 2 and MIT licensed")
290
+ source_rate = int(manifest.get("sample_rate", 0))
291
+ if source_rate != 48_000:
292
+ raise ValueError("Taiko synth assets must be generated at 48 kHz")
293
+
294
+ entries = manifest.get("files")
295
+ if not isinstance(entries, list):
296
+ raise ValueError("Taiko synth manifest files must be a list")
297
+ listed_files = {item.get("filename") for item in entries if isinstance(item, dict)}
298
+ bundled_files = {path.name for path in _ASSET_DIR.glob("*.wav")}
299
+ if listed_files != bundled_files:
300
+ raise ValueError("Taiko synth manifest must cover every bundled WAV exactly")
301
 
302
  grouped: dict[str, list[tuple[int, _Voice]]] = {kind: [] for kind in _REQUIRED_VOICES}
303
+ for item in entries:
304
  kind = item.get("kind")
305
  if kind not in grouped:
306
  raise ValueError(f"unsupported Taiko asset kind in manifest: {kind!r}")
 
313
  file_hash = hashlib.sha256(path.read_bytes()).hexdigest()
314
  if file_hash != item.get("sha256"):
315
  raise ValueError(f"Taiko asset hash mismatch: {filename}")
316
+ if item.get("sample_rate") != source_rate or item.get("channels") != 1:
317
+ raise ValueError(f"Taiko asset format metadata mismatch: {filename}")
318
+ voice = _load_pcm16_voice(
319
+ path,
320
+ int(item["anchor_frame"]),
321
+ sample_rate,
322
+ source_rate,
323
+ int(item["frames"]),
324
+ str(item["pcm_sha256"]),
325
+ )
326
+ grouped[kind].append((1, voice))
327
 
328
  bank = {}
329
  for kind, expected_count in _REQUIRED_VOICES.items():
 
332
  raise ValueError(
333
  f"Taiko kit requires {expected_count} {kind} voice(s), got {len(entries)}"
334
  )
 
 
335
  bank[kind] = tuple(voice for _, voice in entries)
336
  _BANKS[sample_rate] = bank
337
  return bank
 
347
  for event in events:
348
  if event.kind in {"don", "ka", "don_big", "ka_big"}:
349
  pan_width = 0.08 if event.kind in {"ka", "ka_big"} else 0.015
350
+ pan = 0.0 if event.kind == "don_big" else (
351
+ -pan_width if ordinary_index % 2 == 0 else pan_width
352
+ )
353
+ voice_kind = {
354
+ "don": "don",
355
+ "ka": "katsu",
356
+ "don_big": "don_big",
357
+ "ka_big": "katsu",
358
+ }[event.kind]
359
+ gain = 1.12 if event.kind == "ka_big" else 1.0
360
  depth = 0.26 if event.kind.endswith("_big") else 0.22
361
+ strikes.append(_Strike(
362
+ event.time, voice_kind, pan, gain, None, depth, False,
363
+ ))
364
  ordinary_index += 1
365
  continue
366
 
 
368
  times = event.time + np.arange(event.hit_count, dtype=np.float64) * (
369
  event.duration / event.hit_count
370
  )
371
+ voice_kind = "don"
372
+ gain = 0.54
373
+ tail_seconds = 0.135
374
  depth = 0.070
375
  else:
376
  interval = 0.074 if event.kind == "roll_big" else 0.086
 
379
  times = times[times < event.time + event.duration - 0.008]
380
  if len(times) == 0:
381
  times = np.asarray([event.time])
382
+ voice_kind = "don_big" if event.kind == "roll_big" else "don"
383
+ gain = 0.60 if event.kind == "roll_big" else 0.56
384
+ tail_seconds = 0.175 if event.kind == "roll_big" else 0.150
385
  depth = 0.085 if event.kind == "roll_big" else 0.060
386
  for index, time in enumerate(times):
387
+ pan_width = 0.10 if voice_kind == "don_big" else 0.06
388
  pan = -pan_width if index % 2 == 0 else pan_width
389
+ strikes.append(_Strike(
390
+ float(time), voice_kind, pan, gain, tail_seconds, depth, True,
391
+ ))
392
  autoplay_count += 1
393
  strikes.sort(key=lambda strike: strike.time)
394
  return strikes, autoplay_count
 
433
  start = int(round(strike.time * sample_rate)) - voice.anchor_frame
434
  sample_start = max(0, -start)
435
  output_start = max(0, start)
436
+ sample_end = len(sample)
437
+ if strike.tail_seconds is not None:
438
+ sample_end = min(
439
+ sample_end,
440
+ voice.anchor_frame + int(round(strike.tail_seconds * sample_rate)),
441
+ )
442
+ count = min(sample_end - sample_start, len(drums) - output_start)
443
  if count <= 0:
444
  return False
445
 
446
  angle = (float(np.clip(strike.pan, -1.0, 1.0)) + 1.0) * np.pi / 4.0
447
  left_gain, right_gain = math.cos(angle), math.sin(angle)
448
  segment = sample[sample_start:sample_start + count]
449
+ if strike.tail_seconds is not None:
450
+ source_indices = np.arange(sample_start, sample_start + count)
451
+ active_frames = source_indices - voice.anchor_frame
452
+ fade_start = int(round(strike.tail_seconds * sample_rate * 0.43))
453
+ tail_frames = max(fade_start + 1, int(round(strike.tail_seconds * sample_rate)))
454
+ fade_position = np.clip(
455
+ (active_frames - fade_start) / (tail_frames - fade_start), 0.0, 1.0,
456
+ )
457
+ envelope = 0.5 + 0.5 * np.cos(np.pi * fade_position)
458
+ envelope[active_frames < 0] = 1.0
459
+ segment = segment * envelope.astype(np.float32)
460
+ segment = segment * strike.gain
461
  drums[output_start:output_start + count, 0] += segment * left_gain
462
  drums[output_start:output_start + count, 1] += segment * right_gain
463
  return True
 
540
  source_frames = len(music)
541
  output_frames = source_frames
542
  for index, strike in enumerate(strikes):
543
+ voices = bank[strike.voice_kind]
544
+ voice = voices[index % len(voices)]
545
  start = int(round(strike.time * sample_rate)) - voice.anchor_frame
546
+ voice_end = len(voice.samples)
547
+ if strike.tail_seconds is not None:
548
+ voice_end = min(
549
+ voice_end,
550
+ voice.anchor_frame + int(round(strike.tail_seconds * sample_rate)),
551
+ )
552
  output_frames = max(
553
  output_frames,
554
+ max(0, start + voice_end),
555
  )
556
  if output_frames > source_frames:
557
  music = np.pad(music, ((0, output_frames - source_frames), (0, 0)))
 
559
  audible_strikes: list[_Strike] = []
560
 
561
  for index, strike in enumerate(strikes):
562
+ voices = bank[strike.voice_kind]
563
  voice = voices[index % len(voices)]
564
  if _place_strike(drums, voice, strike, sample_rate):
565
  audible_strikes.append(strike)