Skip to content

Commit 5e8ffe7

Browse files
dsward2claude
andcommitted
Add sine-tone filler with UDP/TCP gap detection; bump to v0.1.2.
- New --filler-mode {silence|tone} and --filler-tone-hz (default 1000) flags. Default stays silence so existing setups are unaffected. - UDP and TCP recv loops now use SO_RCVTIMEO at chunk-period cadence; after --filler-after-ms (default 500 ms) of no input, the reader emits filler chunks at the audio sample rate. Resumes immediately on real packets. - FillerGenerator carries phase across chunks so the tone has no clicks at chunk boundaries; tests cover silence/tone modes, amplitude (-20 dBFS), frequency (zero-crossing count), and phase continuity. - README documents the new flags, the three trigger conditions, and a Gqrx end-to-end example. - Refined Gqrx UDP example: drop the ffmpeg passthrough (read UDP directly), add HTTP Basic auth + --silence-dither + --keep-alive, plus an --auth-password-env follow-up and an iPhone Safari credential note. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 558efb4 commit 5e8ffe7

7 files changed

Lines changed: 493 additions & 17 deletions

File tree

README.md

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,20 @@ Options:
326326
treating the stream as dead.
327327
--silence-dither-ms <n> Milliseconds of pure-silence before dither kicks
328328
in (default: 500).
329+
--filler-mode <mode> What to broadcast during the keep-alive
330+
silence-fill window after stdin EOF.
331+
"silence" (default) emits digital zero
332+
(optionally TPDF-dithered via --silence-dither).
333+
"tone" emits a continuous sine wave at
334+
--filler-tone-hz so listeners hear an audible
335+
"dead-air" placeholder. Only effective with
336+
--keep-alive.
337+
--filler-tone-hz <hz> Frequency of the sine-tone filler in Hz
338+
(default: 1000). Ignored unless --filler-mode tone.
339+
--filler-after-ms <n> Milliseconds of consecutive UDP/TCP input absence
340+
before the filler kicks in (default: 500). Brief
341+
network jitter passes through unaltered; longer
342+
gaps switch to filler.
329343
-V, --verbose Verbose logging
330344
-v, --version Print version string and exit
331345
-h, --help Show this help
@@ -381,13 +395,42 @@ ffmpeg -f avfoundation -i "VB-Cable" \
381395

382396
### Gqrx UDP audio feed
383397

398+
**Configure Gqrx first.** In Gqrx, click the **UDP** button in the *Audio*
399+
section. In *Audio Options → Network*, set **UDP host** to `localhost`,
400+
**UDP port** to `7355`, and tick the **Stereo** checkbox. (Gqrx will start
401+
streaming as soon as LiveAudioServer is listening on that port.)
402+
403+
Then start LiveAudioServer:
404+
384405
```bash
385-
# Gqrx commonly sends 16-bit stereo PCM to UDP port 7355.
386-
# This example assumes Gqrx UDP output is configured for 2-channel stereo.
387-
ffmpeg -f s16le -ar 48000 -ac 2 -i "udp://localhost:7355?listen" \
388-
-f s16le -ar 48000 -ac 2 - \
389-
| .build/release/LiveAudioServer --rate 48000 --channels 2
390-
```
406+
# Gqrx sends 48 kHz 16-bit stereo PCM to UDP port 7355 by default — read it
407+
# directly without any external resampler. Basic-auth gates the HTTP routes,
408+
# --silence-dither keeps downstream tools alive between transmissions, and
409+
# --keep-alive holds the stream open across gaps in the upstream feed.
410+
.build/release/LiveAudioServer \
411+
--udp-input-port 7355 \
412+
--auth-user alice \
413+
--auth-password s3cret \
414+
--silence-dither \
415+
--keep-alive
416+
```
417+
418+
A few notes about this example:
419+
420+
- **Don't put the password on the command line in production** — it shows up
421+
in `ps` output and shell history. Use `--auth-password-env` instead:
422+
```bash
423+
export LIVEAUDIO_AUTH_PW=s3cret
424+
.build/release/LiveAudioServer --udp-input-port 7355 \
425+
--auth-user alice --auth-password-env LIVEAUDIO_AUTH_PW \
426+
--silence-dither --keep-alive
427+
```
428+
- Basic auth gates **every** HTTP route, including the status page at `/`.
429+
The first time you open `http://<mac>.local:8080/` on iPhone Safari you'll
430+
see a credential dialog — enter `alice` / `s3cret` (Safari can remember
431+
them in the keychain).
432+
- For anything beyond loopback, pair Basic auth with `--tls-port` so
433+
credentials don't ride in plaintext on the LAN (see "Native HTTPS" below).
391434

392435
### RTL-SDR via `rtl_fm` (mono)
393436

@@ -746,7 +789,7 @@ paths and version. `path=/` is the conventional Safari Bonjour-bookmark key;
746789
`status=/` is the same path under an explicit name for non-Safari clients:
747790

