Skip to content

Add slmodem V.32bis/V.34 modem engine (2400 → 33.6k) - #27

Merged
nicolasumaras merged 8 commits into
mainfrom
feat/slmodem-highspeed-v34
Jun 27, 2026
Merged

Add slmodem V.32bis/V.34 modem engine (2400 → 33.6k)#27
nicolasumaras merged 8 commits into
mainfrom
feat/slmodem-highspeed-v34

Conversation

@nicolasumaras

Copy link
Copy Markdown
Owner

Summary

Adds an optional higher-speed modem engine (SIPFAX_MODEM_ENGINE=slmodem) that drives the free SmartLink slmodem datapump, reaching V.34 33,600 bps on real vintage-machine calls — up from V.22bis 2400 on the spandsp engine — over SIPfax's existing G.711/RTP → pppd pipeline. Default engine stays spandsp, so the V.21/V.22bis path is unchanged.

This branch also carries the foundational softmodem/PPP commits that hadn't yet landed on main (transparent PPP path, V.14 async layer, pppd-supervisor secrets, deploy PPP docs); the new engine builds on them.

What's added

  • vendor/slmodem-bridge/bridge.c — native bridge: SIPfax G.711 stdio ↔ slmodemd 9600 Hz S16 audio; drives the AT answer/originate sequence; emits pty-opened for pppd. Includes a polyphase FIR resampler (fc=3950 Hz, 64 taps). The linear resampler it replaced drooped ~6 dB across the upper voiceband and capped the link at 9600.
  • vendor/slmodem-bridge/test_resamp.c — offline resampler SNR / frequency-response harness used to choose the filter (objective, no live-call guessing).
  • vendor/slmodem/ — vendoring of slmodemd + the gratis dsplibs.o datapump (pinned SHA-256) and 0001-…patch: direct AF_UNIX audio transport, SIPFAX_TTY_LINK, and run-unprivileged.
  • src/index.jsSIPFAX_MODEM_ENGINE selection.
  • deploy/README.md — multilib build notes (the datapump is 32-bit x86).

Validation

  • Real inbound calls connected at 14400 (V.32bis) and 33600 (V.34).
  • Two-modem loopback passes data both directions.
  • Resampler chosen by objective offline measurement (flat ±0.0 dB to 3700 Hz vs −6 dB for linear).

Enable

SIPFAX_MODEM_ENGINE=slmodem
SIPFAX_SLMODEMD=/usr/local/bin/slmodemd
SIPFAX_MODEM_MODULATION=v34   # or v32bis / v22bis

Build (needs gcc-multilib libc6-dev-i386):
./vendor/slmodem/fetch.sh && make -C vendor/slmodem/slmodemd && make -C vendor/slmodem-bridge

Licensing

dsplibs.o is gratis + redistributable but closed-source (32-bit x86); provenance pinned in vendor/slmodem/README.md. Bridge/patch derive from D-Modem/slmodem (GPL-2.0).

🤖 Generated with Claude Code

nicolasumaras and others added 8 commits June 26, 2026 18:24
`modem_connect_tone` in spandsp's `v8_parms_t` is a `MODEM_CONNECT_TONES_*`
enum, not a boolean. The worker set it to `true` (== 1 ==
MODEM_CONNECT_TONES_FAX_CNG), so as the V.8 answerer it emitted the fax
calling tone (1100 Hz, 0.5s on / 3s off) instead of a modem answer tone.

Calling modems never received a valid answer tone, so V.8 always failed and
the worker fell back to V.21, holding a 1650 Hz mark the caller could not
train against. Captured RTP confirmed the 1100 Hz CNG cadence outbound and
near-total silence inbound (the modem never engaged).

Set it to MODEM_CONNECT_TONES_ANSAM_PR (2100 Hz ANSam with phase reversals),
the standard V.8 answer tone. Verified offline (worker now emits continuous
2100 Hz ANSam) and on a live Windows dial-up call: V.8 now succeeds and
selects V.22bis at 2400 baud (v8-v22bis-selected) instead of failing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
V.22bis is a synchronous modem, but Windows dial-up carries asynchronous
PPP (8N1). The worker fed the raw V.22bis bit stream straight into an 8-bit
accumulator with no start/stop handling, so characters never aligned to PPP
HDLC flags and `decodedBytes` stayed 0 (PPP could never come up). The V.21
path already handled this via spandsp's FSK async framing; V.22bis did not.

Insert spandsp async_rx/async_tx (use_v14=TRUE) between the V.22bis bits and
the existing HDLC byte framing:
  rx: v22bis bits -> async_rx (strip start/stop) -> hdlc_rx_byte -> pty
  tx: pty -> hdlc bytes -> async_tx (add start/stop) -> v22bis bits

Also fix v22bis_status: spandsp SIG_STATUS_* codes are all negative, so the
old `status < 0` test could never report carrier-up or training-succeeded.
Decode the specific codes instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several fixes needed to carry real async PPP and to negotiate cleanly:

