Skip to content

Commit dc0f7fb

Browse files
committed
Add CPAL audio backend with multiple waveforms, feature flag, and fixes
Previous: SPKRD was a FreeBSD-only HTTP speaker server that played melodies only via /dev/speaker. The core components (HTTP API, retry logic, server structure) were stable, but audio synthesis was limited to single backend with step-discontinuity clicks at note boundaries. Changed: Refactored to support multiple audio backends. Extracted the original /dev/speaker handler to src/freebsd_speaker.rs. Added a new CPAL audio backend (src/cpal_backend.rs) with five waveforms (Square, SquareBandlimited, Sine, Triangle, Sawtooth) plus a new PC Speaker mode with biquad frequency response simulation. Ported FreeBSD spkr.c's MML melody interpreter to Rust (src/mml.rs), now used across all backends. Put the CPAL backend behind a Cargo feature flag (on by default, can be disabled on FreeBSD). Fixed a concurrency bug where cancelling an HTTP client didn't prevent overlapping melodies; the lock now follows the blocking task instead of the async parent. Fixed square waveform to reset phase per tone, matching FreeBSD kernel behavior. Updated CLI to support --output, --waveform, --volume, --sample-rate, and cpal-specific flags. Updated README with Backends section, MML reference, feature documentation, and waveform descriptions. See: changelog/20260502-cpal-backend-iteration.md, changelog/20260503-cpal-cancellation-bug.md, changelog/20260503-pcspeaker-frequency-response.md, changelog/20260503-square-phase-reset.md
1 parent 24e0f4f commit dc0f7fb

15 files changed

Lines changed: 2471 additions & 71 deletions

Cargo.lock

Lines changed: 484 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ daemonize = "0.5"
1818
log = "0.4"
1919
env_logger = "0.10"
2020
syslog = "6.0"
21+
cpal = { version = "0.16", optional = true }
22+
23+
[features]
24+
default = ["cpal"]
25+
cpal = ["dep:cpal"]
2126

2227
[dev-dependencies]
2328
reqwest = "0.11"

README.md

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,35 @@
1-
# SPKRD - FreeBSD Speaker Network Server
1+
# SPKRD - Speaker Network Server
22

3-
A network server that provides HTTP access to FreeBSD's `/dev/speaker` device for remote melody playback.
3+
A network server that exposes a melody-playback endpoint over HTTP. On
4+
FreeBSD it can drive the kernel `/dev/speaker` device directly; on any
5+
other host (Linux, macOS, Windows) it can synthesise the same melodies
6+
through the system audio output via [CPAL].
7+
8+
[CPAL]: https://github.com/RustAudio/cpal
49

510
## Overview
611

7-
SPKRD exposes FreeBSD's built-in speaker device over HTTP, allowing you to play melodies remotely from any system that can make HTTP requests. The server handles device concurrency automatically with configurable retry logic.
12+
SPKRD accepts FreeBSD-style melody strings over HTTP and plays them
13+
back through one of two backends:
14+
15+
- **`freebsd-speaker`** — writes the melody to the kernel
16+
`/dev/speaker` character device. The kernel driver does the
17+
synthesis on the PC speaker hardware.
18+
- **`cpal`** — parses the melody in user space (a faithful Rust port
19+
of the FreeBSD `spkr.c` interpreter) and renders it to audio via
20+
CPAL using a configurable waveform (square / band-limited square /
21+
sine / triangle / sawtooth).
22+
23+
In `--output=auto` (the default) the server probes the configured
24+
device path and uses `freebsd-speaker` if it exists, falling back to
25+
`cpal` otherwise. Both backends share the same HTTP surface, retry
26+
logic, validation, and one-melody-at-a-time semantics.
827

928
## Features
1029

1130
- **HTTP API** - Simple PUT endpoint for melody playback
12-
- **Device Retry Logic** - Automatically retries when device is busy (1s intervals, configurable timeout)
31+
- **Two backends** - FreeBSD `/dev/speaker` or cross-platform CPAL audio output
32+
- **Device Retry Logic** - Automatically retries when busy (1s intervals, configurable timeout)
1333
- **Input Validation** - Melody length limits and UTF-8 validation
1434
- **Configurable Device Path** - Use custom device paths for testing or alternative devices
1535
- **Daemon Support** - Run as background daemon with PID file management
@@ -40,12 +60,14 @@ Example: `"t120l4 c d e f g a b o5c"`
4060

