Compare commits

...

2 Commits

Author SHA1 Message Date
c00cf02676 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>
2026-07-29 06:06:14 -04:00
204203e3b0 fix(sip): correct five Sippy API mismatches that broke every real call
SippyEngine had never successfully placed a call or registered a trunk.
Every failure was masked by broad exception handlers that logged and marked
the leg terminated, so the gateway reported "ringing" and then ended the
call rather than surfacing the fault. None of it was visible to the test
suite, which runs exclusively on MockSIPEngine.

Found by pointing the gateway at a local Asterisk instance (tests/lab) —
each fix uncovered the next.

1. Trunk registration used kwargs the installed sippy (2.3.0) does not
   accept (auth_name/auth_password → user/passw), called register()
   instead of doregister(), and passed aor/contact as strings where
   SipRegistrationAgent calls .getCopy() and mutates .username/.port,
   so SipURL objects are required.

   It also posted registered=True at *send* time. Registration is
   asynchronous, so a rejected REGISTER would still have reported success
   — and /health treats a registered trunk as a condition for "healthy".
   Now wired to sippy's rok_cb/rfail_cb, so the rejection status line
   (typically a bad trunk password) reaches the operator.

   The Contact also fell back to loopback when the SIP bind is 0.0.0.0;
   a wildcard address is not somewhere a trunk can send an INVITE.

2. The INVITE passed SDP as a `body` kwarg. CCEventTry takes no such
   argument: UacStateIdle unpacks exactly six fields from the data tuple
   and expects the SDP as a MsgBody in position four. callingID/calledID
   are bare usernames — sippy builds the URIs itself from nh_address.

3. _sip_logger was absent from the global config. SipTransactionManager
   dereferences it on every message, so the first SIP packet in either
   direction raised KeyError inside the ED thread.

4. SippyCallController was not callable. Sippy invokes event_cb(event, ua)
   with CCEvent objects; the class only exposed on_* methods that nothing
   called. Added __call__ to dispatch CCEventRing/Connect/Disconnect/Fail
   to the existing handlers, guarding the body because an exception
   escaping into the ED dispatcher would hang the leg silently.

5. The UA was constructed without credentials, so sippy could not answer
   the 401/407 challenge that any authenticating trunk sends. Every
   outbound call died on the challenge.

Verified end to end against Asterisk 22.10.1: 180 Ringing → Connected →
23s of audio → clean teardown, with the dialplan executing and audio
playing in real time.

Not fixed here, and still blocking media: MediaPipeline.create_tap is a
stub that logs success and returns a tap nothing ever feeds, so the
classifier receives no audio on a live call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 06:04:01 -04:00
12 changed files with 961 additions and 16 deletions

View File