748791
```
749-
ver=0.1.1
792+
ver=0.1.2
750793
path=/
751794
status=/
752795
mp3=/stream.mp3
@@ -759,7 +802,7 @@ details, so a LiveAudioServer-aware client can enumerate all streams in a
759802
single Bonjour lookup without hitting `/status.json`:
760803

761804
```
762-
ver=0.1.1
805+
ver=0.1.2
763806
path=/
764807
status=/
765808
rate=48000
@@ -823,6 +866,9 @@ Example `server.json`:
823866
"reopenFIFO": true,
824867
"silenceDither": false,
825868
"silenceDitherMs": 500,
869+
"fillerMode": "silence",
870+
"fillerToneHz": 1000,
871+
"fillerAfterMs": 500,
826872
"verbose": false,
827873
"authUser": "alice",
828874
"authPassword": "s3cret",
@@ -860,7 +906,7 @@ This is what `ffmpeg -f s16le` produces, which is the standard raw PCM format.
860906

861907
### Stream robustness
862908

863-
Three options help the stream survive upstream hiccups:
909+
Four options help the stream survive upstream hiccups:
864910

865911
- **`--keep-alive`** — after stdin EOF, hold the HTTP outputs open and feed
866912
the encoders silence (paced at sample-rate) so listeners aren't disconnected.
@@ -873,6 +919,27 @@ Three options help the stream survive upstream hiccups:
873919
the broadcaster substitutes inaudible TPDF dither (±1 LSB, ≈-90 dBFS), so
874920
downstream tools that flag pure-silence don't treat the stream as dead.
875921
Applies equally to stdin, UDP, and TCP input.
922+
- **`--filler-mode tone`** — replaces the silence emitted during input gaps
923+
with an audible sine wave at `--filler-tone-hz` (default 1000 Hz, -20 dBFS).
924+
Useful when listeners need a positive signal that the stream is up but the
925+
producer is between sources, rather than just hearing dead air. The phase
926+
accumulator carries across chunks so the tone has no audible clicks at
927+
chunk boundaries.
928+
929+
Triggers in three scenarios:
930+
- **stdin EOF + `--keep-alive`** (legacy behavior).
931+
- **UDP input idle**: no packets for `--filler-after-ms` (default 500 ms).
932+
- **TCP input idle**: connected client stops sending for `--filler-after-ms`.
933+
934+
```bash
935+
# Gqrx → LiveAudioServer; if you mute Gqrx's UDP output, listeners hear a
936+
# 1 kHz tone after 500 ms instead of dead air, until Gqrx resumes.
937+
.build/release/LiveAudioServer \
938+
--udp-input-port 7355 \
939+
--filler-mode tone \
940+
--filler-tone-hz 1000 \
941+
--keep-alive
942+
```
876943

877944
---
878945

