case0 / src /case_zero /audio /synth.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
raw
history blame
5.69 kB
"""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("<i2")
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(SAMPLE_RATE)
wav.writeframes(pcm.tobytes())
def _t(duration: float) -> 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()]