- Transparent data path: pppd runs async HDLC itself, so stop re-framing.
  async_put_byte() now writes the recovered octet stream (0x7E flags,
  byte-stuffing, FCS intact) straight to the pty, and poll_pty() queues the
  raw pty octets for V.14 transmission instead of HDLC-framing them. Without
  this, pppd saw de-framed payloads with no frame delimiters and LCP never
  ran.

- V.8 handoff use-after-free: v8_result() runs synchronously inside v8_rx(),
  which keeps using the v8 context afterwards. Freeing v8 and allocating the
  v22bis/async state there corrupted that context (the new state often reused
  the freed block). Now the callback only records the decision; main() does
  the v8_free()/data-modem start after v8_rx/v8_tx return (per spandsp's
  tests/v8_tests.c pattern). Verified with a spandsp loopback harness.

- V.8 modulation choice now masks the caller's offer with what we advertised,
  so we don't get pulled into V.22bis when only V.21 was offered.

- Forced-mode start (SIPFAX_MODEM_START_MODE) defers entry to the first input
  frame, so the operator side is wired before pty-opened fires (start-up race
  that intermittently prevented pppd from starting).
pppd has no command-line option to select a secrets file; it always reads
/etc/ppp/{chap,pap}-secrets. The supervisor wrapped pppd in a shell that
appended a bogus `chap-secrets <tmpfile>` option, which pppd rejects
("unrecognized option 'chap-secrets'"), so it exited before LCP and the
caller saw error 721.

Render the per-call credentials to the standard path before launch (single
active call), spawn pppd directly (no wrapper, no bogus option), and clear
the file in place on teardown -- we own the file but not the root-owned
/etc/ppp directory, so truncate rather than unlink.
Standalone spandsp loopback (caller <-> answerer) that runs full V.8
negotiation and the V.22bis handoff, in both forced and V.8 modes, with an
optional echo model. Used to prove the handoff logic is correct independent
of the real modem/channel.

  cc -O2 -o v8handoff_test tests/v8handoff_test.c -lspandsp -lm
  ./v8handoff_test forced          # PASS
  ./v8handoff_test v8              # PASS (handoff trains s2s)
  ECHO_GAIN=0.3 ECHO_DELAY=64 ./v8handoff_test v8   # PASS with echo
PPP never worked on the first VM because the runtime was never set up:
the ppp package was absent, the cloud kernel has no PPP modules, and the
hardened systemd unit blocks setuid pppd. Add the systemd drop-in that grants
pppd the privileges it needs (without privileging the node process) and a
runbook section covering the ppp package, generic kernel, the drop-in, and
the pre-created /etc/ppp secrets files. Also document why the live service
forces V.22bis instead of relying on V.8 negotiation.
Adds an optional higher-speed modem engine that drives the free SmartLink
slmodem datapump, reaching V.34 33,600 bps on real calls (vs V.22bis 2400
on the spandsp engine) over the existing G.711/RTP -> pppd pipeline.

- vendor/slmodem-bridge/bridge.c: native bridge converting SIPfax G.711
  stdio <-> slmodemd's 9600 Hz S16 audio, driving the AT answer/originate
  sequence and emitting pty-opened for pppd. Includes a polyphase FIR
  resampler (fc=3950 Hz, 64 taps) flat to +/-0 dB through 3700 Hz; a naive
  linear resampler drooped ~6 dB across the upper band and capped the link
  at 9600. test_resamp.c is the offline SNR/frequency-response harness used
  to choose the filter.
- vendor/slmodem/: vendoring of slmodemd + the gratis dsplibs.o datapump
  (pinned SHA-256) and 0001-...patch, which switches slmodemd's audio
  transport to a direct AF_UNIX connect, adds SIPFAX_TTY_LINK, and only
  drops privileges when started as root (so it runs as the unprivileged
  service user).
- src/index.js: SIPFAX_MODEM_ENGINE=slmodem selects the bridge; default
  stays spandsp so the existing V.21/V.22bis path is unchanged.
- deploy/README.md: multilib build notes (the datapump is 32-bit x86).

Validated end-to-end: real vintage-machine calls at 14400 (V.32bis) and
33600 (V.34); two-modem loopback passes data both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
renderChapSecrets wrote to a hardcoded /etc/ppp path, so the pppd-supervisor
tests failed with EACCES on CI runners. Add a secretsDir option to
PppdSupervisor (default /etc/ppp) and have the tests inject a writable temp
dir.

Unblocking the secrets write surfaced two stale assertions in the same test:
it expected a /bin/sh-wrapper spawn with a session-dir arg and per-pid
secrets in the session dir. The supervisor spawns pppd directly (the working
production path) and writes <secretsDir>/{chap,pap}-secrets, so the test is
updated to assert the actual behavior. All 38 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nicolasumaras
nicolasumaras merged commit 26cc58f into main Jun 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant