Skip to content

Commit fd1544b

Browse files
dsward2claude
andcommitted
Add runtime filler-mode toggle via /api/filler-mode
Wraps the active FillerMode in a thread-safe FillerModeState the PCM reader polls each chunk, so a host app can flip between silence and an audible test tone at runtime without restarting the process. Lets a manually-triggered test recording capture a verifiable tone instead of encoded digital zero whenever no real PCM is flowing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 55cdd3f commit fd1544b

3 files changed

Lines changed: 86 additions & 7 deletions

File tree

Sources/LiveAudioServerCore/HTTPServer.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ final class HTTPConnection {
491491
private let nowPlayingStore: NowPlayingStore
492492
private let mp3Recorder: FileRecorder?
493493
private let aacRecorder: FileRecorder?
494+
private let fillerModeState: FillerModeState
494495
private let onClose: (() -> Void)?
495496
private var receiveBuffer = Data()
496497
private var clientID: UUID?
@@ -505,6 +506,7 @@ final class HTTPConnection {
505506
nowPlayingStore: NowPlayingStore,
506507
mp3Recorder: FileRecorder?,
507508
aacRecorder: FileRecorder?,
509+
fillerModeState: FillerModeState,
508510
onClose: (() -> Void)? = nil) {
509511
self.connection = connection
510512
self.config = config
@@ -515,6 +517,7 @@ final class HTTPConnection {
515517
self.nowPlayingStore = nowPlayingStore
516518
self.mp3Recorder = mp3Recorder
517519
self.aacRecorder = aacRecorder
520+
self.fillerModeState = fillerModeState
518521
self.onClose = onClose
519522
}
520523

@@ -623,6 +626,9 @@ final class HTTPConnection {
623626
case ("POST", "/api/now-playing"):
624627
serveNowPlayingPost(body: body)
625628
return
629+
case ("POST", "/api/filler-mode"):
630+
serveFillerModePost(body: body)
631+
return
626632
default:
627633
break
628634
}
@@ -653,6 +659,8 @@ final class HTTPConnection {
653659
serveNowPlayingGet(headOnly: method == "HEAD")
654660
case "/api/recorder":
655661
serveRecorderStatus(headOnly: method == "HEAD")
662+
case "/api/filler-mode":
663+
serveFillerModeStatus(headOnly: method == "HEAD")
656664
default:
657665
if config.enableHLS,
658666
let segmenter = hlsSegmenter,
@@ -908,6 +916,29 @@ final class HTTPConnection {
908916
sendJSONResponse(recorderStatusEnvelope())
909917
}
910918

919+
// MARK: - Filler Mode API
920+
921+
private struct FillerModeBody: Codable { let mode: String }
922+
private struct FillerModeStatus: Codable { let mode: String }
923+
924+
private func serveFillerModeStatus(headOnly: Bool) {
925+
sendJSONResponse(FillerModeStatus(mode: fillerModeState.mode.rawValue), headOnly: headOnly)
926+
}
927+
928+
/// Switches the live filler mode (silence ⟷ tone) without restarting the
929+
/// process — see `FillerModeState`. Lets a host app (e.g. a "start a test
930+
/// recording" button) get an audible confirmation tone recorded even when
931+
/// no real PCM is flowing, then switch back to silence afterward.
932+
private func serveFillerModePost(body: Data) {
933+
guard let req = try? JSONDecoder().decode(FillerModeBody.self, from: body),
934+
let newMode = FillerMode(cliArgument: req.mode) else {
935+
sendStatus(400, "Bad Request")
936+
return
937+
}
938+
fillerModeState.mode = newMode
939+
sendJSONResponse(FillerModeStatus(mode: newMode.rawValue))
940+
}
941+
911942
private func serveHLSPlaylist(headOnly: Bool) {
912943
guard let segmenter = hlsSegmenter else {
913944
connection.send(content: notFoundResponse(), completion: .contentProcessed { [weak self] _ in
@@ -962,6 +993,7 @@ final class HTTPServer {
962993
private let nowPlayingStore: NowPlayingStore
963994
private let mp3Recorder: FileRecorder?
964995
private let aacRecorder: FileRecorder?
996+
private let fillerModeState: FillerModeState
965997
private let tlsIdentity: sec_identity_t?
966998
private var startedAt = Date()
967999
private var httpListener: NWListener?
@@ -976,6 +1008,7 @@ final class HTTPServer {
9761008
nowPlayingStore: NowPlayingStore,
9771009
mp3Recorder: FileRecorder?,
9781010
aacRecorder: FileRecorder?,
1011+
fillerModeState: FillerModeState,
9791012
tlsIdentity: sec_identity_t? = nil) {
9801013
self.config = config
9811014
self.mp3Broadcaster = mp3Broadcaster
@@ -984,6 +1017,7 @@ final class HTTPServer {
9841017
self.nowPlayingStore = nowPlayingStore
9851018
self.mp3Recorder = mp3Recorder
9861019
self.aacRecorder = aacRecorder
1020+
self.fillerModeState = fillerModeState
9871021
self.tlsIdentity = tlsIdentity
9881022
}
9891023

@@ -1099,6 +1133,7 @@ final class HTTPServer {
10991133
nowPlayingStore: self.nowPlayingStore,
11001134
mp3Recorder: self.mp3Recorder,
11011135
aacRecorder: self.aacRecorder,
1136+
fillerModeState: self.fillerModeState,
11021137
onClose: { [weak self] in
11031138
self?.connectionLock.lock()
11041139
self?.activeConnections.removeValue(forKey: id)

Sources/LiveAudioServerCore/LiveAudioServer.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ public final class LiveAudioServer: @unchecked Sendable {
8282
private var pcmReader: PCMReader?
8383
private var readerThread: Thread?
8484
private var statsTimer: DispatchSourceTimer?
85+
/// Runtime-mutable filler mode, toggleable via `/api/filler-mode` without
86+
/// restarting the process. Seeded from `config.fillerMode` on each start.
87+
private var fillerModeState: FillerModeState?
8588

8689
public init(config: ServerConfig) {
8790
self.config = config
@@ -208,13 +211,17 @@ public final class LiveAudioServer: @unchecked Sendable {
208211
let nowPlayingStore = NowPlayingStore()
209212
self.nowPlayingStore = nowPlayingStore
210213

214+
let fillerModeState = FillerModeState(config.fillerMode)
215+
self.fillerModeState = fillerModeState
216+
211217
let httpServer = HTTPServer(config: config,
212218
mp3Broadcaster: mp3Broadcaster,
213219
m4aBroadcaster: m4aBroadcaster,
214220
hlsSegmenter: hlsSegmenter,
215221
nowPlayingStore: nowPlayingStore,
216222
mp3Recorder: mp3Recorder,
217223
aacRecorder: aacRecorder,
224+
fillerModeState: fillerModeState,
218225
tlsIdentity: tlsIdentity)
219226
do {
220227
try httpServer.start()
@@ -232,7 +239,7 @@ public final class LiveAudioServer: @unchecked Sendable {
232239
}
233240
self.bonjourPublisher = bonjourPublisher
234241

235-
let pcmReader = PCMReader(config: config, broadcaster: pcmBroadcaster)
242+
let pcmReader = PCMReader(config: config, broadcaster: pcmBroadcaster, fillerModeState: fillerModeState)
236243
self.pcmReader = pcmReader
237244

238245
// SIGPIPE: prevent the process from dying when a client disconnects
@@ -323,5 +330,6 @@ public final class LiveAudioServer: @unchecked Sendable {
323330
bonjourPublisher = nil
324331
pcmReader = nil
325332
readerThread = nil
333+
fillerModeState = nil
326334
}
327335
}

Sources/LiveAudioServerCore/PCMSource.swift

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,35 @@ struct FillerGenerator {
151151
}
152152
}
153153

154+
// MARK: - Filler Mode State
155+
156+
/// Thread-safe box around the active `FillerMode`, mutable at runtime via the
157+
/// `/api/filler-mode` HTTP endpoint (see `HTTPServer`). The PCM reader polls
158+
/// this each time it needs to (re)build its `FillerGenerator`, so a toggle
159+
/// takes effect on the next chunk without restarting the server — letting a
160+
/// host app switch a live process between silence and an audible test tone.
161+
final class FillerModeState: @unchecked Sendable {
162+
private let lock = NSLock()
163+
private var _mode: FillerMode
164+
165+
init(_ mode: FillerMode) {
166+
self._mode = mode
167+
}
168+
169+
var mode: FillerMode {
170+
get { lock.lock(); defer { lock.unlock() }; return _mode }
171+
set { lock.lock(); _mode = newValue; lock.unlock() }
172+
}
173+
}
174+
154175
// MARK: - PCM Reader
155176

156177
/// Continuously reads raw 16-bit little-endian interleaved PCM from stdin, UDP,
157178
/// or TCP and broadcasts frames to all registered consumers.
158179
final class PCMReader: @unchecked Sendable {
159180
private let config: ServerConfig
160181
private let broadcaster: PCMBroadcaster
182+
private let fillerModeState: FillerModeState
161183
private var isRunning = false
162184
/// Set by `stop()` to break out of every loop unconditionally. Distinct
163185
/// from `isRunning` so we can tell "natural EOF" (isRunning flipped by
@@ -170,9 +192,20 @@ final class PCMReader: @unchecked Sendable {
170192
/// stream. Reset to zero whenever any non-zero sample is encountered.
171193
private var consecutiveZeroSamples: Int = 0
172194

173-
init(config: ServerConfig, broadcaster: PCMBroadcaster) {
195+
init(config: ServerConfig, broadcaster: PCMBroadcaster, fillerModeState: FillerModeState) {
174196
self.config = config
175197
self.broadcaster = broadcaster
198+
self.fillerModeState = fillerModeState
199+
}
200+
201+
/// Rebuilds `generator` in place if the live filler mode has changed since
202+
/// it was created, so a runtime `/api/filler-mode` toggle takes effect on
203+
/// the very next chunk. Losing phase continuity across a mode switch is a
204+
/// one-chunk click at worst — acceptable for a state that changes rarely.
205+
private func refreshFillerGeneratorIfNeeded(_ generator: inout FillerGenerator) {
206+
guard generator.mode != fillerModeState.mode else { return }
207+
generator = FillerGenerator(mode: fillerModeState.mode, channels: config.channels,
208+
sampleRate: config.sampleRate, toneHz: config.fillerToneHz)
176209
}
177210

178211
/// Blocks the calling thread — run on a dedicated background thread.
@@ -268,7 +301,7 @@ final class PCMReader: @unchecked Sendable {
268301
private func runSilenceFill() {
269302
let chunkBytes = config.stdinChunkBytes
270303
var fillerBuf = [UInt8](repeating: 0, count: chunkBytes)
271-
var generator = FillerGenerator(mode: config.fillerMode,
304+
var generator = FillerGenerator(mode: fillerModeState.mode,
272305
channels: config.channels,
273306
sampleRate: config.sampleRate,
274307
toneHz: config.fillerToneHz)
@@ -285,6 +318,7 @@ final class PCMReader: @unchecked Sendable {
285318
}
286319

287320
while !shutdownRequested {
321+
refreshFillerGeneratorIfNeeded(&generator)
288322
generator.fillChunk(&fillerBuf)
289323
broadcastPCMBytes(fillerBuf)
290324
usleep(chunkPeriodUSec)
@@ -373,7 +407,7 @@ final class PCMReader: @unchecked Sendable {
373407
var readBuf = [UInt8](repeating: 0, count: max(chunkBytes, 65536))
374408
var pending = [UInt8]()
375409

376-
var fillerGen = FillerGenerator(mode: config.fillerMode,
410+
var fillerGen = FillerGenerator(mode: fillerModeState.mode,
377411
channels: config.channels,
378412
sampleRate: config.sampleRate,
379413
toneHz: config.fillerToneHz)
@@ -396,9 +430,10 @@ final class PCMReader: @unchecked Sendable {
396430
consecutiveTimeouts += 1
397431
if consecutiveTimeouts >= timeoutsBeforeFiller {
398432
if !fillerActive {
399-
log("UDP input idle — emitting \(config.fillerMode) filler until traffic resumes")
433+
log("UDP input idle — emitting \(fillerModeState.mode) filler until traffic resumes")
400434
fillerActive = true
401435
}
436+
refreshFillerGeneratorIfNeeded(&fillerGen)
402437
fillerGen.fillChunk(&fillerBuf)
403438
broadcastPCMBytes(fillerBuf)
404439
}
@@ -452,7 +487,7 @@ final class PCMReader: @unchecked Sendable {
452487
var readBuf = [UInt8](repeating: 0, count: max(chunkBytes, 8192))
453488
var pending = [UInt8]()
454489

455-
var fillerGen = FillerGenerator(mode: config.fillerMode,
490+
var fillerGen = FillerGenerator(mode: fillerModeState.mode,
456491
channels: config.channels,
457492
sampleRate: config.sampleRate,
458493
toneHz: config.fillerToneHz)
@@ -478,9 +513,10 @@ final class PCMReader: @unchecked Sendable {
478513
consecutiveTimeouts += 1
479514
if consecutiveTimeouts >= timeoutsBeforeFiller {
480515
if !fillerActive {
481-
log("TCP input idle — emitting \(config.fillerMode) filler until traffic resumes")
516+
log("TCP input idle — emitting \(fillerModeState.mode) filler until traffic resumes")
482517
fillerActive = true
483518
}
519+
refreshFillerGeneratorIfNeeded(&fillerGen)
484520
fillerGen.fillChunk(&fillerBuf)
485521
broadcastPCMBytes(fillerBuf)
486522
}

0 commit comments

Comments
 (0)