Follow-up to 3150f78, which over-corrected. Dropping `notice` from logger.conf
made the log quiet by making the gateway undebuggable: Asterisk reports
rejected SIP requests at notice level via log_failed_request ("No matching
endpoint found", "Failed to authenticate"), and on a box whose entire job is
answering SIP those are the most useful lines it produces. A call was refused
and nothing said so.
`notice` is restored. The noise is dealt with where it originates instead:
- modules.conf, new. The image ships `autoload=yes` and loads every module it
was built with, including chan_alsa on a container with no sound card:
~74 ALSA config errors per restart, plus module-load ERRORs for CDR/CEL
backends, LDAP/ODBC realtime config, and format_ogg_vorbis — none of which
can work here. Explicit noload for those; autoload stays on, because an
allow-list would break quietly the first time a scenario needs a module
nobody remembered to add.
- The healthcheck reduction from 3150f78 (one CLI connection on a 60s
interval, rather than the image's ~7 every 30s) does the rest.
Verified on galatea: ALSA lines 74 → 0, all startup ERRORs gone (only one-off
benign WARNINGs remain), steady-state 4 lines per 2 minutes against ~840 per
30 minutes originally — and a refused call still logs 6 diagnostic lines.
Transport bound, both endpoints and dialplan loaded, container healthy.
`verbose` stays excluded: dialplan execution is worth having when tracing a
specific call, not worth shipping to Loki continuously. Raise it at runtime
with `asterisk -rx "core set verbose 3"`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logging was configured correctly and shipping to Loki, but the stream was
useless: measured at 100% healthcheck chatter, with every line wrapped in ANSI
escape codes. The real SIP events were there and completely buried.
Three causes, each masking the next:
- asterisk.conf was written in the first lab commit with `nocolor = yes` and
never mounted, so the setting had no effect. Now mounted. It was also
overriding [directories] and runuser/rungroup, which the image sets up
correctly itself — removed, since overriding them risks breaking the
container for no gain.
- The image's command is `-vvvdddf`: verbosity 3 and debug 3 forced on the
command line, which overrides both asterisk.conf and logger.conf. Overridden
in compose to drop -v and -d; warnings and errors still log, and verbosity
is raisable at runtime when tracing a call.
- The actual source: the image's healthcheck makes ~7 separate `asterisk -rx`
connections every 30s, and Asterisk logs a connect/disconnect pair for each.
Replaced with a single check on a 60s interval, and the check now runs
`pjsip show transports` rather than `core show version` — that fails when
Asterisk is up but unconfigured, which is exactly the state that produced a
"healthy" container with no SIP stack on first deploy.
logger.conf drops both `notice` and `verbose`, which is where those pairs
arrive.
Verified on galatea: noise down from ~48 to 8 lines per two minutes (-83%),
zero ANSI codes in Loki, 90% of the stream now signal, container still
healthy, transport and dialplan intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs
to the lab and the gateway transfers a live call to it by dialling extension
2001. This is deliberate: Hold Slayer's own SIP listener answers 200 OK to any
REGISTER with no digest challenge, so anything on the network could register
as a device and receive transferred calls. Keeping registration in Asterisk
means the lab does not exercise or depend on that path, and the device is
authenticated.
The pjsua CLI built alongside the Python bindings is the test device — same
library stack as the gateway, so no new dependency. Verified end to end: a
gateway call to 2001 produces two channels Up under one bridge id.
Three things that cost time and are now written down:
- `--realm=asterisk`, not `--realm='*'`: the wildcard fails against Asterisk's
digest challenge with PJSIP_EFAILEDCREDENTIAL.
- pjsua is an interactive console app and exits ~8s after start if stdin is
closed or /dev/null. `script -qfc` and `setsid </dev/null` both appear to
work — registration succeeds — and then the process dies, leaving a stale
contact in Asterisk that routes INVITEs to a port nobody is listening on.
Hold a fifo open on stdin instead, and verify the port is actually bound
rather than trusting `pjsip show contacts`.
- Qualify is off for this AOR: the pjsua console does not answer OPTIONS, so
polling marks a working softphone Unavail and the dialplan refuses to ring
it. The 2001 guard therefore tests PJSIP_AOR(softphone,contact) rather than
DEVICE_STATE. A real hardphone answers OPTIONS and can have it re-enabled.
The identify block now matches source address *and port*. A host-only match
claims every packet from that address, so a co-located softphone's REGISTER
was attributed to the gateway endpoint and checked against the gateway's
password — surfacing as "Failed to authenticate" on a correct password.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The speech fixture classified as LIVE_HUMAN on its first 3s window and drifted
to MUSIC for every window after. I had validated only the first window and
reported the fixture as verified, which overstated it: any lab result resting
on that fixture — the hold-slayer scenarios above all — was proving less than
it appeared to.
The cause was one modelling error, not a tuning problem. `_detect_tonality`
looks for an autocorrelation peak above 0.5 in the 50-1000 Hz lag range, and
each syllable used a *constant* f0, which is perfectly periodic there. That
scored is_tonal=True, handing the music score a free 0.3 that speech could not
outrun — and the decision requires speech_score to strictly exceed
music_score, so ties went to music.
Real voices glide and jitter, so the periodicity never locks. The fundamental
now follows a per-syllable pitch contour (rise or fall, plus ~2% cycle-to-cycle
jitter), with the frequency integrated to phase rather than multiplied by t —
`2*pi*f*t` is only a chirp when f is the instantaneous rate, which it is not
once f0 itself moves. is_tonal is now False in every window.
Two smaller fixes fell out of that:
- Aspiration noise is high-passed rather than broadband. Flat noise puts
energy in every Goertzel bin, so the strongest DTMF row and column both
clear the detector's `total_power * 0.1` threshold and each syllable reads
as a keypress. A first-difference filter leaves the 697-1633 Hz bands
comparatively empty. The level is set for margin — spectral flatness lands
at ~0.46, mid-way through the 0.1-0.5 band, not on an edge.
- The music fixture gained two more harmonics and a recording-style noise
floor. Windows straddling a chord change had a momentarily sparse spectrum
and fell *below* the music score's 0.05 flatness floor, scoring as speech.
All three fixtures now classify correctly in 100% of windows (music 27/27,
speech 5/5, silence 2/2), and remain correct when the window is stepped by
half a window — a fixture that only works on aligned boundaries would still
be a trap in a live call, where the analysis window has no relationship to
where the audio began.
Confirmed on a real call through the lab: scenario 1003 now shows the whole
hold-slayer arc, speech -> sustained music -> speech, matching the dialplan.
tests/test_lab_fixtures.py guards this: it sweeps every window rather than
sampling the first, which is exactly what the original validation missed, and
checks the generator is byte-for-byte deterministic. It skips when the
fixtures have not been generated, since they are gitignored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>