Sources/LiveAudioServer/Config.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ struct ServerConfig {
7272
/// Number of consecutive all-zero samples (per channel, summed) that must
7373
/// elapse before dither kicks in. Default ~500 ms at 48 kHz stereo.
7474
var silenceDitherThresholdSamples: Int = 48_000
75+
/// What the reader broadcasts during the keep-alive silence-fill window
76+
/// after stdin reaches EOF. Default `.silence` preserves the historical
77+
/// behavior (zero bytes, optionally TPDF-dithered). `.tone` substitutes a
78+
/// continuous sine wave so listeners hear an audible "test tone" placeholder
79+
/// instead of dead air.
80+
var fillerMode: FillerMode = .silence
81+
/// Frequency in Hz of the sine wave emitted when `fillerMode == .tone`.
82+
/// Ignored otherwise. Default 1000 Hz is the broadcast convention for a
83+
/// reference test tone.
84+
var fillerToneHz: Double = 1000.0
85+
/// Milliseconds of consecutive UDP/TCP input absence before the filler
86+
/// kicks in. Lets brief network jitter pass through unaltered while still
87+
/// covering longer gaps (Gqrx paused, station between feeds, etc.).
88+
/// Default 500 ms matches the silence-dither threshold.
89+
var fillerAfterMs: Int = 500
7590
var inputSource: PCMInputSource = .stdin
7691
var mountMP3: String = "/stream.mp3"
7792
var mountM4A: String = "/stream.m4a"
@@ -125,6 +140,24 @@ struct ServerConfig {
125140
var stdinChunkBytes: Int { stdinChunkFrames * bytesPerFrame }
126141
}
127142

143+
// MARK: - Filler Mode
144+
145+
/// Content the silence-fill loop emits when input has ended (stdin EOF + `--keep-alive`).
146+
enum FillerMode: String {
147+
/// Continue the historical behavior: emit all-zero PCM (optionally TPDF-dithered).
148+
case silence
149+
/// Emit a continuous sine wave so listeners hear an audible placeholder.
150+
case tone
151+
152+
init?(cliArgument: String) {
153+
switch cliArgument.lowercased() {
154+
case "silence": self = .silence
155+
case "tone", "sine", "sine-tone": self = .tone
156+
default: return nil
157+
}
158+
}
159+
}
160+
128161
// MARK: - Encoded Chunk
129162

130163
/// A timestamped chunk of encoded audio bytes for one format.

Sources/LiveAudioServer/ConfigFile.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ enum ConfigFileError: Error, CustomStringConvertible {
2525
case fileNotReadable(String)
2626
case decodeFailed(String, message: String)
2727
case invalidOutputs(String)
28+
case invalidFillerMode(String)
2829

2930
var description: String {
3031
switch self {
@@ -34,6 +35,8 @@ enum ConfigFileError: Error, CustomStringConvertible {
3435
return "Config file at \(p) is not valid JSON: \(m)"
3536
case .invalidOutputs(let token):
3637
return "Invalid value in config 'outputs': '\(token)'. Valid: mp3, aac, hls"
38+
case .invalidFillerMode(let token):
39+
return "Invalid value in config 'fillerMode': '\(token)'. Valid: silence, tone"
3740
}
3841
}
3942
}
@@ -64,6 +67,9 @@ struct ServerConfigFile: Codable {
6467
var reopenFIFO: Bool?
6568
var silenceDither: Bool?
6669
var silenceDitherMs: Int?
70+
var fillerMode: String? // "silence" | "tone"
71+
var fillerToneHz: Double?
72+
var fillerAfterMs: Int?
6773
var verbose: Bool?
6874
var bonjour: String?
6975
var bonjourInputs: Bool?
@@ -113,6 +119,14 @@ func applyConfigFile(_ file: ServerConfigFile, to config: inout ServerConfig) th
113119
// case via --silence-dither-ms.
114120
config.silenceDitherThresholdSamples = (v * config.sampleRate * config.channels) / 1000
115121
}
122+
if let v = file.fillerMode {
123+
guard let mode = FillerMode(cliArgument: v) else {
124+
throw ConfigFileError.invalidFillerMode(v)
125+
}
126+
config.fillerMode = mode
127+
}
128+
if let v = file.fillerToneHz { config.fillerToneHz = v }
129+
if let v = file.fillerAfterMs { config.fillerAfterMs = v }
116130
if let v = file.verbose { config.verbose = v }
117131
if let v = file.bonjour { config.bonjourName = v.isEmpty ? nil : v }
118132
if let v = file.bonjourInputs { config.bonjourAdvertiseInputs = v }

Sources/LiveAudioServer/LiveAudioServerApp.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,21 @@ func printUsage() {
128128
tools from treating the stream as dead.
129129
--silence-dither-ms <n> Milliseconds of pure-silence before dither
130130
kicks in (default: 500).
131+
--filler-mode <mode> What to broadcast during the keep-alive
132+
silence-fill window after stdin EOF.
133+
"silence" (default) emits digital zero
134+
(optionally TPDF-dithered via
135+
--silence-dither). "tone" emits a continuous
136+
sine wave at --filler-tone-hz so listeners
137+
hear an audible "dead-air" placeholder.
138+
Only effective with --keep-alive.
139+
--filler-tone-hz <hz> Frequency of the sine-tone filler in Hz
140+
(default: 1000). Ignored unless
141+
--filler-mode tone.
142+
--filler-after-ms <n> Milliseconds of consecutive UDP/TCP input
143+
absence before the filler kicks in (default:
144+
500). Brief network jitter passes through
145+
unaltered; longer gaps switch to filler.
131146
-V, --verbose Verbose logging
132147
-v, --version Print version string and exit
133148
-h, --help Show this help
@@ -391,6 +406,25 @@ func parseCLI(_ args: [String]) -> CLIParseResult {
391406
return .error("Bad --silence-dither-ms (must be a non-negative integer)")
392407
}
393408
silenceDitherMs = ms
409+
case "--filler-mode":
410+
i += 1
411+
guard i < args.count else { return .error("Missing --filler-mode value (silence|tone)") }
412+
guard let mode = FillerMode(cliArgument: args[i]) else {
413+
return .error("Bad --filler-mode '\(args[i])' (must be 'silence' or 'tone')")
414+
}
415+
config.fillerMode = mode
416+
case "--filler-tone-hz":
417+
i += 1
418+
guard i < args.count, let v = Double(args[i]), v > 0, v < Double(config.sampleRate) / 2.0 else {
419+
return .error("Bad --filler-tone-hz (must be > 0 and below the Nyquist frequency)")
420+
}
421+
config.fillerToneHz = v
422+
case "--filler-after-ms":
423+
i += 1
424+
guard i < args.count, let v = Int(args[i]), v >= 0 else {
425+
return .error("Bad --filler-after-ms (must be a non-negative integer)")
426+
}
427+
config.fillerAfterMs = v
394428
case "-V", "--verbose":
395429
config.verbose = true
396430
case "-v", "--version":

0 commit comments

Comments
 (0)