# /// script # requires-python = ">=3.11" # dependencies = ["numpy<2.1", "scipy"] # /// """Build SoftChart's three-voice modal-resonator Taiko preview bank. The supplied reference WAVs are used offline for RMS-envelope and level calibration only. Reference samples, paths, hashes, and envelope arrays are never written to the output. Every output sample is generated from a seeded noise/impulse excitation, modal resonators, and a decaying pitch sweep. """ from __future__ import annotations import argparse import hashlib import json import math import wave from dataclasses import dataclass from pathlib import Path import numpy as np from scipy.signal import butter, sosfilt SAMPLE_RATE = 48_000 RANDOM_SEED = 20_260_713 BUILD_ORDER = ("Big Don", "Don", "Katsu") EXPECTED_FRAMES = {"Big Don": 42_946, "Don": 39_836, "Katsu": 19_537} SYNTH_FRAMES = EXPECTED_FRAMES OUTPUT_NAMES = { "Big Don": ("don-big.wav", "don_big"), "Don": ("don.wav", "don"), "Katsu": ("katsu.wav", "katsu"), } @dataclass(frozen=True) class ReferencePaths: big_don: Path don: Path katsu: Path def by_role(self) -> dict[str, Path]: return {"Big Don": self.big_don, "Don": self.don, "Katsu": self.katsu} def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1 << 20), b""): digest.update(block) return digest.hexdigest() def _read_reference(path: Path, role: str) -> np.ndarray: if not path.is_file(): raise FileNotFoundError(f"missing {role} reference WAV: {path}") with wave.open(str(path), "rb") as source: sample_rate = source.getframerate() channels = source.getnchannels() width = source.getsampwidth() frames = source.getnframes() payload = source.readframes(frames) if sample_rate != SAMPLE_RATE or channels != 1 or width != 2: raise ValueError(f"{role} reference must be mono PCM16 at 48 kHz") if frames != EXPECTED_FRAMES[role]: raise ValueError( f"{role} reference must contain {EXPECTED_FRAMES[role]} frames, got {frames}" ) audio = np.frombuffer(payload, dtype=" np.ndarray: output = np.zeros_like(excitation) for frequency, gain, t60 in zip(frequencies, gains, t60s, strict=True): radius = 10.0 ** (-3.0 / (max(t60, 0.001) * SAMPLE_RATE)) a1 = 2.0 * radius * math.cos(2.0 * math.pi * frequency / SAMPLE_RATE) a2 = -(radius * radius) input_gain = (1.0 - radius) * 4.0 previous = second_previous = 0.0 mode = np.empty_like(excitation) for index, sample in enumerate(excitation): value = input_gain * sample + a1 * previous + a2 * second_previous mode[index] = value second_previous, previous = previous, value output += gain * mode return output def _soft_normalize(audio: np.ndarray, peak: float = 0.95) -> np.ndarray: shaped = np.tanh(audio * 1.15) maximum = float(np.max(np.abs(shaped))) if not math.isfinite(maximum) or maximum <= 1e-12: raise ValueError("synthesizer produced silent or non-finite audio") return shaped * (peak / maximum) def _make_synth(role: str, rng: np.random.Generator) -> np.ndarray: frame_count = SYNTH_FRAMES[role] if role == "Big Don": frequencies = [86, 125, 141, 176, 193, 240, 264, 291, 313, 344, 409, 529, 578, 700, 788, 869, 957, 1207, 1650, 2200, 2770] gains = [1.00, .92, .28, .22, .45, .38, .28, .22, .18, .12, .12, .09, .07, .07, .08, .045, .04, .035, .018, .012, .010] t60s = [.72, .82, .55, .50, .46, .40, .36, .34, .31, .28, .25, .22, .19, .16, .14, .12, .11, .09, .07, .055, .045] cutoff, noise_decay = 6_500.0, .018 sweep_start, sweep_end, sweep_decay = 302.0, 82.0, .11 direct_amount, body_amount, sweep_amount = .13, 1.60, .23 elif role == "Don": frequencies = [86, 125, 141, 174, 193, 240, 247, 276, 292, 313, 368, 386, 408, 487, 540, 578, 647, 694, 787, 870, 962, 2165] gains = [1.0, .60, .27, .15, .34, .36, .23, .18, .20, .21, .10, .15, .10, .08, .08, .07, .07, .12, .12, .05, .05, .018] t60s = [.52, .48, .37, .34, .34, .31, .30, .27, .25, .24, .22, .21, .20, .18, .17, .16, .14, .14, .12, .10, .09, .052] cutoff, noise_decay = 7_500.0, .024 sweep_start, sweep_end, sweep_decay = 346.0, 86.0, .075 direct_amount, body_amount, sweep_amount = .17, 1.55, .16 elif role == "Katsu": time = np.arange(frame_count) / SAMPLE_RATE noise = rng.standard_normal(frame_count) high = sosfilt( butter(3, 300 / (SAMPLE_RATE / 2), btype="high", output="sos"), noise, ) band = sosfilt( butter( 4, [450 / (SAMPLE_RATE / 2), 10_000 / (SAMPLE_RATE / 2)], btype="band", output="sos", ), noise, ) excitation = np.zeros(frame_count) excitation[0] = 4.5 excitation += 1.6 * band * np.exp(-time / .018) frequencies = [455, 560, 641, 735, 849, 1025, 1047, 1166, 1214, 1374, 2188, 2231, 2279, 2933, 2994, 3083, 3123, 3856, 4006] gains = [.25, .32, .38, .30, .26, .65, .52, .34, .26, .30, .85, .72, .38, .38, .30, .40, .34, .30, .28] t60s = [.23, .18, .16, .14, .13, .20, .18, .15, .14, .13, .17, .16, .13, .12, .11, .12, .11, .10, .10] body = _resonator_bank(excitation, frequencies, gains, t60s) direct = .32 * high * np.exp(-time / .050) + .50 * band * np.exp(-time / .012) click_frames = max(1, int(.0012 * SAMPLE_RATE)) click = np.zeros(frame_count) click[:click_frames] = rng.standard_normal(click_frames) * np.hanning(click_frames) synth = 1.2 * body + direct + .18 * click lead = int(.018 * SAMPLE_RATE) return _soft_normalize(np.concatenate([np.zeros(lead), synth])[:frame_count]) else: raise ValueError(f"unsupported synth role: {role}") time = np.arange(frame_count) / SAMPLE_RATE noise = rng.standard_normal(frame_count) low = sosfilt( butter(4, cutoff / (SAMPLE_RATE / 2), btype="low", output="sos"), noise, ) excitation = np.zeros(frame_count) excitation[0] = 3.0 excitation += 1.2 * low * np.exp(-time / noise_decay) body = _resonator_bank(excitation, frequencies, gains, t60s) instantaneous_frequency = ( (sweep_start - sweep_end) * np.exp(-time / .025) + sweep_end ) phase = 2.0 * math.pi * np.cumsum(instantaneous_frequency) / SAMPLE_RATE sweep = np.sin(phase) * np.exp(-time / sweep_decay) synth = ( body_amount * body + sweep_amount * sweep + direct_amount * low * np.exp(-time / noise_decay) ) synth *= ( 1.0 + .03 * np.sin(2.0 * math.pi * 7.7 * time) + .018 * np.sin(2.0 * math.pi * 14.9 * time + .5) ) lead = int(.018 * SAMPLE_RATE) return _soft_normalize(np.concatenate([np.zeros(lead), synth])[:frame_count]) def _attack_burst( frame_count: int, seed: int, low_hz: float, high_hz: float, attack_seconds: float, decay_seconds: float, ) -> np.ndarray: time = np.arange(frame_count) / SAMPLE_RATE noise = np.random.default_rng(seed).standard_normal(frame_count) band = sosfilt( butter( 3, [low_hz / (SAMPLE_RATE / 2), high_hz / (SAMPLE_RATE / 2)], btype="band", output="sos", ), noise, ) envelope = ( (1.0 - np.exp(-time / attack_seconds)) * np.exp(-time / decay_seconds) ) return band * envelope def _calibrate_spectrum(role: str, synth: np.ndarray) -> np.ndarray: """Tune attack and modal balance while preserving the supplied method.""" lead = int(.018 * SAMPLE_RATE) active_frames = len(synth) - lead time = np.arange(active_frames) / SAMPLE_RATE calibrated = synth.copy() if role in {"Big Don", "Don"}: seed = 9_201 if role == "Big Don" else 9_202 burst = _attack_burst( active_frames, seed, 2_000.0, 14_000.0, .0006, .0045, ) if role == "Big Don": calibrated[lead:] += 2.05 * burst calibrated[lead:] += ( .25 * np.sin(2.0 * math.pi * 86.0 * time) * np.exp(-time / .50) ) else: calibrated[lead:] += 2.40 * burst calibrated[lead:] += ( .20 * np.sin(2.0 * math.pi * 125.0 * time) * np.exp(-time / .30) ) return calibrated if role == "Katsu": low = sosfilt( butter(4, 3_000.0 / (SAMPLE_RATE / 2), btype="low", output="sos"), calibrated, ) mid = sosfilt( butter( 3, [1_400.0 / (SAMPLE_RATE / 2), 2_800.0 / (SAMPLE_RATE / 2)], btype="band", output="sos", ), calibrated, ) burst = _attack_burst( active_frames, 9_203, 1_000.0, 10_000.0, .0003, .004, ) calibrated = low calibrated[lead:] += 12.0 * mid[lead:] * np.exp(-time / .060) calibrated[lead:] += 10.0 * burst return calibrated raise ValueError(f"unsupported synth role: {role}") def _rms_envelope(audio: np.ndarray, milliseconds: float = 6.0) -> np.ndarray: window = max(1, int(SAMPLE_RATE * milliseconds / 1_000.0)) return np.sqrt( np.convolve(audio * audio, np.ones(window) / window, mode="same") + 1e-10 ) def _match_reference_envelope( synth: np.ndarray, reference: np.ndarray, ) -> np.ndarray: frame_count = min(len(synth), len(reference)) if frame_count <= 0: raise ValueError("synthesis and reference must both contain audio") reference = reference[:frame_count] matched = synth[:frame_count].copy() window = max(3, int(.012 * SAMPLE_RATE)) kernel = np.hanning(window) kernel /= np.sum(kernel) for _ in range(2): ratio = _rms_envelope(reference) / (_rms_envelope(matched) + 1e-7) log_ratio = np.log(np.clip(ratio, .03, 30.0)) matched *= np.exp(np.convolve(log_ratio, kernel, mode="same")) target_rms = float(np.sqrt(np.mean(reference * reference))) measured_rms = float(np.sqrt(np.mean(matched * matched))) if measured_rms <= 1e-12: raise ValueError("envelope calibration produced silence") matched *= target_rms / measured_rms peak_limit = min(.98, max(.90, float(np.max(np.abs(reference))))) measured_peak = float(np.max(np.abs(matched))) if measured_peak > peak_limit: matched *= peak_limit / measured_peak if not np.all(np.isfinite(matched)): raise ValueError("envelope calibration produced non-finite audio") return matched def _anchor_frame(signal: np.ndarray) -> int: peak = float(np.max(np.abs(signal))) candidates = np.flatnonzero(np.abs(signal) >= peak * 10.0 ** (-30.0 / 20.0)) if len(candidates) == 0: raise ValueError("synthesized voice has no measurable onset") return int(candidates[0]) def _profile(signal: np.ndarray, anchor: int) -> dict[str, float]: active = signal[anchor:] energy = active * active cumulative = np.cumsum(energy) spectrum = np.abs(np.fft.rfft(active * np.hanning(len(active)))) ** 2 frequencies = np.fft.rfftfreq(len(active), 1.0 / SAMPLE_RATE) spectrum /= max(float(np.sum(spectrum)), 1e-15) attack = active[:int(round(.010 * SAMPLE_RATE))] attack_spectrum = np.abs(np.fft.rfft(attack * np.hanning(len(attack)))) ** 2 attack_frequencies = np.fft.rfftfreq(len(attack), 1.0 / SAMPLE_RATE) tail = active[int(round(.120 * SAMPLE_RATE)):] tail_spectrum = np.abs(np.fft.rfft(tail * np.hanning(len(tail)))) ** 2 tail_frequencies = np.fft.rfftfreq(len(tail), 1.0 / SAMPLE_RATE) return { "centroid_hz": round(float(np.sum(frequencies * spectrum)), 3), "attack_centroid_hz": round(float( np.sum(attack_frequencies * attack_spectrum) / max(float(np.sum(attack_spectrum)), 1e-15) ), 3), "tail_centroid_hz": round(float( np.sum(tail_frequencies * tail_spectrum) / max(float(np.sum(tail_spectrum)), 1e-15) ), 3), "peak_seconds_after_anchor": round( float(np.argmax(np.abs(active)) / SAMPLE_RATE), 6, ), "t90_seconds_after_anchor": round( float(np.searchsorted(cumulative, .90 * cumulative[-1]) / SAMPLE_RATE), 6, ), "t99_seconds_after_anchor": round( float(np.searchsorted(cumulative, .99 * cumulative[-1]) / SAMPLE_RATE), 6, ), "crest_db": round( float(20.0 * math.log10( np.max(np.abs(active)) / np.sqrt(np.mean(energy)) )), 3, ), } def _write_pcm16(path: Path, signal: np.ndarray) -> tuple[str, str]: clipped = np.clip(signal, -1.0, 1.0) pcm = np.rint(clipped * 32767.0).astype(" None: references = { role: _read_reference(path, role) for role, path in reference_paths.by_role().items() } output_dir.mkdir(parents=True, exist_ok=True) for stale in output_dir.glob("*.wav"): stale.unlink() rng = np.random.default_rng(RANDOM_SEED) files: list[dict[str, object]] = [] for role in BUILD_ORDER: synth = _make_synth(role, rng) synth = _calibrate_spectrum(role, synth) synth = _match_reference_envelope(synth, references[role]) filename, kind = OUTPUT_NAMES[role] path = output_dir / filename file_hash, pcm_hash = _write_pcm16(path, synth) anchor = _anchor_frame(synth) files.append({ "filename": filename, "kind": kind, "sha256": file_hash, "pcm_sha256": pcm_hash, "sample_rate": SAMPLE_RATE, "channels": 1, "frames": len(synth), "anchor_frame": anchor, "profile": _profile(synth, anchor), }) manifest = { "schema_version": 2, "kit_id": "softchart-modal-resonator-v1", "license": "MIT", "sample_rate": SAMPLE_RATE, "generator": "scripts/build_taiko_synth.py", "method": ( "seeded noise/impulse excitation, modal resonators, decaying pitch " "sweep, band-limited transient calibration, and offline RMS-envelope " "calibration" ), "random_seed": RANDOM_SEED, "reference_policy": ( "Reference audio is used only for offline RMS-envelope and level " "calibration; it is not copied, mixed, embedded, or required at runtime." ), "files": sorted(files, key=lambda item: str(item["filename"])), } (output_dir / "manifest.json").write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--big-don-reference", type=Path, required=True) parser.add_argument("--don-reference", type=Path, required=True) parser.add_argument("--katsu-reference", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) args = parser.parse_args() build( ReferencePaths( big_don=args.big_don_reference, don=args.don_reference, katsu=args.katsu_reference, ), args.output_dir, ) if __name__ == "__main__": main()