test(lab): add Asterisk lab — a fake PSTN for media validation
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m12s

An Asterisk instance that answers calls, plays an IVR, holds with music and
connects a "human", so the gateway has something real to dial that is not the
PSTN: no charges, no strangers, no E911 exposure.

Asterisk rather than Kamailio because the unproven risks are media risks.
Kamailio is a proxy — it routes signalling and answers nothing, so it would
forward the INVITE and find nobody home. Asterisk is a B2BUA: it answers,
plays prompts and collects DTMF, which is the hold-slayer scenario itself.
Kamailio remains the better model for trunk registration/digest auth later.

No application changes are needed to use it. SIP_TRUNK_HOST is just an
address, so the production code path runs unmodified — there is no test-only
branch anywhere in the gateway. It also means safety is structural: while the
trunk points at the lab there is no route to the PSTN at all, an absence of
route rather than a policy that could be misconfigured.

Nine scenarios (1001-1008 plus an echo test) cover the baseline call, the
IVR/DTMF path, hold-then-human, long hold, busy, no-answer, remote hangup and
silence.

The image ships no sound files, so sounds/generate.py synthesises three
fixtures from fixed seeds — byte-identical on every run, which is what makes
a classifier regression distinguishable from noise. Verified against
AudioClassifier: music→MUSIC 0.85, speech→LIVE_HUMAN 0.75,
silence→SILENCE 1.00. The speech formants deliberately avoid the DTMF bands;
the first version landed on a valid pair and classified as a keypress.

Anonymous inbound calls are refused, and endpoint matching is by source
address — Asterisk's default matches the From-header domain, which Hold
Slayer populates from its SIP bind address (0.0.0.0 on a wildcard bind).

Generated audio and the rendered per-host configs are gitignored: the former
is reproducible from a fixed seed, the latter carry a host-specific IP and
the lab password.

Known limit, documented in the README: MediaPipeline.create_tap is a stub, so
the classifier receives no audio on a live call. RTP flows and Asterisk plays
audio, but the tap is never fed — the fixture results above were measured by
feeding the classifier directly. This blocks scenarios 1002/1003/1004.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 06:06:14 -04:00
parent 204203e3b0
commit c00cf02676
11 changed files with 848 additions and 0 deletions

147
tests/lab/README.md Normal file
View File