@@ -84,6 +84,48 @@ class SippyCallController:
self.leg_id = leg_id self.leg_id = leg_id
self.engine = engine self.engine = engine
def __call__(self, event, ua) -> None:
"""Sippy's ``event_cb`` — invoked as ``event_cb(event, ua)``.
Sippy delivers call progress as CCEvent objects through this one
entry point; it never calls the ``on_*`` methods directly. This
dispatches to them so each SIP fact still has a named handler.
"""
from sippy.CCEvents import (
CCEventConnect,
CCEventDisconnect,
CCEventFail,
CCEventPreConnect,
CCEventRing,
)
try:
if isinstance(event, CCEventRing):
self.on_ringing()
elif isinstance(event, (CCEventConnect, CCEventPreConnect)):
# data is (code, reason, body) — the body carries the
# negotiated SDP that tells the media pipeline where to
# send RTP.
data = event.getData()
body = data[2] if isinstance(data, tuple) and len(data) > 2 else None
self.on_connected(str(body) if body is not None else None)
elif isinstance(event, CCEventDisconnect):
self.on_disconnected("remote hangup")
elif isinstance(event, CCEventFail):
data = event.getData()
reason = " ".join(str(d) for d in data[:2]) if data else "call failed"
self.on_disconnected(reason)
# DTMF is not handled here: SIP INFO arrives as a request and is
# picked up by _handle_incoming_info, and RFC 2833 DTMF rides in
# the RTP stream, which is the media pipeline's business.
except Exception as e:
# This runs on the Sippy ED thread: an escaping exception is
# swallowed by the dispatcher and the leg would hang silently.
logger.error(
f" {self.leg_id}: error handling {type(event).__name__}: {e}",
exc_info=True,
)
def on_trying(self): def on_trying(self):
"""100 Trying received.""" """100 Trying received."""
logger.debug(f" {self.leg_id}: 100 Trying") logger.debug(f" {self.leg_id}: 100 Trying")
@@ -319,10 +361,17 @@ class SippyEngine(SIPEngine):
SipConf.my_port = self._sip_port SipConf.my_port = self._sip_port
SipConf.my_uaname = "Hold Slayer Gateway" SipConf.my_uaname = "Hold Slayer Gateway"
# SipTransactionManager dereferences _sip_logger unconditionally on
# every message, so it must exist before any SIP traffic. It
# defaults to the stderr backend (SIPLOG_BEND), not the
# /var/log/sip.log path in its signature — nothing to create.
from sippy.SipLogger import SipLogger
self._sippy_global_config = { self._sippy_global_config = {
"_sip_address": self._sip_address, "_sip_address": self._sip_address,
"_sip_port": self._sip_port, "_sip_port": self._sip_port,
"_sip_tm": None, # Transaction manager set after start "_sip_tm": None, # Transaction manager set after start
"_sip_logger": SipLogger("hold-slayer"),
} }
# Start Sippy's SIP transaction manager in a background thread # Start Sippy's SIP transaction manager in a background thread
@@ -499,23 +548,53 @@ class SippyEngine(SIPEngine):
def do_register(): def do_register():
try: try:
from sippy.SipRegistrationAgent import SipRegistrationAgent from sippy.SipRegistrationAgent import SipRegistrationAgent
from sippy.SipURL import SipURL
def on_registered(_rtime, _contact, _cb_arg):
logger.info(" ✅ Trunk registration accepted")
self._post_from_ed("trunk_registered", {"registered": True})
def on_register_failed(status_line, _cb_arg):
# status_line is the response's status line (e.g. "403
# Forbidden") — surface it; a bad trunk password is the
# most common cause and is otherwise invisible.
logger.error(f" ❌ Trunk registration rejected: {status_line}")
self._post_from_ed(
"trunk_registered",
{"registered": False, "reason": str(status_line)},
)
# A wildcard bind is not a routable Contact — the trunk would
# have nowhere to send the inbound INVITE. Fall back to
# loopback, matching _generate_sdp's handling.
contact_host = (
self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
)
# aor/contact must be SipURL objects: the agent calls
# .getCopy() and mutates .username/.port on them.
reg_agent = SipRegistrationAgent( reg_agent = SipRegistrationAgent(
self._sippy_global_config, self._sippy_global_config,
f"sip:{self._trunk_username}@{self._trunk_host}", SipURL(f"sip:{self._trunk_username}@{self._trunk_host}"),
f"sip:{self._trunk_host}:{self._trunk_port}", SipURL(f"sip:{self._trunk_username}@{contact_host}:{self._sip_port}"),
auth_name=self._trunk_username, user=self._trunk_username,
auth_password=self._trunk_password, passw=self._trunk_password,
rok_cb=on_registered,
rfail_cb=on_register_failed,
) )
reg_agent.register() # Registration is asynchronous: success is reported by the
logger.info(" ✅ Trunk registration sent") # callbacks above, not here. Reporting "registered" at send
self._post_from_ed("trunk_registered", {"registered": True}) # time would let /health go green on a rejected REGISTER.
reg_agent.doregister()
logger.info(" Trunk REGISTER sent, awaiting response")
except ImportError: except ImportError:
logger.warning(" Sippy registration agent not available") logger.warning(" Sippy registration agent not available")
self._post_from_ed("trunk_registered", {"registered": False}) self._post_from_ed("trunk_registered", {"registered": False})
except Exception as e: except Exception as e:
logger.error(f" ❌ Trunk registration failed: {e}") logger.error(f" ❌ Trunk registration failed: {e}", exc_info=True)
self._post_from_ed("trunk_registered", {"registered": False}) self._post_from_ed(
"trunk_registered", {"registered": False, "reason": str(e)}
)
self._run_on_sippy(do_register) self._run_on_sippy(do_register)
@@ -570,7 +649,8 @@ class SippyEngine(SIPEngine):
else: else:
remote_uri = f"sip:{number}@{self._domain}" remote_uri = f"sip:{number}@{self._domain}"
from_uri = f"sip:{caller_id or self._did}@{self._domain}" caller_number = caller_id or self._did
from_uri = f"sip:{caller_number}@{self._domain}"
leg = SipCallLeg(leg_id, "outbound", remote_uri) leg = SipCallLeg(leg_id, "outbound", remote_uri)
self._legs[leg_id] = leg self._legs[leg_id] = leg
@@ -583,24 +663,38 @@ class SippyEngine(SIPEngine):
def do_invite(): def do_invite():
try: try:
from sippy.CCEvents import CCEventTry from sippy.CCEvents import CCEventTry
from sippy.MsgBody import MsgBody
from sippy.SipCallId import SipCallId from sippy.SipCallId import SipCallId
from sippy.UA import UA from sippy.UA import UA
controller = SippyCallController(leg_id, self) controller = SippyCallController(leg_id, self)
# Create Sippy UA for this call # Create Sippy UA for this call. The credentials are required:
# a trunk answers the first INVITE with 401/407, and sippy
# only retries with a digest response when they are set —
# without them every outbound call dies on the challenge.
ua = UA( ua = UA(
self._sippy_global_config, self._sippy_global_config,
event_cb=controller, event_cb=controller,
username=self._trunk_username or None,
password=self._trunk_password or None,
nh_address=(self._trunk_host, self._trunk_port), nh_address=(self._trunk_host, self._trunk_port),
) )
self._ed_leg_to_ua[leg_id] = ua self._ed_leg_to_ua[leg_id] = ua
self._ed_ua_to_leg[ua] = leg_id self._ed_ua_to_leg[ua] = leg_id
# Send INVITE # SDP travels inside the event's data tuple as a MsgBody, not
# as a kwarg. needs_update=False marks it final: with it set,
# sippy would call ua.on_local_sdp_change (unset here) before
# sending, and the INVITE would never go out.
body = MsgBody(sdp_body, mtype="application/sdp")
body.needs_update = False
# UacStateIdle unpacks exactly six fields and builds the SIP
# URIs itself from nh_address — callingID/calledID are bare
# usernames, not full URIs.
event = CCEventTry( event = CCEventTry(
(SipCallId(), from_uri, remote_uri), (SipCallId(), caller_number, number, body, None, None)
body=sdp_body,
) )
ua.recvEvent(event) ua.recvEvent(event)
@@ -613,8 +707,11 @@ class SippyEngine(SIPEngine):
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"}) self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
except Exception as e: except Exception as e:
logger.error(f" Failed to send INVITE for {leg_id}: {e}") logger.error(f" Failed to send INVITE for {leg_id}: {e}", exc_info=True)
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"}) self._post_from_ed(
"leg_state",
{"leg_id": leg_id, "state": "terminated", "error": str(e)},
)
self._run_on_sippy(do_invite) self._run_on_sippy(do_invite)
return leg_id return leg_id

