@@ -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.
158179final 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