4161
- Rust 1.70+ (for server)
4262
- Go 1.19+ (for Go client example)
43-
- FreeBSD system with `/dev/speaker` device
63+
- FreeBSD with `/dev/speaker` for the `freebsd-speaker` backend; any
64+
CPAL-supported host (Linux/ALSA, macOS/CoreAudio, Windows/WASAPI,
65+
JACK, …) for the `cpal` backend
4466

4567
### Building
4668

4769
```bash
48-
# Clone and build the server
70+
# Clone and build the server with the default features (includes CPAL)
4971
git clone <repository-url>
5072
cd spkrd
5173
cargo build --release
@@ -56,6 +78,26 @@ cargo build --release # Rust client
5678
go build client.go # Go client
5779
```
5880

81+
### Build features
82+
83+
The `cpal` Cargo feature controls whether the user-space audio
84+
synthesis backend is compiled in. It is **enabled by default**.
85+
86+
```bash
87+
# Default build: both backends available
88+
cargo build --release
89+
90+
# FreeBSD: typically you only want the kernel backend. Disabling
91+
# the `cpal` feature removes the cpal dependency and shrinks the
92+
# binary; --output=cpal and the related --waveform/--volume/
93+
# --sample-rate/--cpal-host/--cpal-device flags become unavailable.
94+
cargo build --release --no-default-features
95+
```
96+
97+
When built without the `cpal` feature, `--output=auto` will fail at
98+
startup if the configured device path does not exist (rather than
99+
silently falling back to a non-existent CPAL backend).
100+
59101
### System-Wide Installation
60102

61103
For production deployment as a system service on FreeBSD:
@@ -89,12 +131,42 @@ spkrd_flags="--port 1111 --device /dev/speaker --retry-timeout 30"
89131

90132
**Available configuration flags:**
91133
- `--port <port>` - Server port (default: 1111)
92-
- `--device <path>` - Speaker device path (default: /dev/speaker)
134+
- `--device <path>` - Speaker device path (default: /dev/speaker)
135+
- `--output <mode>` - Output backend: `auto`, `freebsd-speaker`, or `cpal` (default: auto; `cpal` only available when built with the `cpal` feature)
93136
- `--retry-timeout <secs>` - Device retry timeout (default: 30)
94137
- `--daemon` - Run as background daemon (automatically added by rc.d)
95138
- `--pidfile <path>` - PID file path (default: /var/run/spkrd.pid)
96139
- `--debug/-d` - Enable debug logging including client request details
97140

141+
**CPAL-only flags (only present when built with the `cpal` feature):**
142+
- `--waveform <wf>` - `pc-speaker` (default), `square-bandlimited` (sounds nice), `square`, `sine`, `triangle`, or `sawtooth`
143+
- `--volume <v>` - Output volume in `[0.0, 1.0]` (default: 0.25)
144+
- `--sample-rate <hz>` - Override the device's default sample rate
145+
- `--cpal-host <name>` - CPAL host backend (e.g. ALSA, JACK, CoreAudio); defaults to the platform default
146+
- `--cpal-device <name>` - Output device name; defaults to the host's default output
147+
148+
The `pc-speaker` waveform is a faithful simulation of a modern
149+
piezoelectric PC speaker: note frequencies are quantised to what the
150+
Intel 8254 PIT can actually produce (`1,193,182 Hz / divisor`, integer
151+
divisor), a square wave at that frequency is processed through a 3-stage
152+
biquad chain (high-pass / midrange peak / low-pass) tuned to a small
153+
piezo disc, and the output is soft-clipped via `tanh` to mimic driver
154+
saturation. The square-wave phase is reset at every note (mirroring the
155+
PIT counter reset the FreeBSD kernel performs in `timer_spkr_setfreq`),
156+
so consecutive notes — even at the same pitch — get the mechanical
157+
"plink" articulation a real piezo produces. Filter state is preserved
158+
across notes and rests, so the speaker rings out naturally on note-off
159+
rather than cutting silently.
160+
161+
The `square` waveform is the kernel-faithful raw output: phase is reset
162+
at every note (matching the PIT counter reset) and no envelope is
163+
applied, so consecutive notes have hard amplitude-step boundary clicks
164+
that match what FreeBSD's unfiltered `/dev/speaker` output sounds like
165+
through a modern DAC. If you want click-suppressed alternatives, the
166+
remaining software waveforms (`square-bandlimited`, `sine`, `triangle`,
167+
`sawtooth`) keep phase continuity across notes and apply a 5 ms
168+
attack/release envelope to fade in/out each note.
169+
98170
**Example configurations:**
99171