@@ -0,0 +1,147 @@
# Asterisk lab — a fake PSTN
An Asterisk instance that answers calls, plays an IVR, holds you with music,
and eventually connects a "human". It gives the gateway something real to dial
that is **not** the PSTN: no charges, no strangers, no E911 exposure, and a
deterministic script that makes classifier regressions reproducible.
Design rationale and the Virgo deployment plan:
[docs/asterisk-lab-design.md](../../docs/asterisk-lab-design.md).
> This lab found five bugs in `SippyEngine` on its first call — the engine had
> never successfully placed one. Everything below runs against the real
> `SippyEngine`, never `MockSIPEngine`, which is the entire point.
---
## Run it
```bash
cd tests/lab
# 1. Generate the audio fixtures (the image ships with NO sound files).
python sounds/generate.py
# 2. Render the local configs. They carry a host-specific IP and the lab
# password, so they are gitignored — regenerate them per machine.
cd dialplan
LOCALIP=$(ip route get 1.1.1.1 | grep -oP '(?<=src\s)\d+(\.\d+){3}')
sed -e "s/{{ asterisk_sip_port }}/21061/" \
-e "s/{{ asterisk_external_ip }}/$LOCALIP/" \
-e "s#{{ asterisk_local_net }}#10.10.0.0/24#" \
-e "s/{{ asterisk_match_host }}/127.0.0.1/" \
-e "s/{{ asterisk_sip_username }}/holdslayer/" \
-e "s/{{ asterisk_sip_password }}/labpassword/" \
pjsip.conf > pjsip.local.conf
sed -e "s/{{ asterisk_rtp_start }}/21100/" \
-e "s/{{ asterisk_rtp_end }}/21149/" \
rtp.conf > rtp.local.conf
cd ..
# 3. Start it.
docker compose -f docker-compose.lab.yml up -d
```
Point Hold Slayer at it — no code changes, no test-only branch. `make_call`
builds `sip:{number}@{trunk_host}:{trunk_port}`, so the lab is just an address:
```bash
USE_MOCK_SIP=false
SIP_TRUNK_HOST=127.0.0.1
SIP_TRUNK_PORT=21061
SIP_TRUNK_USERNAME=holdslayer
SIP_TRUNK_PASSWORD=labpassword
SIP_TRUNK_DID=+15550000000
GATEWAY_SIP_PORT=21062 # must differ from the Asterisk port
```
> **The repo's own `.env` sets `USE_MOCK_SIP=true`** and a placeholder trunk
> host, and pydantic-settings lets `.env` win over the process environment. If
> the engine reports `MockSIPEngine` despite the above, that is why.
>
> `AIPSTNGateway(settings=...)` also defaults to `MockSIPEngine` unless an
> engine is assigned — `main.py`'s lifespan calls `build_sip_engine()` after
> construction. A harness that skips that step silently tests the mock.
## Useful commands
```bash
docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv # CLI
docker compose -f docker-compose.lab.yml logs -f asterisk # logs
docker compose -f docker-compose.lab.yml exec asterisk \
asterisk -rx "pjsip set logger on" # SIP trace
```
---
## Scenarios
Hold Slayer dials these as `number`.
| Ext | Scenario | Proves |
|---|---|---|
| `1001` | Answers, speech, hangs up | Baseline: INVITE→200→ACK→RTP→BYE, audio both ways |
| `1002` | IVR menu, branches on DTMF | `send_dtmf` really emits RFC 2833 and Asterisk receives it |
| `1003` | Hold music, then a human | The hold-slayer loop: music → wait → human → ring owner |
| `1004` | Long hold (~10 min) | `MAX_HOLD_TIME`, `HOLD_CHECK_INTERVAL` |
| `1005` | Busy | Failure path: call marked `FAILED`, no stuck leg |
| `1006` | Rings, never answers | Timeout path |
| `1007` | Answers, hangs up after 5s | Remote BYE, DB persistence on hangup |
| `1008` | Answers, then silence | Classifier `SILENCE` vs. no-audio |
| `1099` | Echo test | Debugging aid — confirm bidirectional RTP by ear |
## Audio fixtures
The Asterisk image ships **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. `sounds/generate.py`
synthesises three fixtures from fixed seeds — byte-identical every run.
Verified against `AudioClassifier` (16 kHz):
| Fixture | Classifies as | Confidence |
|---|---|---|
| `lab-music.sln` | `MUSIC` | 0.85 |
| `lab-speech.sln` | `LIVE_HUMAN` | 0.75 |
| `lab-silence.sln` | `SILENCE` | 1.00 |
Format is 8 kHz 16-bit mono signed-linear (`.sln`) — Asterisk's native
telephony rate, played without transcoding.
> The speech fixture's formants deliberately avoid the DTMF bands (rows
> 697941 Hz, columns 12091633 Hz). The first version landed on a valid
> DTMF pair and the whole utterance classified as a keypress.
---
## Known limits
- **The classifier receives nothing on a live call.**
`MediaPipeline.create_tap` is a stub — it logs `🎤 Audio tap created` and
returns a tap that is never fed (`core/media_pipeline.py`, and the same at
stream creation). RTP flows and Asterisk plays audio, but nothing reaches
the classifier. The table above was measured by feeding the fixtures
directly. **This blocks the hold-slayer scenarios (1002/1003/1004).**
- **Not real PSTN audio** — no transcoding artefacts, packet loss, jitter, or
carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
- **Not real IVR behaviour** — this dialplan is what we imagine a bank sounds
like. Real trees are longer, noisier, and interrupt.
- **Not trunk registration against a real ITSP** — `_register_trunk()` works
against Asterisk, but carrier quirks are their own phase.
## Security
`pjsip.conf` refuses anonymous inbound calls: every call must authenticate as
the `hold-slayer` endpoint. Asterisk's stock examples allow anonymous calls and
are a well-known toll-fraud target — there is no PSTN behind this instance, so
an unauthorised call reaches only the dialplan, but the lock-down keeps this
config safe to copy.
Endpoint matching is by **source address** (`type=identify`). Asterisk's
default matches the From-header domain, which Hold Slayer populates from its
SIP bind address — `0.0.0.0` on a wildcard bind, which never matches.
> **Separate, pre-existing:** Hold Slayer's own SIP listener answers `200 OK`
> to any REGISTER with no digest challenge. Fine on loopback; it must be
> resolved before the gateway binds a LAN interface, or any host on the
> network can register as a device and receive transferred calls.

