#!/usr/bin/env python3 """UI SFX v3: procedurally synthesize crisp, light UI one-shots (marimba / glockenspiel / bubble-pop voices). Replaces the TangoFlux v2 clips, which sounded muddy. Pure numpy and deterministic, so there is no model license. Usage: python Tools/synth_ui_sfx.py # write the 10 clips into the bundle dir and Doc/Design/Sound/UiSfx/synth_v3.json python Tools/synth_ui_sfx.py --preview # also write Tools/output/ui_sfx_v3/preview.wav (all clips back to back) """ from __future__ import annotations import argparse import json import time import wave from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[1] BUNDLE_DIR = ROOT / "AIClient/Assets/Bundles/Sound/SFX/UI" OUT_DIR = ROOT / "Tools/output/ui_sfx_v3" REPORT = ROOT / "Doc/Design/Sound/UiSfx/synth_v3.json" RATE = 44100 NYQUIST = RATE / 2 * 0.9 # Note frequencies (equal temperament, A4 = 440). G5, C6, D6, E6, G6, A6, B6, C7, D7, E7, G7, A7, C8 = 784.0, 1046.5, 1174.7, 1318.5, 1568.0, 1760.0, 1975.5, 2093.0, 2349.3, 2637.0, 3136.0, 3520.0, 4186.0 def axis(dur: float) -> np.ndarray: return np.arange(int(dur * RATE)) / RATE def attack(t: np.ndarray, ms: float = 1.5) -> np.ndarray: return np.minimum(1.0, t / (ms / 1000)) def partials(freq: float, dur: float, ratios, amps, decays) -> np.ndarray: t = axis(dur) out = np.zeros_like(t) for r, a, d in zip(ratios, amps, decays): if freq * r < NYQUIST: out += a * np.sin(2 * np.pi * freq * r * t) * np.exp(-t / d) return out * attack(t) def marimba(freq: float, decay: float = .07, bright: float = .35, dur: float | None = None) -> np.ndarray: """Woody mallet: strong fundamental plus the bar's ~3.9x and ~9.2x modes, which die fast for the 'tok'.""" return partials(freq, dur or decay * 6, [1, 3.93, 9.2], [1, bright, bright * .3], [decay, decay * .3, decay * .12]) def glock(freq: float, decay: float = .2, dur: float | None = None) -> np.ndarray: """Glockenspiel / small bell: inharmonic bar partials give the sparkle.""" return partials(freq, dur or decay * 5, [1, 2.76, 5.40, 8.93], [1, .4, .2, .08], [decay, decay * .45, decay * .25, decay * .15]) def pop(f0: float, f1: float, dur: float = .06, decay: float = .02, glide: float = .012) -> np.ndarray: """Bubble pop: a sine whose pitch flicks from f0 to f1, the classic cute button blip.""" t = axis(dur) freq = f1 + (f0 - f1) * np.exp(-t / glide) phase = 2 * np.pi * np.cumsum(freq) / RATE return (np.sin(phase) + .15 * np.sin(2 * phase)) * np.exp(-t / decay) * attack(t, 1) def sweep(f0: float, f1: float, dur: float) -> np.ndarray: """Smooth glide with a rounded envelope: the soft 'fwip' under open/close.""" t = axis(dur) freq = f0 * (f1 / f0) ** (t / dur) phase = 2 * np.pi * np.cumsum(freq) / RATE return np.sin(phase) * np.sin(np.pi * t / dur) ** 1.5 def tick(seed: int, dur: float = .004) -> np.ndarray: """A few ms of high-passed noise: the crisp transient on top of tonal hits.""" t = axis(dur) noise = np.diff(np.random.default_rng(seed).standard_normal(len(t) + 1)) return noise / np.abs(noise).max() * np.exp(-t / .0008) def mix(length: float, *layers: tuple[float, float, np.ndarray]) -> np.ndarray: out = np.zeros(int(length * RATE)) for at, gain, sig in layers: start = int(at * RATE) n = min(len(sig), len(out) - start) out[start:start + n] += gain * sig[:n] return out def finish(sig: np.ndarray, peak_db: float, tail_ms: float = 15) -> np.ndarray: n = min(len(sig), int(RATE * tail_ms / 1000)) sig = sig.copy() sig[-n:] *= np.linspace(1, 0, n) return sig * (10 ** (peak_db / 20)) / np.abs(sig).max() def sparkle(seed: int, start: float, end: float, count: int) -> list[tuple[float, float, np.ndarray]]: rng = np.random.default_rng(seed) notes = [E7, G7, A7, C8] return [(float(rng.uniform(start, end)), float(rng.uniform(.12, .22)), glock(notes[int(rng.integers(len(notes)))], .06)) for _ in range(count)] # Each recipe: (description, peak dB, signal) RECIPES = { "Ui_Click": ("bubble pop 700→1250 Hz + noise tick", -6, lambda: mix(.08, (0, 1, pop(700, 1250, .08, .02)), (0, .18, tick(1)))), "Ui_Confirm": ("marimba E6→B6 rising, glockenspiel on top note", -5, lambda: mix(.36, (0, 1, marimba(E6, .07)), (.075, 1, marimba(B6, .08)), (.075, .35, glock(B6, .09)), (0, .15, tick(2)))), "Ui_Back": ("soft marimba G6→D6 falling", -7, lambda: mix(.22, (0, .8, marimba(G6, .045, .2)), (.06, 1, marimba(D6, .05, .2)))), "Ui_Open": ("rising fwip 520→1400 Hz into an E7 ping", -7, lambda: mix(.34, (0, .55, sweep(520, 1400, .11)), (.09, 1, glock(E7, .08)), (.09, .4, marimba(E6, .05)))), "Ui_Close": ("falling fwip 1300→480 Hz into a soft G5 tok", -7, lambda: mix(.24, (0, .55, sweep(1300, 480, .11)), (.09, 1, marimba(G5, .045, .25)))), "Ui_Tab": ("light marimba A6 tick", -8, lambda: mix(.07, (0, 1, marimba(A6, .022, .25)), (0, .2, tick(3)))), "Ui_Toggle": ("double pop 900→1500 then 1200→1900 Hz", -8, lambda: mix(.1, (0, .8, pop(900, 1500, .05, .015)), (.035, 1, pop(1200, 1900, .06, .016)))), "Ui_Claim": ("glockenspiel arpeggio C6-E6-G6-C7 with marimba body and high sparkles", -3, lambda: mix(.78, (0, .7, glock(C6, .12)), (.06, .75, glock(E6, .12)), (.12, .8, glock(G6, .14)), (.18, 1, glock(C7, .2)), (0, .35, marimba(C6, .05)), (.06, .35, marimba(E6, .05)), (.12, .35, marimba(G6, .05)), (.18, .4, marimba(C7, .06)), *sparkle(7, .22, .5, 6))), "Ui_Deny": ("two soft low boops 330→262 Hz, gentle not harsh", -7, lambda: mix(.27, (0, 1, pop(380, 330, .1, .035, .02)), (.11, 1, pop(300, 262, .14, .045, .02)))), "Ui_Notify": ("bell ding-ding G6 then D7", -6, lambda: mix(.52, (0, .8, glock(G6, .14)), (.11, 1, glock(D7, .16)))), } def write_wav(path: Path, mono: np.ndarray) -> None: pcm = (np.clip(mono, -1, 1) * 32767).round().astype(np.int16) stereo = np.repeat(pcm[:, None], 2, axis=1) # same layout as earlier batches (44.1 kHz stereo) path.parent.mkdir(parents=True, exist_ok=True) with wave.open(str(path), "wb") as w: w.setnchannels(2) w.setsampwidth(2) w.setframerate(RATE) w.writeframes(stereo.tobytes()) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--preview", action="store_true", help="Also write a back-to-back preview wav") args = ap.parse_args() rows, preview = [], [] for name, (desc, peak_db, build) in RECIPES.items(): clip = finish(build(), peak_db) write_wav(BUNDLE_DIR / f"{name}.wav", clip) write_wav(OUT_DIR / f"{name}.wav", clip) preview += [clip, np.zeros(int(RATE * .45))] rows.append({"id": name, "recipe": desc, "peakDb": peak_db, "lengthSec": round(len(clip) / RATE, 3)}) print(json.dumps(rows[-1], ensure_ascii=False)) if args.preview: write_wav(OUT_DIR / "preview.wav", np.concatenate(preview)) REPORT.write_text(json.dumps({"batchId": "ui_sfx_v3", "method": "procedural synthesis (Tools/synth_ui_sfx.py, numpy)", "format": "44.1 kHz 16-bit stereo (dual mono)", "generatedUtc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "items": rows}, ensure_ascii=False, indent=2), encoding="utf-8") if __name__ == "__main__": main()