293
docs/asterisk-lab-design.md Normal file
View File

@@ -0,0 +1,293 @@
# Asterisk Lab — design
A **fake PSTN** for Hold Slayer: an Asterisk instance in Virgo Dev that answers
calls, plays an IVR, holds you in a queue 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.
Status: **design, not built.** Nothing here has been deployed.
---
## Why Asterisk and not Kamailio
Kamailio is a SIP **proxy** — it routes signaling and does not answer calls or
handle media. The risks that remain unproven in Hold Slayer are mostly *media*
risks: `send_dtmf` has only ever run against `MockSIPEngine` (a no-op), the
audio classifier has never seen real RTP, and the PJSUA2 pipeline built in Phase
1b has never carried a packet. A proxy forwards the INVITE and finds nobody
home, so it exercises none of that.
Asterisk is a B2BUA: it answers, plays prompts, collects DTMF, and can hold a
call in a queue with music. That is precisely the hold-slayer scenario, so it is
the right primary target.
Kamailio still has a place — **later, and narrowly**. It models a real ITSP's
registration/digest-auth behaviour better than Asterisk does, so it is the right
tool for exercising `_register_trunk()` ([core/sippy_engine.py:495](../core/sippy_engine.py#L495))
in isolation. It is deliberately *not* in scope for this lab.
```
Phase 2/3 (this lab) Phase 4a (optional) Phase 4b
Asterisk IVR + media → Kamailio registration → real PSTN, one call
```
---
## The one thing that makes this work without code changes
`SIP_TRUNK_HOST` is just an address. `SippyEngine.make_call()` builds
`sip:{number}@{trunk_host}:{trunk_port}`
([core/sippy_engine.py:568](../core/sippy_engine.py#L568)) and registers against
whatever host it is given. Point it at Asterisk and Hold Slayer dials it exactly
as it would dial a real provider.
**There is no test-only branch, no mock, no `if lab:` anywhere.** The code path
under test is the production code path. That is the entire value of this
approach — a lab that requires special-casing the application proves less than
it costs.
It also means the safety story is structural: while `SIP_TRUNK_HOST` points at
Asterisk on the Dev LAN, there is **no route to the PSTN at all**. Not a policy
that could be misconfigured — an absence of route.
---
## Placement in Virgo
| Decision | Value | Why |
|---|---|---|
| Host | **nereid** (`10.0.1.214`) | Terraform describes it as "Experimental Apps (POC, testing new technologies)" — [terraform/incus/containers.tf](../../virgo/terraform/incus/containers.tf). An unauthenticated SIP endpoint is exactly experimental. |
| Deploy | Docker Compose via Ansible | Matches every other Virgo service. `nereid` already has `docker = true`. |
| Hostname | `asterisk.helu.ca` (internal) | LAN only. **No `*.d.helu.ca` HAProxy entry** — HAProxy is HTTP; SIP/RTP would not traverse it, and this must not be publicly reachable. |
| Database | none | Asterisk needs no DB, so the "no databases in Docker" rule is not engaged. |
Hold Slayer itself stays on **triton** (`10.0.1.213`), where it is already
deployed at port 21081. Splitting the two hosts is deliberate: SIP then crosses
a real network with real latency, jitter and MTU, rather than a loopback that
hides every transport problem.
### Ports
Project **210** is Hold Slayer's (existing: `hold_slayer_web_port: 21081`).
Verified free: only `21081` and `29181` are allocated in that space today.
| Var | Port | Purpose |
|---|---|---|
| `asterisk_sip_port` | **21061** | SIP signalling (UDP). `2-10-6-1`: project 210, service 6 (SIP), instance 1 |
| `asterisk_rtp_start` | **21100** | RTP media range start (UDP) |
| `asterisk_rtp_end` | **21149** | RTP range end — 50 ports ≈ 25 concurrent calls, well above `max_concurrent_calls: 4` |
| `asterisk_ari_port` | **21071** | ARI/HTTP management (service 7 = management) |
| `asterisk_syslog_port` | **51462** | Docker syslog, `514YZ` convention (daedalus uses 51461) |
Service digit `6` for SIP is a **new allocation** — the existing scheme
([docs/virgo.md](../../virgo/docs/virgo.md) §Port Numbering) defines 1/2/5/7/8/9
and has no telephony digit. Worth confirming before it becomes precedent.
> **Note:** 5060 is *not* used. The convention forbids random ports, and using
> the well-known SIP port invites scanner traffic. Nothing requires 5060 — both
> ends are configured.
---
## Call scenarios
Each maps to a Hold Slayer behaviour that is currently unproven. Extensions are
what Hold Slayer dials as `number`.
| Ext | Scenario | Proves |
|---|---|---|
| `1001` | **Immediate answer**, plays speech, hangs up after 30s | Baseline: INVITE→200→ACK→RTP→BYE, audio flows both ways, classifier reports `LIVE_HUMAN` |
| `1002` | **IVR menu** — "press 1 for accounts, 2 for cards", branches on DTMF | `send_dtmf` genuinely emits RFC 2833 and Asterisk receives it. This is the big one — currently a no-op in the mock |
| `1003` | **Hold music, then human** — 60s MoH, then answers | The whole hold-slayer loop: classify music → stay on hold → detect human → ring the owner |
| `1004` | **Long hold** — 10 min MoH | `MAX_HOLD_TIME` and the hold-check interval |
| `1005` | **Immediate busy** (`BUSY()`) | Failure path: call marked `FAILED`, no stuck leg |
| `1006` | **Ring, never answer** | Timeout path |
| `1007` | **Answer then hang up after 5s** | Remote-BYE handling, DB persistence on hangup |
| `1008` | **Silence after answer** | Classifier `SILENCE` vs the 30s no-audio case |
`1002` and `1003` are the two that matter most; the rest are cheap to add once
the dialplan exists.
### Classifier determinism
The reason for a scripted IVR rather than a real call: 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. Recommend a **fixed MoH
file committed to the repo** rather than Asterisk's stock music, so the
classifier's input is byte-identical on every run and across hosts.
This is what makes the Phase 0 music-vs-speech precedence fix
([services/audio_classifier.py](../services/audio_classifier.py)) testable
against real audio for the first time.
---
## Security — the part that needs a decision
Two unauthenticated SIP endpoints would exist on the Dev LAN.
**1. Asterisk.** Default `pjsip.conf` examples accept anonymous calls. This lab
must not: `allow_anonymous_inbound = no`, an explicit endpoint for Hold Slayer
with a password, and `permit=` limited to triton's address. Asterisk's default
config is a well-known toll-fraud target and must not be shipped as-is.
**2. Hold Slayer's own SIP listener — pre-existing, flagged earlier.**
`_handle_incoming_register()` replies `200 OK` to any REGISTER with **no digest
challenge**. On loopback that was tolerable. The moment the gateway binds a LAN
interface to talk to Asterisk, any host on the Dev LAN can register as a device
and receive transferred calls.
That is not caused by this lab, but this lab is what makes it reachable. Options,
in order of preference:
1. **Implement digest auth** on inbound REGISTER — the real fix
2. **Bind the SIP listener to a specific interface** and firewall 5060 to triton
↔ nereid only — mitigation, not a fix
3. Accept it explicitly, in writing, as Dev-only
I would not deploy this without at least (2), and (1) is required before
anything resembling production. **This needs your decision before build.**
Also note: `pjsua2` runs `--enable-shared` with TLS available, so SIP-TLS +
SRTP is possible later. Not proposed for the lab — plain UDP keeps `sngrep`
readable, which matters enormously when debugging signalling.
---
## Observability
Match the estate rather than inventing:
- **Logs** — default `json-file` driver, discovered by the host Alloy Docker
socket source and labelled `job=asterisk`. **No syslog listener and no Loki
URL env** — that would double-ship, the same note already carried in
[hold-slayer's compose template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2).
- **Health** — Asterisk has no HTTP health endpoint by default. Enable ARI on
`21071` and probe `/ari/asterisk/info`, which is a genuine liveness signal
(the SIP stack answers), unlike a bare TCP check.
- **`sngrep`** on nereid for live SIP ladder inspection. Not currently installed
anywhere; it is the single most useful tool when signalling misbehaves.
---
## What this does *not* prove
Stated plainly so the lab is not over-trusted:
- **Not real PSTN audio.** No G.711 transcoding artefacts, no packet loss, no
jitter, no carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
- **Not real IVR behaviour.** Our dialplan is what we imagine a bank sounds
like. Real trees are longer, noisier, and interrupt.
- **Not trunk registration/auth** — that is Kamailio's job (Phase 4a), or the
real trunk's.
- **Not carrier-specific quirks** — each ITSP has its own.
It proves the gateway's *own* logic end to end. That is the majority of the
risk, and it is the part that is currently entirely untested against real media.
---
## Blocking prerequisite — the container runs stub media
**The Hold Slayer Docker image deliberately does not build PJSUA2**
([docs/pjsua2-build.md](pjsua2-build.md)), so the deployed container on triton
runs the media pipeline in **stub mode**. Stub mode's audio calls *return
successfully while doing nothing*.
If the lab runs against the current image, every media test passes while proving
nothing. This is the single most dangerous failure mode in the plan, because it
looks like success.
Two options:
| Option | Effort | Trade-off |
|---|---|---|
| **A. Add a pjproject build stage to the Dockerfile** | Higher — multi-stage build, ~10 min build, larger image | The deployed artefact gains real media. Needed eventually regardless |
| **B. Run Hold Slayer from a venv on triton for the lab** | Lower — the Phase 1b build already exists on caliban | Tests the binaries actually built, but diverges from the deployed artefact |
**Recommendation: A.** B tests something that is not what ships, and the
Dockerfile needs this anyway before Hold Slayer can place a real call from a
container. Doing it now means the lab validates the real artefact. B is a
reasonable short-cut only if you want a fast first signal.
The existing deploy also has a **stale-config finding** — see below — that
touches the same file, so both are worth doing in one pass.
---
## Finding: the deployed compose template is stale
Independent of this lab, [the deployed template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2)
sets `API_TOKEN`, which **no longer exists** — auth is now Casdoor SSO + PATs
via one resolver. The comment "the app's single static bearer across REST/WS/MCP
… required on 0.0.0.0" describes an auth model that was removed.
Live state confirms the service is up and `degraded`/`engine: mock` (correct and
honest). Given `_check_startup_config` refuses SSO-off on a non-loopback bind, it
is worth establishing how it is currently booting — most likely `CASDOOR_ENABLED`
defaults such that the unknown `API_TOKEN` is simply ignored.
Flagging, not fixing — it is outside this design, but it lives in the file the
lab will modify, and `hold_slayer_api_token` is still being pulled from the OCI
vault for a variable the app no longer reads.
---
## Build order
Each step is independently verifiable; none commits you to the next.
1. **Decide** the two open questions: media (A or B), and SIP-listener security
(digest / firewall / accept)
2. **Dialplan + compose**, developed on caliban against a local Asterisk
container — no Virgo changes yet, fastest iteration
3. **Prove `1001`** locally: Hold Slayer places a call, audio flows, classifier
sees `LIVE_HUMAN`. This is the real Phase 2 gate
4. **Prove `1002`/`1003`** locally: DTMF lands, hold→human transition fires
5. **Promote to Virgo** — Ansible role on nereid, `SIP_TRUNK_HOST=nereid.helu.ca`
on triton, re-run 18 across the LAN
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
Steps 24 need no Virgo changes at all, which is worth exploiting: the dialplan
is where the fiddly work is, and iterating locally is far faster than through
Ansible.
---
## Files this would add
```
hold-slayer/
tests/lab/
dialplan/extensions.conf # the 8 scenarios
dialplan/pjsip.conf # endpoint for Hold Slayer, anonymous denied
sounds/hold-music.wav # fixed MoH — deterministic classifier input
docker-compose.lab.yml # local Asterisk for steps 24
README.md # how to run the lab locally
virgo/
ansible/asterisk/
deploy.yml # mirrors ansible/hold-slayer/deploy.yml
docker-compose.yml.j2
extensions.conf.j2
pjsip.conf.j2
ansible/inventory/host_vars/nereid.helu.ca.yml # + asterisk_* vars, + service
```
Dialplan lives in **hold-slayer**, not virgo: it is test fixture data that
belongs with the code it tests, and step 24 iteration needs it locally.
Ansible templates it out to nereid for step 5.
---
## Open questions
1. **Media: A or B?** Determines whether step 5 tests the real artefact.
2. **SIP listener security** — digest auth, firewall, or documented acceptance?
Blocking for step 5, not for steps 24.
3. **Is service digit `6` acceptable for SIP** in the 22XYZ scheme, or should
telephony get a different digit? Sets estate precedent.
4. **`asterisk.helu.ca` DNS** — needs an entry, or is `nereid.helu.ca` on the
allocated port sufficient? (Simpler, and one less thing to maintain.)

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()