3
tests/lab/dialplan/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Rendered from the .conf templates by the local-lab instructions in
# README.md; contains a host-specific IP and the lab password.
*.local.conf

View File

@@ -0,0 +1,24 @@
; Minimal Asterisk core config for the lab.
[directories](!)
astetcdir => /etc/asterisk
astmoddir => /usr/lib/asterisk/modules
astvarlibdir => /var/lib/asterisk
astdbdir => /var/lib/asterisk
astkeydir => /var/lib/asterisk
astdatadir => /var/lib/asterisk
astagidir => /var/lib/asterisk/agi-bin
astspooldir => /var/spool/asterisk
astrundir => /var/run/asterisk
astlogdir => /var/log/asterisk
astsbindir => /usr/sbin
[options]
; Log to stdout so Docker's json-file driver captures it and Alloy ships it.
; A file-based log inside the container would be invisible to Loki.
verbose = 3
debug = 0
nocolor = yes
dumpcore = no
; Never run as root inside the container.
runuser = asterisk
rungroup = asterisk

View File

@@ -0,0 +1,144 @@
; ---------------------------------------------------------------------------
; Hold Slayer lab dialplan — a fake bank phone tree
; ---------------------------------------------------------------------------
; Each extension is one scenario Hold Slayer must handle. Everything here is
; deterministic on purpose: real hold music varies per call, so a classifier
; regression on the PSTN is indistinguishable from noise. Against a fixed
; prompt the answer is binary.
;
; Hold Slayer dials these as `number` with SIP_TRUNK_HOST pointing here.
; ---------------------------------------------------------------------------
[globals]
; Lab-generated audio (tests/lab/sounds/, built by generate.py). The Asterisk
; image ships with no sounds at all, and these are synthesised from a fixed
; seed so the classifier sees byte-identical input on every run.
; lab-speech -> must classify LIVE_HUMAN
; lab-music -> must classify MUSIC
; lab-silence -> must classify SILENCE
GREETING=lab-speech
INVALID=lab-speech
[hold-slayer-lab]
; --- 1001: immediate answer, speech, hangup -------------------------------
; Baseline. Proves INVITE→200→ACK→RTP→BYE and that audio flows both ways.
; The classifier should report LIVE_HUMAN throughout.
exten => 1001,1,NoOp(LAB 1001: immediate answer)
same => n,Answer()
same => n,Wait(1)
same => n,Playback(${GREETING})
same => n,Playback(lab-speech)
same => n,Wait(20)
same => n,Playback(lab-speech)
same => n,Hangup()
; --- 1002: IVR menu, branches on DTMF -------------------------------------
; THE important one. Proves send_dtmf genuinely emits RFC 2833 and that
; Asterisk receives the digits — currently a no-op in MockSIPEngine.
; Press 1 → accounts (answers as human). Press 2 → cards (hold, then human).
exten => 1002,1,NoOp(LAB 1002: IVR menu)
same => n,Answer()
same => n,Wait(1)
same => n,Set(TRIES=0)
same => n(menu),Background(lab-speech)
same => n,WaitExten(8)
same => n,Set(TRIES=$[${TRIES} + 1])
same => n,GotoIf($[${TRIES} < 3]?menu)
same => n,Playback(lab-speech)
same => n,Hangup()
; Option 1 — straight to a "human"
exten => 1,1,NoOp(LAB 1002: caller pressed 1 -> accounts)
same => n,Playback(lab-speech)
same => n,Playback(lab-speech)
same => n,Wait(15)
same => n,Hangup()
; Option 2 — hold queue, then a "human"
exten => 2,1,NoOp(LAB 1002: caller pressed 2 -> cards, hold)
same => n,Playback(lab-speech)
same => n,Playback(lab-music)
same => n,Playback(lab-speech)
same => n,Wait(15)
same => n,Hangup()
exten => i,1,NoOp(LAB 1002: invalid entry)
same => n,Playback(${INVALID})
same => n,Goto(1002,menu)
exten => t,1,NoOp(LAB 1002: entry timeout)
same => n,Goto(1002,menu)
; --- 1003: hold music, then a human ---------------------------------------
; The whole hold-slayer loop in one call: classify music → stay on hold →
; detect the human → ring the owner. 60s of MoH is long enough for several
; classifier windows (CLASSIFIER_WINDOW_SECONDS defaults to 3.0).
exten => 1003,1,NoOp(LAB 1003: hold then human)
same => n,Answer()
same => n,Wait(1)
same => n,Playback(lab-speech)
same => n,Playback(lab-music)
same => n,Playback(lab-music)
same => n,Playback(lab-speech)
same => n,Playback(lab-speech)
same => n,Wait(30)
same => n,Hangup()
; --- 1004: long hold ------------------------------------------------------
; Exercises MAX_HOLD_TIME and HOLD_CHECK_INTERVAL. 10 minutes.
exten => 1004,1,NoOp(LAB 1004: long hold)
same => n,Answer()
same => n,Wait(1)
same => n,Playback(lab-speech)
same => n,Playback(lab-music)
same => n,Playback(lab-music)
same => n,Playback(lab-music)
same => n,Playback(lab-music)
same => n,Playback(lab-speech)
same => n,Wait(15)
same => n,Hangup()
; --- 1005: busy -----------------------------------------------------------
; Failure path: the call must be marked FAILED with no stuck leg.
exten => 1005,1,NoOp(LAB 1005: busy)
same => n,Busy(20)
same => n,Hangup()
; --- 1006: ring, never answer ---------------------------------------------
; Timeout path. Rings for 120s without answering.
exten => 1006,1,NoOp(LAB 1006: ring no answer)
same => n,Progress()
same => n,Wait(120)
same => n,Hangup()
; --- 1007: answer, then remote hangup after 5s ----------------------------
; Proves remote-BYE handling and that the call persists to the DB on hangup.
exten => 1007,1,NoOp(LAB 1007: quick remote hangup)
same => n,Answer()
same => n,Playback(${GREETING})
same => n,Wait(5)
same => n,Hangup()
; --- 1008: answer, then silence -------------------------------------------
; Classifier SILENCE vs the no-audio case. 45s of nothing.
exten => 1008,1,NoOp(LAB 1008: silence)
same => n,Answer()
same => n,Playback(lab-silence)
same => n,Wait(40)
same => n,Hangup()
; --- echo test ------------------------------------------------------------
; Not a scenario — a debugging aid. Echoes audio back so you can confirm
; bidirectional RTP by ear when something looks wrong.
exten => 1099,1,NoOp(LAB 1099: echo test)
same => n,Answer()
same => n,Playback(lab-speech)
same => n,Echo()
same => n,Hangup()
; Anything else: reject explicitly rather than failing obscurely.
exten => _X.,1,NoOp(LAB: unknown extension ${EXTEN})
same => n,Answer()
same => n,Playback(${INVALID})
same => n,Hangup()

