Spaces:
Sleeping
Sleeping
Replace the preview kit with calibrated modal synthesis
Browse files- README.md +5 -5
- scripts/build_taiko_bank.py +0 -285
- scripts/build_taiko_synth.py +437 -0
- softchart/assets/taiko/README.md +0 -17
- softchart/assets/taiko/balloon-01.wav +0 -0
- softchart/assets/taiko/balloon-02.wav +0 -0
- softchart/assets/taiko/don-01.wav +0 -0
- softchart/assets/taiko/don-02.wav +0 -0
- softchart/assets/taiko/don-big.wav +0 -0
- softchart/assets/taiko/ka-01.wav +0 -0
- softchart/assets/taiko/ka-02.wav +0 -0
- softchart/assets/taiko/ka-big.wav +0 -0
- softchart/assets/taiko/manifest.json +0 -310
- softchart/assets/taiko/roll-01.wav +0 -0
- softchart/assets/taiko/roll-02.wav +0 -0
- softchart/assets/taiko/roll-big-01.wav +0 -0
- softchart/assets/taiko/roll-big-02.wav +0 -0
- softchart/assets/taiko_synth/README.md +23 -0
- softchart/assets/taiko_synth/don-big.wav +0 -0
- softchart/assets/taiko_synth/don.wav +0 -0
- softchart/assets/taiko_synth/katsu.wav +0 -0
- softchart/assets/taiko_synth/manifest.json +69 -0
- softchart/preview_audio.py +119 -40
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.
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 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
|
| 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
|
| 6 |
-
hash-validated
|
|
|
|
| 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") / "
|
| 29 |
|
| 30 |
|
| 31 |
@dataclass(frozen=True)
|
|
@@ -53,8 +54,10 @@ class _OpenSpan:
|
|
| 53 |
@dataclass(frozen=True)
|
| 54 |
class _Strike:
|
| 55 |
time: float
|
| 56 |
-
|
| 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 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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") !=
|
| 270 |
-
raise ValueError("Taiko
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
grouped: dict[str, list[tuple[int, _Voice]]] = {kind: [] for kind in _REQUIRED_VOICES}
|
| 273 |
-
for item in
|
| 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 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
depth = 0.26 if event.kind.endswith("_big") else 0.22
|
| 315 |
-
strikes.append(_Strike(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 336 |
pan = -pan_width if index % 2 == 0 else pan_width
|
| 337 |
-
strikes.append(_Strike(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 472 |
start = int(round(strike.time * sample_rate)) - voice.anchor_frame
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
output_frames = max(
|
| 474 |
output_frames,
|
| 475 |
-
max(0, start +
|
| 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.
|
| 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)
|