#!/usr/bin/env python3 """Generate the lab's audio fixtures. The Asterisk container ships with no sound files, and the design calls for *deterministic* audio: real hold music varies per call, so a classifier regression on the PSTN is indistinguishable from noise. These are synthesised from a fixed seed, so every run classifies identical input. Output is 8 kHz 16-bit mono signed-linear (.sln), which Asterisk plays without transcoding — the format is implied by the extension, so `Playback(lab-music)` finds `lab-music.sln`. python generate.py [outdir] """ import sys from pathlib import Path import numpy as np RATE = 8000 # Asterisk's native rate for ulaw/alaw telephony def _write_sln(path: Path, samples: np.ndarray) -> None: """Write float samples in [-1, 1] as 16-bit signed little-endian PCM.""" clipped = np.clip(samples, -1.0, 1.0) pcm = (clipped * 32767).astype(" np.ndarray: """Sustained multi-harmonic tones — what the classifier must call MUSIC. A chord progression with stable pitch and strong harmonic structure. The steady spectrum across a long window is what distinguishes music from speech; this deliberately has no pauses. """ rng = np.random.default_rng(seed) t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False) # A-minor-ish progression, one chord per 2s bar. chords = [(220.0, 261.6, 329.6), (196.0, 246.9, 293.7), (174.6, 220.0, 261.6), (196.0, 246.9, 329.6)] out = np.zeros_like(t) bar = 2.0 for i, chord in enumerate(chords * int(np.ceil(seconds / (bar * len(chords))))): start, end = i * bar, (i + 1) * bar if start >= seconds: break mask = (t >= start) & (t < end) for j, freq in enumerate(chord): # Fundamental plus four harmonics, decaying — a plucked-string # feel. Enough harmonics to keep spectral flatness inside the # music score's 0.05-0.4 band: with only three, some windows fall # *below* 0.05 (too pure to read as music) and score as speech. for h, amp in ((1, 0.30), (2, 0.12), (3, 0.05), (4, 0.03), (5, 0.02)): out[mask] += amp / (j + 1) * np.sin(2 * np.pi * freq * h * t[mask]) # Gentle per-bar envelope so bars are distinguishable but never silent. env = 0.8 + 0.2 * np.sin(2 * np.pi * (t[mask] - start) / bar) out[mask] *= env # Recording-style noise floor. Windows straddling a chord change have a # momentarily sparse spectrum and land just *under* the music score's # 0.05 flatness floor, scoring as speech. This is well below the level # that would disturb tonality — every real recording has one. out += rng.normal(0, 0.004, len(out)) return out * 0.45 def make_speech(seconds: float = 8.0, seed: int = 1337) -> np.ndarray: """Formant-like bursts with pauses — what the classifier must call SPEECH. Not real speech, but it carries the features the classifier keys on: a fundamental in the human range, shifting formants, and syllable-rate amplitude modulation with genuine silence between utterances. """ rng = np.random.default_rng(seed) t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False) out = np.zeros_like(t) pos = 0.3 # leading pause while pos < seconds - 0.4: syl = rng.uniform(0.12, 0.28) # syllable length mask = (t >= pos) & (t < pos + syl) if mask.any(): local = t[mask] - pos frac = local / syl # Pitch CONTOUR, not a constant. This is the single feature that # separates this fixture from music. `_detect_tonality` looks for # an autocorrelation peak > 0.5 in the 50-1000 Hz lag range; a # fixed f0 is perfectly periodic there, scores is_tonal=True, and # hands the music score a free 0.3 that speech cannot outrun. # Real voices glide and jitter, so the periodicity never locks. f0_start = rng.uniform(95, 165) f0_end = f0_start * rng.uniform(0.72, 1.38) # rise or fall f0 = f0_start + (f0_end - f0_start) * frac # Cycle-to-cycle jitter on top of the glide (~2% is human). f0 *= 1.0 + 0.02 * rng.standard_normal(len(local)) # Integrate frequency to phase — with a varying f0, `2*pi*f*t` # would be wrong (that is a chirp only if f is the *instantaneous* # rate, which it is not once f0 itself moves). ph0 = 2 * np.pi * np.cumsum(f0) / RATE # Two formants, swept across the syllable. The ranges deliberately # avoid the DTMF bands (rows 697-941, columns 1209-1633): a formant # pair landing on both trips the Goertzel detector and the whole # utterance is classified as a keypress. f1 = rng.uniform(300, 620) + rng.uniform(-40, 40) * frac f2 = rng.uniform(1750, 2600) + rng.uniform(-120, 120) * frac sig = (0.50 * np.sin(ph0) + 0.30 * np.sin(2 * np.pi * f1 * local) + 0.18 * np.sin(2 * np.pi * f2 * local)) # Aspiration noise — HIGH-PASSED, not broadband. Real speech noise # sits above the formants; flat noise puts energy in every # Goertzel bin, so the strongest DTMF row and column both clear # the detector's `total_power * 0.1` threshold and every syllable # reads as a keypress. A first-difference filter (y[n]-y[n-1]) is # a cheap +6dB/octave tilt that leaves the 697-1633 Hz DTMF bands # comparatively empty. The 0.09 level is chosen for margin: it puts # spectral flatness at ~0.46, mid-way through the 0.1-0.5 band the # speech score rewards, rather than on either edge. noise = rng.standard_normal(len(local) + 1) sig += 0.09 * np.diff(noise) # Raised-cosine envelope: no clicks at syllable edges. sig *= np.sin(np.pi * local / syl) ** 0.6 out[mask] += sig # Inter-syllable gap; occasionally a longer between-word pause. pos += syl + (rng.uniform(0.25, 0.5) if rng.random() < 0.25 else rng.uniform(0.04, 0.12)) return out * 0.55 def make_silence(seconds: float = 5.0) -> np.ndarray: """Near-silence with a trace of noise — real lines are never digitally flat.""" rng = np.random.default_rng(4242) return rng.normal(0, 0.0006, int(RATE * seconds)) def main() -> None: outdir = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent) outdir.mkdir(parents=True, exist_ok=True) print(f"Generating lab audio into {outdir}/") _write_sln(outdir / "lab-music.sln", make_music()) _write_sln(outdir / "lab-speech.sln", make_speech()) _write_sln(outdir / "lab-silence.sln", make_silence()) print("Done. Deterministic: same bytes on every run.") if __name__ == "__main__": main()