View File

@@ -0,0 +1,8 @@
; Log to stdout only — Docker's json-file driver captures it and the host
; Alloy ships it to Loki as job=<compose project>. Writing to a file inside
; the container would put the logs where nothing can see them.
[general]
dateformat = %F %T
[logfiles]
console => notice,warning,error

View File

@@ -0,0 +1,75 @@
; ---------------------------------------------------------------------------
; Hold Slayer lab — PJSIP configuration
; ---------------------------------------------------------------------------
; SECURITY: this endpoint answers calls. Asterisk's stock examples allow
; anonymous inbound, which is a well-known toll-fraud target. This config
; refuses it: every call must authenticate as the `hold-slayer` endpoint.
;
; There is no PSTN behind this Asterisk — an unauthorised call reaches only
; the lab dialplan and costs nothing. The lock-down is defence in depth and
; so this config is never copied somewhere it would matter.
; ---------------------------------------------------------------------------
[global]
type = global
; Do not fall through to an `anonymous` endpoint for unmatched calls.
; This is the single most important line in the file.
unidentified_request_count = 5
unidentified_request_period = 5
unidentified_request_prune_interval = 30
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:{{ asterisk_sip_port }}
; The address Asterisk advertises in SDP. Without this, containers advertise
; their internal bridge IP and RTP arrives at an unroutable address — the
; classic "call connects but there is no audio" failure.
external_media_address = {{ asterisk_external_ip }}
external_signaling_address = {{ asterisk_external_ip }}
local_net = {{ asterisk_local_net }}
; ---------------------------------------------------------------------------
; Hold Slayer endpoint
; ---------------------------------------------------------------------------
; Hold Slayer authenticates as this endpoint to place calls into the lab.
; Identify the endpoint by source address. Asterisk's default matching uses
; the From-header domain, which Hold Slayer populates from its SIP bind
; address (0.0.0.0 on a wildcard bind) — never a value Asterisk can match.
; Matching on where the packet actually came from sidesteps that.
[hold-slayer]
type = identify
endpoint = hold-slayer
match = {{ asterisk_match_host }}
[hold-slayer]
type = endpoint
context = hold-slayer-lab
disallow = all
; ulaw first: it is what the PSTN uses, so the lab exercises the same codec
; path a real trunk would. alaw as fallback.
allow = ulaw
allow = alaw
auth = hold-slayer-auth
aors = hold-slayer
; RFC 2833 out-of-band DTMF — what send_dtmf must produce. Setting this
; explicitly (rather than `auto`) means a DTMF failure is a real failure and
; not a negotiation fallback quietly rescuing it.
dtmf_mode = rfc4733
direct_media = no
force_rport = yes
rewrite_contact = yes
rtp_symmetric = yes
[hold-slayer-auth]
type = auth
auth_type = userpass
username = {{ asterisk_sip_username }}
password = {{ asterisk_sip_password }}
[hold-slayer]
type = aor
max_contacts = 2
remove_existing = yes
qualify_frequency = 60

