test(lab): add Asterisk lab — a fake PSTN for media validation
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:
293
docs/asterisk-lab-design.md
Normal file
293
docs/asterisk-lab-design.md
Normal 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 1–8 across the LAN
|
||||||
|
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
|
||||||
|
|
||||||
|
Steps 2–4 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 2–4
|
||||||
|
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 2–4 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 2–4.
|
||||||
|
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
147
tests/lab/README.md
Normal 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
|
||||||
|
> 697–941 Hz, columns 1209–1633 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
3
tests/lab/dialplan/.gitignore
vendored
Normal 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
|
||||||
24
tests/lab/dialplan/asterisk.conf
Normal file
24
tests/lab/dialplan/asterisk.conf
Normal 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
|
||||||
144
tests/lab/dialplan/extensions.conf
Normal file
144
tests/lab/dialplan/extensions.conf
Normal 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()
|
||||||
8
tests/lab/dialplan/logger.conf
Normal file
8
tests/lab/dialplan/logger.conf
Normal 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
|
||||||
75
tests/lab/dialplan/pjsip.conf
Normal file
75
tests/lab/dialplan/pjsip.conf
Normal 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
|
||||||
9
tests/lab/dialplan/rtp.conf
Normal file
9
tests/lab/dialplan/rtp.conf
Normal 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 }}
|
||||||
28
tests/lab/docker-compose.lab.yml
Normal file
28
tests/lab/docker-compose.lab.yml
Normal 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
4
tests/lab/sounds/.gitignore
vendored
Normal 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
|
||||||
113
tests/lab/sounds/generate.py
Normal file
113
tests/lab/sounds/generate.py
Normal 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()
|
||||||
Reference in New Issue
Block a user