"""Procedural audio synth (numpy + stdlib wave). Generates calm, professional UI SFX and a sparse ambient music loop in the spirit of Minecraft's quiet themes. Used as the offline placeholder until an open audio model (Stable Audio Open / MusicGen) bakes higher-fidelity assets via scripts/prebake_audio.py. """ from __future__ import annotations import wave from pathlib import Path import numpy as np from .manifest import MUSIC_DIR, SFX_DIR SAMPLE_RATE = 22050 def _write_wav(path: Path, samples: np.ndarray) -> None: path.parent.mkdir(parents=True, exist_ok=True) clipped = np.clip(samples, -1.0, 1.0) pcm = (clipped * 32767.0).astype(" np.ndarray: return np.linspace(0.0, duration, int(SAMPLE_RATE * duration), endpoint=False) def _adsr(n: int, attack: float = 0.01, release: float = 0.2) -> np.ndarray: env = np.ones(n) a = min(max(1, int(SAMPLE_RATE * attack)), n) r = min(max(1, int(SAMPLE_RATE * release)), max(0, n - a)) if a: env[:a] = np.linspace(0.0, 1.0, a) if r: env[-r:] = np.linspace(1.0, 0.0, r) return env def _tone(freq: float, duration: float, *, kind: str = "sine", gain: float = 0.4, attack: float = 0.01, release: float = 0.2) -> np.ndarray: t = _t(duration) phase = 2 * np.pi * freq * t if kind == "square": wave_data = np.sign(np.sin(phase)) elif kind == "triangle": wave_data = 2 / np.pi * np.arcsin(np.sin(phase)) else: wave_data = np.sin(phase) return wave_data * _adsr(len(t), attack, release) * gain def _sweep(f0: float, f1: float, duration: float, gain: float = 0.35) -> np.ndarray: t = _t(duration) freqs = np.linspace(f0, f1, len(t)) phase = 2 * np.pi * np.cumsum(freqs) / SAMPLE_RATE return np.sin(phase) * _adsr(len(t), 0.005, 0.15) * gain def _noise(duration: float, gain: float = 0.25) -> np.ndarray: n = int(SAMPLE_RATE * duration) rng = np.random.default_rng(7) return rng.uniform(-1, 1, n) * _adsr(n, 0.002, 0.08) * gain def _sfx(name: str) -> np.ndarray: if name == "click": return _tone(660, 0.06, kind="triangle", release=0.05) if name == "select": return np.concatenate([_tone(523, 0.06, release=0.04), _tone(784, 0.08, release=0.06)]) if name == "present": return _sweep(400, 900, 0.22) if name == "accuse": return _tone(110, 0.5, kind="triangle", gain=0.5, release=0.4) if name == "success": return np.concatenate([_tone(f, 0.12, release=0.1) for f in (523, 659, 784, 1046)]) if name == "fail": return np.concatenate([_tone(f, 0.16, kind="triangle", release=0.12) for f in (392, 311, 233)]) if name == "page": return _noise(0.18) return _tone(440, 0.1) def generate_sfx(out_dir: Path | None = None) -> list[Path]: out = out_dir or SFX_DIR from .manifest import SFX_EVENTS paths: list[Path] = [] for event, filename in SFX_EVENTS.items(): path = out / filename _write_wav(path, _sfx(event)) paths.append(path) return paths # A slow lofi-noir jazz loop: i - iv - V7 - i in A minor, soft Rhodes-like pads, # a gentle bass, brushed percussion, and faint vinyl crackle. _CHORDS: tuple[tuple[float, tuple[float, ...]], ...] = ( (110.00, (261.63, 329.63, 392.00)), # Am7 (A bass; C E G) (146.83, (349.23, 440.00, 523.25)), # Dm7 (D bass; F A C) (164.81, (415.30, 493.88, 587.33)), # E7 (E bass; G# B D) (110.00, (261.63, 329.63, 392.00)), # Am7 ) def _pad(freqs: tuple[float, ...], duration: float) -> np.ndarray: t = _t(duration) vibrato = 1.0 + 0.004 * np.sin(2 * np.pi * 5.0 * t) tone = np.zeros(len(t)) for f in freqs: tone += np.sin(2 * np.pi * f * t * vibrato) + 0.4 * np.sin(2 * np.pi * 2 * f * t) env = np.ones(len(t)) a, r = int(SAMPLE_RATE * 0.25), int(SAMPLE_RATE * 0.6) env[:a] = np.linspace(0, 1, a) env[-r:] = np.linspace(1, 0.0, r) return tone / len(freqs) * env * 0.16 def generate_music(out_dir: Path | None = None, chord_seconds: float = 4.0) -> Path: out = out_dir or MUSIC_DIR duration = chord_seconds * len(_CHORDS) n = int(SAMPLE_RATE * duration) mix = np.zeros(n) rng = np.random.default_rng(1989) for i, (bass, voicing) in enumerate(_CHORDS): start = int(i * chord_seconds * SAMPLE_RATE) chord = _pad(voicing, chord_seconds) bass_line = _tone(bass, chord_seconds, kind="sine", gain=0.18, attack=0.05, release=chord_seconds * 0.4) seg = chord[: len(bass_line)] + bass_line[: len(chord)] end = min(n, start + len(seg)) mix[start:end] += seg[: end - start] # Brushed percussion on a slow ~80 BPM pulse. beat = 60.0 / 80.0 pos = 0.0 while pos < duration - 0.1: tick = _noise(0.05, gain=0.04) s = int(pos * SAMPLE_RATE) e = min(n, s + len(tick)) mix[s:e] += tick[: e - s] pos += beat # Faint vinyl crackle. crackle = rng.uniform(-1, 1, n) crackle[np.abs(crackle) < 0.985] = 0.0 mix += crackle * 0.05 # Seamless loop fade. fade = int(SAMPLE_RATE * 0.8) mix[:fade] *= np.linspace(0, 1, fade) mix[-fade:] *= np.linspace(1, 0, fade) mix *= 0.85 / (np.max(np.abs(mix)) or 1.0) path = out / "ambient_theme.wav" _write_wav(path, mix) return path def generate_placeholder_pack() -> list[Path]: return [*generate_sfx(), generate_music()]