View File

@@ -0,0 +1,9 @@
; RTP media port range for the lab.
;
; 50 ports ≈ 25 concurrent calls — comfortably above Hold Slayer's
; max_concurrent_calls (default 4). The range must match the ports published
; in docker-compose, or media arrives at a port Docker isn't forwarding and
; the call connects with no audio.
[general]
rtpstart = {{ asterisk_rtp_start }}
rtpend = {{ asterisk_rtp_end }}

View File

@@ -0,0 +1,28 @@
# Local Asterisk lab — for iterating on caliban before promoting to Virgo.
#
# This is the LOCAL variant: ports and credentials are concrete, not Jinja.
# Ansible templates the same dialplan out to galatea with the estate's
# variables (see virgo/ansible/asterisk/).
#
# Run: docker compose -f docker-compose.lab.yml up -d
# CLI: docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv
#
# host networking: SIP/RTP carry IP addresses *inside* the payload, so a
# bridged network needs external_media_address set correctly or the call
# connects with no audio. Host networking sidesteps that entirely for local
# work. The Virgo deploy uses the same approach for the same reason.
services:
asterisk:
image: andrius/asterisk:22.10.1_debian-trixie
container_name: asterisk-lab
network_mode: host
volumes:
- ./dialplan/extensions.conf:/etc/asterisk/extensions.conf:ro
- ./dialplan/pjsip.local.conf:/etc/asterisk/pjsip.conf:ro
- ./dialplan/rtp.local.conf:/etc/asterisk/rtp.conf:ro
- ./dialplan/logger.conf:/etc/asterisk/logger.conf:ro
# The image ships no sound files at all. These are generated by
# sounds/generate.py; Asterisk resolves Playback(lab-music) to
# lab-music.sln here (8kHz signed-linear, no transcoding).
- ./sounds:/var/lib/asterisk/sounds/en:ro
restart: unless-stopped

4
tests/lab/sounds/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# Generated by generate.py — deterministic from fixed seeds, so the bytes are
# reproducible and there is no reason to carry ~680K of binary in the repo.
# Run `python generate.py` before starting the lab.
*.sln

View File

@@ -0,0 +1,113 @@
#!/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 struct
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("<i2")
path.write_bytes(pcm.tobytes())
print(f" {path.name}: {len(pcm) / RATE:.1f}s ({path.stat().st_size} bytes)")
def make_music(seconds: float = 30.0) -> 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.
"""
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 two harmonics, decaying — a plucked-string feel.
for h, amp in ((1, 0.30), (2, 0.12), (3, 0.05)):
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
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
f0 = rng.uniform(95, 165) # fundamental — adult speaking range
# Two formants, swept slightly 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) * local / syl
f2 = rng.uniform(1750, 2600) + rng.uniform(-120, 120) * local / syl
sig = (0.50 * np.sin(2 * np.pi * f0 * local)
+ 0.30 * np.sin(2 * np.pi * f1 * local)
+ 0.18 * np.sin(2 * np.pi * f2 * local))
# 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()