100172
```bash
@@ -205,10 +277,18 @@ Jan 29 10:30:17 hostname spkrd[1234]: Request from 192.168.1.100 completed succe
205277
- `--port` - Server port (default: 1111)
206278
- `--retry-timeout` - Device retry timeout in seconds (default: 30)
207279
- `--device` - Path to speaker device (default: /dev/speaker)
280+
- `--output` - Output backend: `auto` (default), `freebsd-speaker`, or `cpal` (the `cpal` value is available only when built with the `cpal` feature)
208281
- `--daemon` - Run as background daemon
209282
- `--pidfile` - Path to PID file (default: /var/run/spkrd.pid)
210283
- `--debug/-d` - Enable debug logging including client request details
211284

285+
When built with the `cpal` feature, the following additional flags are
286+
available (and are otherwise hidden):
287+
288+
- `--waveform`, `--volume`, `--sample-rate`, `--cpal-host`,
289+
`--cpal-device` — see the configuration-flags section above for
290+
details.
291+
212292
### API Usage
213293

214294
#### Play a Melody
@@ -338,7 +418,7 @@ cat /tmp/test-speaker
338418

339419
This project is licensed under the BSD 2-Clause License. See the [LICENSE](LICENSE) file for details.
340420

341-
Copyright (c) 2025, Raphael Poss
421+
Copyright (c) 2025-2026, Raphael Poss
342422

343423
## Contributing
344424

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# CPAL backend iteration
2+
3+
## Task Specification
4+
5+
The user wants to iterate on the recently added local-melody-playback
6+
feature. The relevant staged changes (not yet committed) are:
7+
8+
- `src/mml.rs` (new): Rust port of FreeBSD `spkr.c`'s MML
9+
interpreter. Produces `Event::Tone { freq_hz, centisecs }` /
10+
`Event::Rest { centisecs }` sequences from a melody string.
11+
- `src/cpal_backend.rs` (new): synthesises an MML melody to PCM via
12+
one of several waveforms (Square, SquareBandlimited, Sine,
13+
Triangle, Sawtooth) and plays it through CPAL. Uses an async mutex
14+
to enforce one-melody-at-a-time and reuses the
15+
retry-on-busy/timeout convention. Wraps the blocking CPAL stream in
16+
`spawn_blocking`.
17+
- `src/freebsd_speaker.rs` (new file but old behaviour): the original
18+
`/dev/speaker` writer extracted out of the previous `speaker.rs`.
19+
- `src/lib.rs`: exposes the new modules.
20+
- `src/main.rs`: adds `--output` (auto/freebsd-speaker/cpal),
21+
`--waveform`, `--volume`, `--sample-rate`, `--cpal-host`,
22+
`--cpal-device`. `auto` falls back to CPAL when the device path is
23+
missing. Warns about flags that don't apply to the resolved
24+
backend.
25+
- `src/server.rs`: dispatches between backends through a `Backend`
26+
enum.
27+
- `src/error.rs`: adds `CpalError(String)`.
28+
- `Cargo.toml`: adds the `cpal = "0.16"` dependency.
29+
- `tests/integration_tests.rs`: updated to use the new
30+
`Backend::FreebsdSpeaker` shape.
31+
32+
The user has not yet specified the direction of the iteration. Open
33+
clarifying questions are recorded below, awaiting answers before any
34+
implementation plan is drafted.
35+
36+
## Iteration scope (user-specified)
37+
38+
Three concrete deliverables for this round:
39+
40+
1. Add a history-attribution header to `src/mml.rs` pointing at the
41+
original FreeBSD `spkr.c` (sources are in `src/fbsd-speaker/`).
42+
2. Put the CPAL backend behind a Cargo feature flag. The feature is
43+
on by default, but can be disabled at build time. Document this
44+
in README and recommend disabling on FreeBSD targets (where the
45+
real /dev/speaker is available).
46+
3. Since the project is no longer FreeBSD-only, add a short
47+
description of the MML melody input language to README so users
48+
not familiar with the FreeBSD speaker driver can use it.
49+
50+
## Decisions
51+
52+
- Cargo feature is named `cpal`; default-on. `dep:cpal` makes the
53+
optional dep purely a feature gate (no implicit feature with the
54+
same name as the dep).
55+
- Asymmetric gating: only `cpal_backend` and the cpal dependency are
56+
feature-gated. `freebsd_speaker` is always compiled — it has no
57+
platform-specific deps, so the savings of gating it would be
58+
negligible.
59+
- `auto` resolution when `cpal` is disabled: if the device path is
60+
missing, fail at startup with a clear message (rather than
61+
resolving to `freebsd-speaker` and erroring per-request, which
62+
would be noisier and harder to diagnose).
63+
- `--output=cpal` and the cpal-only flags are hidden from `--help`
64+
when the feature is disabled (via `#[cfg(feature = "cpal")]` on
65+
each `#[arg]` field and on the enum variant). The `--output` help
66+
text also varies based on the feature.
67+
- README change scope: kept narrow. The MML "Quick Melody Syntax
68+
Reference" already exists, so we only updated the project
69+
framing (no longer FreeBSD-only), added the Backends paragraph,
70+
and a Build features section documenting the feature flag and
71+
recommending `--no-default-features` on FreeBSD targets.
72+
- Attribution in `mml.rs`: short two-paragraph note crediting the
73+
`spkr.c` lineage (Raymond v1.4 1993, Chernov FreeBSD port),
74+
with a license-compatibility note (BSD-2-Clause both sides).
75+
76+
## Files Modified
77+
78+
- `Cargo.toml`: `cpal` becomes optional; new `[features]` table with
79+
`default = ["cpal"]` and `cpal = ["dep:cpal"]`.
80+
- `src/lib.rs`: gate `pub mod cpal_backend;` behind the feature;
81+
refresh header comment.
82+
- `src/error.rs`: gate `CpalError(String)` variant + Display arm.
83+
- `src/server.rs`: gate `CpalBackend` import, `Backend::Cpal`
84+
variant, dispatch arm, error-mapping arm, and the now-conditional
85+
`Arc` import.
86+
- `src/main.rs`: gate the CPAL imports, `WaveformArg`, the
87+
`OutputMode::Cpal` variant, the five cpal-only CLI flags, the
88+
cpal arms in `resolve_output`/`warn_unused_flags`/`build_backend`,
89+
the `Arc` import, and the `warn` import. New const `OUTPUT_HELP`
90+
switches the `--output` help text based on feature state. New
91+
startup check that fails loudly if `--output=auto` is used with
92+
no device and the cpal feature is off.
93+
- `src/mml.rs`: replaced top-of-file comment with attribution +
94+
license note. Behaviour unchanged.
95+
- `README.md`: retitled (no longer FreeBSD-only); added Backends
96+
paragraph; added Build features section documenting the `cpal`
97+
feature and the FreeBSD recommendation; expanded the flag lists
98+
with `--output` plus the cpal-only flags as a clearly-marked
99+
conditional group.
100+
101+
Not modified:
102+
- `src/cpal_backend.rs`, `src/freebsd_speaker.rs`,
103+
`src/qemu_pcspeaker.md`, `src/fbsd-speaker/spkr.c`,
104+
`tests/integration_tests.rs`, `API.md`.
105+
106+
## Verification
107+
108+
- `cargo build` (default features) → clean.
109+
- `cargo build --no-default-features` → clean (no warnings).
110+
- `cargo test` (default features) → 14 tests pass (10 mml unit + 4
111+
integration).
112+
- `cargo test --no-default-features` → 14 tests pass.
113+
- `./spkrd --help` shows the cpal flags; `./spkrd --help` (no
114+
default features) hides them and the `--output` possible values
115+
list contains only `auto, freebsd-speaker`.
116+
117+
## Current Status
118+
119+
Done. Awaiting the user's call on whether to commit.
120+
121+
## High-Level Decisions
122+
123+
(none yet)
124+
125+
## Files Modified
126+
127+
(none yet — changelog only)
128+
129+
## Current Status
130+
131+
Awaiting user clarification on which direction to take.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# CPAL backend cancellation bug — concurrent melodies overlapping
2+
3+
## Task Specification
4+
5+
The user reported that the CPAL backend does not enforce mutual
6+
exclusion between melodies in the way the FreeBSD `/dev/speaker`
7+
driver does: when the first HTTP client is interrupted (Ctrl-C
8+
before its melody finishes), a second client's melody plays in
9+
parallel with the first instead of waiting.
10+
11+
## Root cause
12+
13+
In the original `play_melody`, the lock was a
14+
`tokio::sync::Mutex<()>` and the `MutexGuard` was held by the async
15+
parent future across the `spawn_blocking(...).await`. When the
16+
client disconnects, axum drops the parent future, which drops the
17+
guard and releases the lock. But `spawn_blocking` cannot be aborted
18+
by tokio — the blocking task continues running, owns its own
19+
`Arc<CpalBackend>` clone, and keeps the live `cpal::Stream` playing
20+
in CPAL's audio thread. The next request sees the lock free,
21+
acquires it, builds a *second* `cpal::Stream`, and PulseAudio /
22+
PipeWire mixes both streams — audible overlap.
23+
24+
Verified via per-event eprintln instrumentation (timestamps showed
25+
the lock-released event firing before the audio actually stopped
26+
when the parent was cancelled).
27+
28+
## Decisions
29+
30+
- Fix matches FreeBSD's `spkr.c` behaviour. There the per-tone
31+
`tsleep` is invoked with `PCATCH`, so a signal interrupts the
32+
in-progress melody and releases the `sx_xlock` mid-string. We
33+
reproduce this with an abort flag observed by the audio callback.
34+
- Lock-follows-the-work: the `play_lock` is now acquired *inside*
35+
the `spawn_blocking` task. The lock's lifetime is tied to the
36+
blocking task's lifetime, not to the parent future's. If the
37+
parent future is dropped, the blocking task continues holding the
38+
lock until it finishes naturally (or is cut short by the abort
39+
flag), at which point the lock is released and the next request
40+
can proceed. This eliminates any window where the parent has
41+
released the lock but the audio thread is still playing.
42+
- Cancellation is propagated via `Arc<AtomicBool>`: an
43+
`AbortOnDrop` guard in the async parent sets the flag in its
44+
`Drop` impl. The cpal callback observes the flag once per
45+
invocation; when set it writes zeros to the output and signals
46+
`done`, ending the wait in `run_stream`.
47+
- `play_lock` switches from `tokio::sync::Mutex<()>` to
48+
`std::sync::Mutex<()>` because the lock is now held entirely
49+
inside synchronous (blocking) code. Holding a tokio mutex across
50+
blocking work is the wrong tool for the job.
51+
- Cancellation does not surface as an HTTP error: by definition the
52+
client has already disconnected, so the response body is
53+
irrelevant. The blocking task may complete with `Ok(retries)` or
54+
with `SpeakerError::Timeout` if the lock was never acquired
55+
within retry_timeout.
56+
57+
## Files Modified
58+
59+
- `src/cpal_backend.rs`:
60+
- `play_lock` type: `AsyncMutex<()>``std::sync::Mutex<()>`.
61+
- `play_melody`: render the buffer in the async parent
62+
(synthesis is CPU-only, no need to spawn_blocking it); move
63+
the retry-poll lock acquisition into the blocking task; install
64+
an `AbortOnDrop` guard in the async parent.
65+
- New `AbortOnDrop` struct.
66+
- `run_stream` (and `play_buffer`): now take an
67+
`Arc<AtomicBool>` abort flag, wire it into the callback. The
68+
callback writes silence and signals `done` when the flag is
69+
set. The 50 ms tail sleep is skipped on abort to keep
70+
cancellation snappy.
71+
72+
## Verification
73+
74+
- `cargo build` (default features) → clean.
75+
- `cargo build --no-default-features` → clean.
76+
- `cargo test` and `cargo test --no-default-features` → green.
77+
- Manual reproduction of the user's scenario: long melody on
78+
client 1, Ctrl-C client 1, immediate request from client 2 →
79+
should hear client 2 alone (with at most a small audio-system
80+
tail bleed, separate issue).
81+
82+
## Current Status
83+
84+
Done. Manual reproduction of the original scenario (long melody on
85+
client 1, Ctrl-C, immediate request from client 2) is left to the
86+
user since this requires real audio output that the development
87+
environment can't validate without disturbing other audio.

0 commit comments

Comments
 (0)