From 93801bb625fcdc2e1d0632cb2c7eded0b985a5e6 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 11:16:08 -0500 Subject: [PATCH 01/10] Add empty LiveAudioServerCore library target and product. Set up the new SwiftPM library target and product so external packages can depend on us, with a placeholder source file. Sources move in the next commit. Executable target and tests unchanged. Co-Authored-By: Claude Opus 4.7 --- Package.swift | 17 ++++++++++++++++- Sources/LiveAudioServerCore/Placeholder.swift | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 Sources/LiveAudioServerCore/Placeholder.swift diff --git a/Package.swift b/Package.swift index 8189934..5acc15f 100644 --- a/Package.swift +++ b/Package.swift @@ -6,6 +6,14 @@ let package = Package( platforms: [ .macOS(.v13) ], + products: [ + // Public library product so external SwiftPM packages (e.g. a SwiftUI + // host app) can `.package(url: …)` this repo and consume the server + // in-process. The CLI binary continues to exist as a separate + // executable product. + .library(name: "LiveAudioServerCore", targets: ["LiveAudioServerCore"]), + .executable(name: "LiveAudioServer", targets: ["LiveAudioServer"]) + ], dependencies: [ .package(url: "https://github.com/apple/swift-testing.git", from: "0.10.0") ], @@ -16,9 +24,16 @@ let package = Package( name: "CLame", path: "Frameworks/Mp3Lame.xcframework" ), + // Server + encoders + streaming + config. Reusable from a host app. + .target( + name: "LiveAudioServerCore", + dependencies: ["CLame"], + path: "Sources/LiveAudioServerCore" + ), + // Thin CLI shim: argument parsing, signal handling, process exit. .executableTarget( name: "LiveAudioServer", - dependencies: ["CLame"], + dependencies: ["LiveAudioServerCore"], path: "Sources/LiveAudioServer" ), .testTarget( diff --git a/Sources/LiveAudioServerCore/Placeholder.swift b/Sources/LiveAudioServerCore/Placeholder.swift new file mode 100644 index 0000000..be58e79 --- /dev/null +++ b/Sources/LiveAudioServerCore/Placeholder.swift @@ -0,0 +1,10 @@ +// LiveAudioServer — https://github.com/dsward2/LiveAudioServer +// +// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US +// Licensed under the Apache License, Version 2.0. +// +// Placeholder so the new `LiveAudioServerCore` library target has at least +// one source file in this transitional commit. Will be deleted as the real +// sources move in from Sources/LiveAudioServer/. + +import Foundation From ec6455d62b78e0ee69ba3af27183df2b7125964f Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 15:33:45 -0500 Subject: [PATCH 02/10] Move sources to LiveAudioServerCore and add public LiveAudioServer API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the package into two targets: - LiveAudioServerCore (new library product): all the server, encoding, streaming, and config logic. Exposes a public façade: * struct ServerConfig (aka LiveAudioServerConfig typealias) * final class LiveAudioServer with init(config:), start() async throws, stop() async, isRunning * enum LiveAudioServerError for setup-failure cases * func parseCLI(_:) -> CLIParseResult — pure, testable * version/notice constants - LiveAudioServer (executable): a thin shim around the library that does argv parsing, signal handling, and process exit. CLame is now a dependency of the library, not the executable. Tests now @testable import LiveAudioServerCore. Behavior changes: - HTTPServer.swift no longer calls exit(1) when its NWListener enters the .failed state; library code never terminates the process. Behavior unchanged: - CLI flags, defaults, exit codes, on-the-wire protocol all identical. Co-Authored-By: Claude Opus 4.7 --- Package.swift | 2 +- .../LiveAudioServer/LiveAudioServerApp.swift | 549 +----------------- .../AACEncoder.swift | 0 .../Bonjour.swift | 0 Sources/LiveAudioServerCore/CLIParse.swift | 300 ++++++++++ .../ChunkBroadcaster.swift | 0 .../Config.swift | 98 ++-- .../ConfigFile.swift | 0 .../HLSSegmenter.swift | 0 .../HTTPServer.swift | 3 +- .../IPACL.swift | 10 +- .../LiveAudioServerCore/LiveAudioServer.swift | 311 ++++++++++ .../MP3Encoder.swift | 0 .../NowPlaying.swift | 0 .../PCMSource.swift | 0 Sources/LiveAudioServerCore/Placeholder.swift | 10 - .../Recorder.swift | 0 .../StatsCollector.swift | 0 .../TLSIdentity.swift | 0 .../Version.swift | 8 +- .../ADTSHeaderTests.swift | 2 +- .../LiveAudioServerTests/CLIParseTests.swift | 2 +- .../ConfigFileTests.swift | 2 +- .../FillerGeneratorTests.swift | 2 +- .../HLSSegmenterTests.swift | 2 +- Tests/LiveAudioServerTests/IPACLTests.swift | 2 +- .../NowPlayingTests.swift | 2 +- .../PCMInputSourceTests.swift | 2 +- .../LiveAudioServerTests/RecorderTests.swift | 2 +- .../StatsCollectorTests.swift | 2 +- .../TPDFDitherTests.swift | 2 +- 31 files changed, 718 insertions(+), 595 deletions(-) rename Sources/{LiveAudioServer => LiveAudioServerCore}/AACEncoder.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/Bonjour.swift (100%) create mode 100644 Sources/LiveAudioServerCore/CLIParse.swift rename Sources/{LiveAudioServer => LiveAudioServerCore}/ChunkBroadcaster.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/Config.swift (70%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/ConfigFile.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/HLSSegmenter.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/HTTPServer.swift (99%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/IPACL.swift (95%) create mode 100644 Sources/LiveAudioServerCore/LiveAudioServer.swift rename Sources/{LiveAudioServer => LiveAudioServerCore}/MP3Encoder.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/NowPlaying.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/PCMSource.swift (100%) delete mode 100644 Sources/LiveAudioServerCore/Placeholder.swift rename Sources/{LiveAudioServer => LiveAudioServerCore}/Recorder.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/StatsCollector.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/TLSIdentity.swift (100%) rename Sources/{LiveAudioServer => LiveAudioServerCore}/Version.swift (89%) diff --git a/Package.swift b/Package.swift index 5acc15f..aff91f6 100644 --- a/Package.swift +++ b/Package.swift @@ -39,7 +39,7 @@ let package = Package( .testTarget( name: "LiveAudioServerTests", dependencies: [ - "LiveAudioServer", + "LiveAudioServerCore", .product(name: "Testing", package: "swift-testing") ], path: "Tests/LiveAudioServerTests" diff --git a/Sources/LiveAudioServer/LiveAudioServerApp.swift b/Sources/LiveAudioServer/LiveAudioServerApp.swift index 481d4c0..a05ac25 100644 --- a/Sources/LiveAudioServer/LiveAudioServerApp.swift +++ b/Sources/LiveAudioServer/LiveAudioServerApp.swift @@ -4,44 +4,16 @@ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -// implied. See the License for the specific language governing -// permissions and limitations under the License. // Sources/LiveAudioServer/LiveAudioServerApp.swift -// Entry point: parse arguments, wire up the pipeline, and run. -// -// Pipeline: -// -// stdin / UDP / TCP (raw 16-bit PCM) -// │ -// ▼ -// PCMReader -// │ -// ▼ -// PCMBroadcaster ──────────────────────────────┐ -// │ │ -// ▼ ▼ -// MP3Encoder (libmp3lame) AACEncoder (AudioToolbox) -// │ │ -// ▼ ▼ -// ChunkBroadcaster [mp3] ChunkBroadcaster [m4a] -// │ │ -// └──────────────┬──────────────────────────┘ -// ▼ -// HTTPServer (NWListener) -// │ -// HTTP clients (VLC, browsers, ffmpeg…) +// CLI shim. Parses argv via the library, hands the resulting config to a +// `LiveAudioServer`, wires SIGINT/SIGTERM to a graceful shutdown, and blocks +// on the main RunLoop. All audio + HTTP + Bonjour orchestration lives in the +// library — keep this file thin so behavior changes happen in one place. import Foundation -import AudioToolbox -import Network +import Dispatch +import LiveAudioServerCore // MARK: - Usage @@ -176,299 +148,6 @@ func printUsage() { print(usage) } -// MARK: - CLI parsing (testable, side-effect-free) - -/// Result of parsing argv. Either a fully populated ServerConfig (proceed to -/// run the server) or a directive to print usage / version and exit. -enum CLIParseResult { - case run(ServerConfig) - case printUsage - case printVersion - case error(String) -} - -/// Extract `--config ` from argv, returning the path (if any) and the -/// remaining argv with the flag removed. Used by `parseCLI` so the file's -/// values can be loaded before CLI overrides are applied. -func extractConfigPath(_ args: [String]) -> (path: String?, remaining: [String]) { - var remaining: [String] = [] - var path: String? = nil - var i = 0 - while i < args.count { - if args[i] == "--config" { - i += 1 - if i < args.count { - path = args[i] - i += 1 - continue - } - // Missing value — leave the `--config` token in place so the - // normal parser emits the canonical error. - remaining.append("--config") - continue - } - remaining.append(args[i]) - i += 1 - } - return (path, remaining) -} - -/// Parse argv (without the program name) into a `CLIParseResult`. Pure: no -/// process-wide side effects, no exits — the caller decides how to react. -/// Exposed at module scope so the tests can exercise it without launching the -/// server. -func parseCLI(_ args: [String]) -> CLIParseResult { - var config = ServerConfig() - // Deferred: --silence-dither-ms must be applied after --rate / --channels - // so the sample-rate conversion uses the final values regardless of flag - // order. nil = use the default threshold from ServerConfig. - var silenceDitherMs: Int? = nil - - // Pre-pass: load a config file if `--config ` was supplied. The - // file populates the starting config; CLI flags processed below override. - let (configPath, args) = extractConfigPath(args) - if let path = configPath { - do { - let file = try loadConfigFile(at: path) - try applyConfigFile(file, to: &config) - } catch let err as ConfigFileError { - return .error("\(err)") - } catch { - return .error("Failed to load --config \(path): \(error)") - } - } - - var i = 0 - while i < args.count { - switch args[i] { - case "-p", "--port": - i += 1 - guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad port") } - config.port = v - case "-r", "--rate": - i += 1 - guard i < args.count, let v = Int(args[i]), - [8000,11025,16000,22050,32000,44100,48000].contains(v) - else { return .error("Bad sample rate (must be 8000/11025/16000/22050/32000/44100/48000)") } - config.sampleRate = v - case "-c", "--channels": - i += 1 - guard i < args.count, let v = Int(args[i]), v == 1 || v == 2 - else { return .error("Bad channel count (must be 1 or 2)") } - config.channels = v - case "--mp3-bitrate": - i += 1 - guard i < args.count, let v = Int(args[i]), v > 0 - else { return .error("Bad MP3 bitrate") } - config.mp3Bitrate = v - case "--aac-bitrate": - i += 1 - guard i < args.count, let v = Int(args[i]), v > 0 - else { return .error("Bad AAC bitrate") } - config.aacBitrate = v * 1000 // accept kbps, store as bps - case "--mp3-mount": - i += 1 - guard i < args.count else { return .error("Missing mp3 mount path") } - config.mountMP3 = args[i] - case "--m4a-mount": - i += 1 - guard i < args.count else { return .error("Missing m4a mount path") } - config.mountM4A = args[i] - case "--hls-mount": - i += 1 - guard i < args.count else { return .error("Missing hls mount path") } - config.mountHLSIndex = args[i] - case "--chunk-frames": - i += 1 - guard i < args.count, let v = Int(args[i]), v >= 512 - else { return .error("Bad chunk-frames (minimum 512)") } - config.stdinChunkFrames = v - case "--udp-input-port": - i += 1 - guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad UDP input port") } - config.inputSource = .udp(port: v) - case "--tcp-input-port": - i += 1 - guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad TCP input port") } - config.inputSource = .tcp(port: v) - case "--outputs": - i += 1 - guard i < args.count else { return .error("Missing --outputs value (e.g. mp3,aac,hls)") } - config.enableMP3 = false - config.enableAAC = false - config.enableHLS = false - let tokens = args[i].split(separator: ",").map { - $0.trimmingCharacters(in: .whitespaces).lowercased() - } - for token in tokens where !token.isEmpty { - switch token { - case "mp3": config.enableMP3 = true - case "aac", "m4a": config.enableAAC = true - case "hls": config.enableHLS = true - default: - return .error("Unknown output '\(token)' in --outputs. Valid: mp3, aac, hls") - } - } - if !config.enableMP3 && !config.enableAAC && !config.enableHLS { - return .error("--outputs requires at least one of: mp3, aac, hls") - } - case "--tls-port": - i += 1 - guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad --tls-port value") } - config.tlsPort = v - case "--tls-identity": - i += 1 - guard i < args.count else { return .error("Missing --tls-identity path") } - config.tlsIdentityPath = args[i] - case "--tls-password": - i += 1 - guard i < args.count else { return .error("Missing --tls-password value") } - config.tlsPassword = args[i] - case "--tls-password-env": - i += 1 - guard i < args.count else { return .error("Missing --tls-password-env name") } - guard let v = ProcessInfo.processInfo.environment[args[i]] else { - return .error("Env var \(args[i]) not set (referenced by --tls-password-env)") - } - config.tlsPassword = v - case "--bind": - i += 1 - guard i < args.count else { return .error("Missing --bind host (e.g. 127.0.0.1)") } - config.bindHost = args[i] - case "--allow-ip": - i += 1 - guard i < args.count else { return .error("Missing --allow-ip value (e.g. 127.0.0.1,192.168.0.0/24)") } - do { - config.allowedClientIPs = try parseAllowList(args[i]) - } catch let err as IPACLParseError { - return .error("\(err)") - } catch { - return .error("\(error)") - } - case "--bonjour": - i += 1 - guard i < args.count else { return .error("Missing --bonjour name") } - let name = args[i].trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return .error("--bonjour name cannot be empty") } - config.bonjourName = name - case "--bonjour-inputs": - config.bonjourAdvertiseInputs = true - case "--stats-interval": - i += 1 - guard i < args.count, let v = Int(args[i]), v >= 0 - else { return .error("Bad --stats-interval (must be ≥ 0; 0 disables)") } - config.statsIntervalSeconds = v - case "--config": - // extractConfigPath() only leaves a bare "--config" here when its - // value was missing — surface a useful error. - return .error("Missing --config path") - case "--record-mp3": - i += 1 - guard i < args.count else { return .error("Missing --record-mp3 path") } - config.recordMP3Path = args[i] - case "--record-aac": - i += 1 - guard i < args.count else { return .error("Missing --record-aac path") } - config.recordAACPath = args[i] - case "--auth-user": - i += 1 - guard i < args.count else { return .error("Missing --auth-user value") } - let u = args[i] - guard !u.isEmpty else { return .error("--auth-user cannot be empty") } - guard !u.contains(":") else { return .error("--auth-user cannot contain ':' (RFC 7617)") } - config.httpAuthUser = u - case "--auth-password": - i += 1 - guard i < args.count else { return .error("Missing --auth-password value") } - config.httpAuthPassword = args[i] - case "--auth-password-env": - i += 1 - guard i < args.count else { return .error("Missing --auth-password-env name") } - guard let v = ProcessInfo.processInfo.environment[args[i]] else { - return .error("Env var \(args[i]) not set (referenced by --auth-password-env)") - } - config.httpAuthPassword = v - case "--auth-realm": - i += 1 - guard i < args.count else { return .error("Missing --auth-realm value") } - let r = args[i].trimmingCharacters(in: .whitespaces) - guard !r.isEmpty else { return .error("--auth-realm cannot be empty") } - config.httpAuthRealm = r - case "--keep-alive": - config.keepAliveOnInputEnd = true - case "--no-fifo-reopen": - config.reopenStdinFIFO = false - case "--silence-dither": - config.silenceDitherEnabled = true - case "--silence-dither-ms": - i += 1 - guard i < args.count, let ms = Int(args[i]), ms >= 0 else { - return .error("Bad --silence-dither-ms (must be a non-negative integer)") - } - silenceDitherMs = ms - case "--filler-mode": - i += 1 - guard i < args.count else { return .error("Missing --filler-mode value (silence|tone)") } - guard let mode = FillerMode(cliArgument: args[i]) else { - return .error("Bad --filler-mode '\(args[i])' (must be 'silence' or 'tone')") - } - config.fillerMode = mode - case "--filler-tone-hz": - i += 1 - guard i < args.count, let v = Double(args[i]), v > 0, v < Double(config.sampleRate) / 2.0 else { - return .error("Bad --filler-tone-hz (must be > 0 and below the Nyquist frequency)") - } - config.fillerToneHz = v - case "--filler-after-ms": - i += 1 - guard i < args.count, let v = Int(args[i]), v >= 0 else { - return .error("Bad --filler-after-ms (must be a non-negative integer)") - } - config.fillerAfterMs = v - case "-V", "--verbose": - config.verbose = true - case "-v", "--version": - return .printVersion - case "-h", "--help": - return .printUsage - default: - return .error("Unknown option: \(args[i])") - } - i += 1 - } - - // Apply deferred dither threshold once sample rate / channels are final. - if let ms = silenceDitherMs { - // Threshold counts individual Int16 samples across all channels. - config.silenceDitherThresholdSamples = (ms * config.sampleRate * config.channels) / 1000 - } - - // Cross-flag validation. - if config.tlsPort != nil && config.tlsIdentityPath == nil { - return .error("--tls-port requires --tls-identity") - } - if config.tlsIdentityPath != nil && config.tlsPort == nil { - return .error("--tls-identity requires --tls-port") - } - if config.recordMP3Path != nil && !config.enableMP3 { - return .error("--record-mp3 requires mp3 in --outputs") - } - if config.recordAACPath != nil && !config.enableAAC { - return .error("--record-aac requires aac in --outputs") - } - if config.bonjourAdvertiseInputs && config.bonjourName == nil { - return .error("--bonjour-inputs requires --bonjour") - } - if config.httpAuthUser != nil && config.httpAuthPassword == nil { - return .error("--auth-user requires --auth-password or --auth-password-env") - } - if config.httpAuthPassword != nil && config.httpAuthUser == nil { - return .error("--auth-password requires --auth-user") - } - - return .run(config) -} - // MARK: - Entry point @main @@ -491,189 +170,39 @@ struct LiveAudioServerApp { exit(1) } - // Load the TLS identity up front so we fail fast on bad cert / passphrase. - var tlsIdentity: sec_identity_t? = nil - if let identityPath = config.tlsIdentityPath { + let server = LiveAudioServer(config: config) + + // Kick off start() in a Task so we can return from main() into the + // RunLoop. start() throws only on synchronous setup failures (TLS + // load, bind, encoder init); those map to exit(1) here so the CLI + // surface is unchanged. + let startupGate = DispatchSemaphore(value: 0) + var startupError: Error? + Task.detached { do { - tlsIdentity = try loadTLSIdentity(p12Path: identityPath, password: config.tlsPassword) + try await server.start() } catch { - fputs("❌ \(error)\n", stderr); exit(1) - } - } - - log("🎙 \(liveAudioServerVersionString)") - log(" Input : \(config.channels == 1 ? "Mono" : "Stereo") PCM, \(config.sampleRate) Hz, 16-bit signed via \(config.inputSource)") - if config.enableMP3 { log(" MP3 : \(config.mp3Bitrate) kbps → \(config.mountMP3)") } - if config.enableAAC { log(" AAC : \(config.aacBitrate / 1000) kbps → \(config.mountM4A)") } - if config.enableHLS { log(" HLS : AAC playlist → \(config.mountHLSIndex)") } - log(" Port : \(config.port)") - if let tlsPort = config.tlsPort { - log(" TLS : enabled on port \(tlsPort)") - } - if let bindHost = config.bindHost { - log(" Bind : \(bindHost) (listeners restricted to this address)") - } - if let acl = config.allowedClientIPs, !acl.allowAll { - log(" ACL : \(acl.matchers.count) allow-list entr\(acl.matchers.count == 1 ? "y" : "ies") — only matching client IPs accepted") - } - if let user = config.httpAuthUser, config.httpAuthPassword != nil { - log(" Auth : HTTP Basic enabled (user=\(user), realm=\"\(config.httpAuthRealm)\")") - if config.tlsPort == nil { - log(" ⚠️ HTTP Basic credentials travel base64-encoded; enable --tls-port for non-localhost use.") + startupError = error } + startupGate.signal() } - if let bname = config.bonjourName { - let scope = config.bonjourAdvertiseInputs ? "outputs + inputs" : "outputs only" - log(" Bonjour: \(bname) (\(scope))") - } - - // Stats collector — totals encoded bytes and uptime. Always live; the - // periodic emitter below decides whether to surface it. - let statsCollector = StatsCollector() - - // File recorders — one per enabled output format, idle until started. - // The `--record-X ` CLI flag, if provided, calls `start(path:)` - // immediately so recording begins at launch. - let mp3Recorder: FileRecorder? = config.enableMP3 ? FileRecorder(format: .mp3) : nil - let aacRecorder: FileRecorder? = config.enableAAC ? FileRecorder(format: .m4a) : nil - do { - if let p = config.recordMP3Path { try mp3Recorder?.start(path: p) } - if let p = config.recordAACPath { try aacRecorder?.start(path: p) } - } catch { - fputs("❌ \(error)\n", stderr); exit(1) - } - - // 1. Broadcasters (fan-out encoded chunks to HTTP clients, stats, and - // optional file recorders). - let mp3Broadcaster = ChunkBroadcaster(format: .mp3, verbose: config.verbose, - onBroadcast: { data in - statsCollector.recordMP3Bytes(data.count) - mp3Recorder?.write(data) - }) - let m4aBroadcaster = ChunkBroadcaster(format: .m4a, verbose: config.verbose, - onBroadcast: { data in - statsCollector.recordAACBytes(data.count) - aacRecorder?.write(data) - }) - let hlsSegmenter: HLSSegmenter? = config.enableHLS - ? HLSSegmenter(sampleRate: config.sampleRate, - segmentDurationTarget: config.hlsSegmentDuration, - maxSegmentCount: config.hlsPlaylistWindowSize, - segmentPathPrefix: config.mountHLSSegmentPrefix) - : nil - - // 2. Encoders — only constructed for active outputs. - // The AAC encoder must also run when HLS is enabled, since HLS reuses its AAC frames. - var mp3Encoder: MP3Encoder? - var aacEncoder: AACEncoder? - - do { - if config.enableMP3 { - let enc = MP3Encoder(config: config, output: mp3Broadcaster) - try enc.start() - mp3Encoder = enc - } - if config.enableAAC || config.enableHLS { - let enc = AACEncoder(config: config, output: m4aBroadcaster, hlsSegmenter: hlsSegmenter) - try enc.start() - aacEncoder = enc - } - } catch { - fputs("❌ Encoder init failed: \(error)\n", stderr) + startupGate.wait() + if let startupError { + fputs("❌ \(startupError)\n", stderr) exit(1) } - // 3. PCM broadcaster (distributes raw PCM to the active encoders) - let pcmBroadcaster = PCMBroadcaster() - if let mp3Encoder { - _ = pcmBroadcaster.addConsumer { samples in mp3Encoder.encode(samples: samples) } - } - if let aacEncoder { - _ = pcmBroadcaster.addConsumer { samples in aacEncoder.encode(samples: samples) } - } - - // 4. HTTP server - let nowPlayingStore = NowPlayingStore() - let httpServer = HTTPServer(config: config, - mp3Broadcaster: mp3Broadcaster, - m4aBroadcaster: m4aBroadcaster, - hlsSegmenter: hlsSegmenter, - nowPlayingStore: nowPlayingStore, - mp3Recorder: mp3Recorder, - aacRecorder: aacRecorder, - tlsIdentity: tlsIdentity) - do { - try httpServer.start() - } catch { - fputs("❌ HTTP server failed: \(error)\n", stderr) - exit(1) - } - - // Bonjour advertising. HTTP/HTTPS outputs are advertised by their - // NWListener inside HTTPServer (well-known _http._tcp. / _https._tcp. - // service types). This publisher adds: - // - A custom _liveaudio._tcp. service on the HTTP port carrying - // richer metadata (per-stream bitrates, sample rate, etc.) so a - // LiveAudioServer-aware client can filter discovery to that type. - // - The PCM input port (when --bonjour-inputs is set). - let bonjourPublisher = BonjourPublisher() - if let bname = config.bonjourName { - bonjourPublisher.publishCustomOutput(name: bname, config: config) - if config.bonjourAdvertiseInputs { - bonjourPublisher.publishInputs(name: bname, config: config) - } - } - - // 5. PCM reader (runs on a background thread, blocking) - let stdinReader = PCMReader(config: config, broadcaster: pcmBroadcaster) - let readerThread = Thread { - stdinReader.run() - mp3Encoder?.stop() - aacEncoder?.stop() - if config.keepAliveOnInputEnd { - log("Input ended. Encoders stopped; HTTP server remains available because --keep-alive is enabled.") - return - } - log("All encoders stopped. Exiting.") - exit(0) - } - readerThread.name = "stdin-pcm-reader" - readerThread.qualityOfService = .userInteractive - readerThread.start() - - // Handle SIGPIPE (clients disconnect mid-stream) - signal(SIGPIPE, SIG_IGN) - - // Single shutdown path used by both SIGINT and SIGTERM. The default - // `signal()` handlers for SIGINT/SIGTERM would have killed the process - // outright; the DispatchSourceSignal sources override that, but only - // after we explicitly ignore the kernel default via `signal(SIGINT/TERM, - // SIG_IGN)`. Without that, the first signal hits the default handler. + // Graceful shutdown wired to SIGINT/SIGTERM. Ignoring the kernel + // default first means the DispatchSourceSignal sees the signal + // instead of the process being torn down. let runGracefulShutdown: (String) -> Void = { reason in log("\n\(reason) received — graceful shutdown") - // 1. Stop accepting new HTTP clients and close existing ones first - // so subsequent encoder flushes don't pile bytes onto dying - // sockets. - httpServer.stop() - // 2. Stop the PCM reader (releases the input socket / fd) and the - // encoders (flushes lame and AudioConverter). - stdinReader.stop() - mp3Encoder?.stop() - aacEncoder?.stop() - // 3. Stop any active recordings so their trailing bytes flush. - mp3Recorder?.stop() - aacRecorder?.stop() - // 3b. Stop Bonjour publishers so clients see the service vanish - // instead of timing out. - bonjourPublisher.stopAll() - // 4. Give in-flight `NWConnection.send` completions a moment to - // fire on their own queues before the process exits. - Thread.sleep(forTimeInterval: 0.2) - log("Shutdown complete.") - // Drain the async log queue so the final line actually reaches - // stderr before exit(). - logQueue.sync {} - exit(0) + Task { + await server.stop() + log("Shutdown complete.") + logQueue.sync {} + exit(0) + } } signal(SIGINT, SIG_IGN) @@ -684,23 +213,7 @@ struct LiveAudioServerApp { sigTerm.setEventHandler { runGracefulShutdown("SIGTERM") } sigInt.resume() sigTerm.resume() - _ = sigInt; _ = sigTerm // keep alive for the lifetime of the process - - // Optional periodic stats line. - var statsTimer: DispatchSourceTimer? - if config.statsIntervalSeconds > 0 { - let interval = DispatchTimeInterval.seconds(config.statsIntervalSeconds) - let timer = DispatchSource.makeTimerSource(queue: .global()) - timer.schedule(deadline: .now() + interval, repeating: interval) - timer.setEventHandler { - log(formatStatsLine(snapshot: statsCollector.snapshot(), - mp3Clients: mp3Broadcaster.clientCount, - aacClients: m4aBroadcaster.clientCount)) - } - timer.resume() - statsTimer = timer - } - _ = statsTimer // keep alive for the lifetime of the process + _ = sigInt; _ = sigTerm RunLoop.main.run() } diff --git a/Sources/LiveAudioServer/AACEncoder.swift b/Sources/LiveAudioServerCore/AACEncoder.swift similarity index 100% rename from Sources/LiveAudioServer/AACEncoder.swift rename to Sources/LiveAudioServerCore/AACEncoder.swift diff --git a/Sources/LiveAudioServer/Bonjour.swift b/Sources/LiveAudioServerCore/Bonjour.swift similarity index 100% rename from Sources/LiveAudioServer/Bonjour.swift rename to Sources/LiveAudioServerCore/Bonjour.swift diff --git a/Sources/LiveAudioServerCore/CLIParse.swift b/Sources/LiveAudioServerCore/CLIParse.swift new file mode 100644 index 0000000..84a2ed4 --- /dev/null +++ b/Sources/LiveAudioServerCore/CLIParse.swift @@ -0,0 +1,300 @@ +// LiveAudioServer — https://github.com/dsward2/LiveAudioServer +// +// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US +// Licensed under the Apache License, Version 2.0. + +// Sources/LiveAudioServerCore/CLIParse.swift +// Argument-vector parsing for the `LiveAudioServer` CLI. Pure / testable: no +// process-wide side effects, no exits. The CLI shim and tests both call into +// this from outside the library. + +import Foundation + +/// Result of parsing argv. Either a fully populated `ServerConfig` (proceed +/// to run the server) or a directive to print usage / version and exit. +public enum CLIParseResult { + case run(ServerConfig) + case printUsage + case printVersion + case error(String) +} + +/// Extract `--config ` from argv, returning the path (if any) and the +/// remaining argv with the flag removed. Used by `parseCLI` so the file's +/// values can be loaded before CLI overrides are applied. +public func extractConfigPath(_ args: [String]) -> (path: String?, remaining: [String]) { + var remaining: [String] = [] + var path: String? = nil + var i = 0 + while i < args.count { + if args[i] == "--config" { + i += 1 + if i < args.count { + path = args[i] + i += 1 + continue + } + // Missing value — leave the `--config` token in place so the + // normal parser emits the canonical error. + remaining.append("--config") + continue + } + remaining.append(args[i]) + i += 1 + } + return (path, remaining) +} + +/// Parse argv (without the program name) into a `CLIParseResult`. Pure: no +/// process-wide side effects, no exits — the caller decides how to react. +public func parseCLI(_ args: [String]) -> CLIParseResult { + var config = ServerConfig() + // Deferred: --silence-dither-ms must be applied after --rate / --channels + // so the sample-rate conversion uses the final values regardless of flag + // order. nil = use the default threshold from ServerConfig. + var silenceDitherMs: Int? = nil + + // Pre-pass: load a config file if `--config ` was supplied. The + // file populates the starting config; CLI flags processed below override. + let (configPath, args) = extractConfigPath(args) + if let path = configPath { + do { + let file = try loadConfigFile(at: path) + try applyConfigFile(file, to: &config) + } catch let err as ConfigFileError { + return .error("\(err)") + } catch { + return .error("Failed to load --config \(path): \(error)") + } + } + + var i = 0 + while i < args.count { + switch args[i] { + case "-p", "--port": + i += 1 + guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad port") } + config.port = v + case "-r", "--rate": + i += 1 + guard i < args.count, let v = Int(args[i]), + [8000,11025,16000,22050,32000,44100,48000].contains(v) + else { return .error("Bad sample rate (must be 8000/11025/16000/22050/32000/44100/48000)") } + config.sampleRate = v + case "-c", "--channels": + i += 1 + guard i < args.count, let v = Int(args[i]), v == 1 || v == 2 + else { return .error("Bad channel count (must be 1 or 2)") } + config.channels = v + case "--mp3-bitrate": + i += 1 + guard i < args.count, let v = Int(args[i]), v > 0 + else { return .error("Bad MP3 bitrate") } + config.mp3Bitrate = v + case "--aac-bitrate": + i += 1 + guard i < args.count, let v = Int(args[i]), v > 0 + else { return .error("Bad AAC bitrate") } + config.aacBitrate = v * 1000 // accept kbps, store as bps + case "--mp3-mount": + i += 1 + guard i < args.count else { return .error("Missing mp3 mount path") } + config.mountMP3 = args[i] + case "--m4a-mount": + i += 1 + guard i < args.count else { return .error("Missing m4a mount path") } + config.mountM4A = args[i] + case "--hls-mount": + i += 1 + guard i < args.count else { return .error("Missing hls mount path") } + config.mountHLSIndex = args[i] + case "--chunk-frames": + i += 1 + guard i < args.count, let v = Int(args[i]), v >= 512 + else { return .error("Bad chunk-frames (minimum 512)") } + config.stdinChunkFrames = v + case "--udp-input-port": + i += 1 + guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad UDP input port") } + config.inputSource = .udp(port: v) + case "--tcp-input-port": + i += 1 + guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad TCP input port") } + config.inputSource = .tcp(port: v) + case "--outputs": + i += 1 + guard i < args.count else { return .error("Missing --outputs value (e.g. mp3,aac,hls)") } + config.enableMP3 = false + config.enableAAC = false + config.enableHLS = false + let tokens = args[i].split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces).lowercased() + } + for token in tokens where !token.isEmpty { + switch token { + case "mp3": config.enableMP3 = true + case "aac", "m4a": config.enableAAC = true + case "hls": config.enableHLS = true + default: + return .error("Unknown output '\(token)' in --outputs. Valid: mp3, aac, hls") + } + } + if !config.enableMP3 && !config.enableAAC && !config.enableHLS { + return .error("--outputs requires at least one of: mp3, aac, hls") + } + case "--tls-port": + i += 1 + guard i < args.count, let v = UInt16(args[i]) else { return .error("Bad --tls-port value") } + config.tlsPort = v + case "--tls-identity": + i += 1 + guard i < args.count else { return .error("Missing --tls-identity path") } + config.tlsIdentityPath = args[i] + case "--tls-password": + i += 1 + guard i < args.count else { return .error("Missing --tls-password value") } + config.tlsPassword = args[i] + case "--tls-password-env": + i += 1 + guard i < args.count else { return .error("Missing --tls-password-env name") } + guard let v = ProcessInfo.processInfo.environment[args[i]] else { + return .error("Env var \(args[i]) not set (referenced by --tls-password-env)") + } + config.tlsPassword = v + case "--bind": + i += 1 + guard i < args.count else { return .error("Missing --bind host (e.g. 127.0.0.1)") } + config.bindHost = args[i] + case "--allow-ip": + i += 1 + guard i < args.count else { return .error("Missing --allow-ip value (e.g. 127.0.0.1,192.168.0.0/24)") } + do { + config.allowedClientIPs = try parseAllowList(args[i]) + } catch let err as IPACLParseError { + return .error("\(err)") + } catch { + return .error("\(error)") + } + case "--bonjour": + i += 1 + guard i < args.count else { return .error("Missing --bonjour name") } + let name = args[i].trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return .error("--bonjour name cannot be empty") } + config.bonjourName = name + case "--bonjour-inputs": + config.bonjourAdvertiseInputs = true + case "--stats-interval": + i += 1 + guard i < args.count, let v = Int(args[i]), v >= 0 + else { return .error("Bad --stats-interval (must be ≥ 0; 0 disables)") } + config.statsIntervalSeconds = v + case "--config": + // extractConfigPath() only leaves a bare "--config" here when its + // value was missing — surface a useful error. + return .error("Missing --config path") + case "--record-mp3": + i += 1 + guard i < args.count else { return .error("Missing --record-mp3 path") } + config.recordMP3Path = args[i] + case "--record-aac": + i += 1 + guard i < args.count else { return .error("Missing --record-aac path") } + config.recordAACPath = args[i] + case "--auth-user": + i += 1 + guard i < args.count else { return .error("Missing --auth-user value") } + let u = args[i] + guard !u.isEmpty else { return .error("--auth-user cannot be empty") } + guard !u.contains(":") else { return .error("--auth-user cannot contain ':' (RFC 7617)") } + config.httpAuthUser = u + case "--auth-password": + i += 1 + guard i < args.count else { return .error("Missing --auth-password value") } + config.httpAuthPassword = args[i] + case "--auth-password-env": + i += 1 + guard i < args.count else { return .error("Missing --auth-password-env name") } + guard let v = ProcessInfo.processInfo.environment[args[i]] else { + return .error("Env var \(args[i]) not set (referenced by --auth-password-env)") + } + config.httpAuthPassword = v + case "--auth-realm": + i += 1 + guard i < args.count else { return .error("Missing --auth-realm value") } + let r = args[i].trimmingCharacters(in: .whitespaces) + guard !r.isEmpty else { return .error("--auth-realm cannot be empty") } + config.httpAuthRealm = r + case "--keep-alive": + config.keepAliveOnInputEnd = true + case "--no-fifo-reopen": + config.reopenStdinFIFO = false + case "--silence-dither": + config.silenceDitherEnabled = true + case "--silence-dither-ms": + i += 1 + guard i < args.count, let ms = Int(args[i]), ms >= 0 else { + return .error("Bad --silence-dither-ms (must be a non-negative integer)") + } + silenceDitherMs = ms + case "--filler-mode": + i += 1 + guard i < args.count else { return .error("Missing --filler-mode value (silence|tone)") } + guard let mode = FillerMode(cliArgument: args[i]) else { + return .error("Bad --filler-mode '\(args[i])' (must be 'silence' or 'tone')") + } + config.fillerMode = mode + case "--filler-tone-hz": + i += 1 + guard i < args.count, let v = Double(args[i]), v > 0, v < Double(config.sampleRate) / 2.0 else { + return .error("Bad --filler-tone-hz (must be > 0 and below the Nyquist frequency)") + } + config.fillerToneHz = v + case "--filler-after-ms": + i += 1 + guard i < args.count, let v = Int(args[i]), v >= 0 else { + return .error("Bad --filler-after-ms (must be a non-negative integer)") + } + config.fillerAfterMs = v + case "-V", "--verbose": + config.verbose = true + case "-v", "--version": + return .printVersion + case "-h", "--help": + return .printUsage + default: + return .error("Unknown option: \(args[i])") + } + i += 1 + } + + // Apply deferred dither threshold once sample rate / channels are final. + if let ms = silenceDitherMs { + // Threshold counts individual Int16 samples across all channels. + config.silenceDitherThresholdSamples = (ms * config.sampleRate * config.channels) / 1000 + } + + // Cross-flag validation. + if config.tlsPort != nil && config.tlsIdentityPath == nil { + return .error("--tls-port requires --tls-identity") + } + if config.tlsIdentityPath != nil && config.tlsPort == nil { + return .error("--tls-identity requires --tls-port") + } + if config.recordMP3Path != nil && !config.enableMP3 { + return .error("--record-mp3 requires mp3 in --outputs") + } + if config.recordAACPath != nil && !config.enableAAC { + return .error("--record-aac requires aac in --outputs") + } + if config.bonjourAdvertiseInputs && config.bonjourName == nil { + return .error("--bonjour-inputs requires --bonjour") + } + if config.httpAuthUser != nil && config.httpAuthPassword == nil { + return .error("--auth-user requires --auth-password or --auth-password-env") + } + if config.httpAuthPassword != nil && config.httpAuthUser == nil { + return .error("--auth-password requires --auth-user") + } + + return .run(config) +} diff --git a/Sources/LiveAudioServer/ChunkBroadcaster.swift b/Sources/LiveAudioServerCore/ChunkBroadcaster.swift similarity index 100% rename from Sources/LiveAudioServer/ChunkBroadcaster.swift rename to Sources/LiveAudioServerCore/ChunkBroadcaster.swift diff --git a/Sources/LiveAudioServer/Config.swift b/Sources/LiveAudioServerCore/Config.swift similarity index 70% rename from Sources/LiveAudioServer/Config.swift rename to Sources/LiveAudioServerCore/Config.swift index ac379a4..dd97a3e 100644 --- a/Sources/LiveAudioServer/Config.swift +++ b/Sources/LiveAudioServerCore/Config.swift @@ -14,19 +14,19 @@ // implied. See the License for the specific language governing // permissions and limitations under the License. -// Sources/LiveAudioServer/Config.swift +// Sources/LiveAudioServerCore/Config.swift // Configuration, shared types, and constants. import Foundation // MARK: - Server Configuration -enum PCMInputSource: CustomStringConvertible { +public enum PCMInputSource: CustomStringConvertible { case stdin case udp(port: UInt16) case tcp(port: UInt16) - var description: String { + public var description: String { switch self { case .stdin: return "stdin" @@ -50,88 +50,93 @@ enum PCMInputSource: CustomStringConvertible { } } -struct ServerConfig { - var port: UInt16 = 8080 - var channels: Int = 2 // 1 = mono, 2 = stereo - var sampleRate: Int = 48000 // Hz (44100 or 48000 recommended) - var mp3Bitrate: Int = 128 // kbps - var aacBitrate: Int = 128_000 // bps (AudioToolbox uses bps) - var verbose: Bool = false - var stdinChunkFrames: Int = 4096 // PCM frames read per stdin iteration - var keepAliveOnInputEnd: Bool = false +/// All server-tunable knobs. Construct one with the default initializer to get +/// CLI-equivalent defaults, then override fields as needed before handing the +/// value to `LiveAudioServer(config:)`. +public struct ServerConfig { + public var port: UInt16 = 8080 + public var channels: Int = 2 // 1 = mono, 2 = stereo + public var sampleRate: Int = 48000 // Hz (44100 or 48000 recommended) + public var mp3Bitrate: Int = 128 // kbps + public var aacBitrate: Int = 128_000 // bps (AudioToolbox uses bps) + public var verbose: Bool = false + public var stdinChunkFrames: Int = 4096 // PCM frames read per stdin iteration + public var keepAliveOnInputEnd: Bool = false /// When the input stream is a FIFO/named pipe and `keepAliveOnInputEnd` is /// on, re-`open()` the same path on EOF so a new producer can attach. /// Plain pipes (e.g. shell `|`) cannot be reopened — this only takes /// effect when stdin is in fact a FIFO. - var reopenStdinFIFO: Bool = true + public var reopenStdinFIFO: Bool = true /// Inject inaudible TPDF dither into the broadcast PCM stream when a long /// run of all-zero (digitally silent) samples is detected. Prevents /// downstream tools from seeing the stream as "dead" while keeping the /// added noise below the threshold of audibility. - var silenceDitherEnabled: Bool = false + public var silenceDitherEnabled: Bool = false /// Number of consecutive all-zero samples (per channel, summed) that must /// elapse before dither kicks in. Default ~500 ms at 48 kHz stereo. - var silenceDitherThresholdSamples: Int = 48_000 + public var silenceDitherThresholdSamples: Int = 48_000 /// What the reader broadcasts during the keep-alive silence-fill window /// after stdin reaches EOF. Default `.silence` preserves the historical /// behavior (zero bytes, optionally TPDF-dithered). `.tone` substitutes a /// continuous sine wave so listeners hear an audible "test tone" placeholder /// instead of dead air. - var fillerMode: FillerMode = .silence + public var fillerMode: FillerMode = .silence /// Frequency in Hz of the sine wave emitted when `fillerMode == .tone`. /// Ignored otherwise. Default 1000 Hz is the broadcast convention for a /// reference test tone. - var fillerToneHz: Double = 1000.0 + public var fillerToneHz: Double = 1000.0 /// Milliseconds of consecutive UDP/TCP input absence before the filler /// kicks in. Lets brief network jitter pass through unaltered while still /// covering longer gaps (Gqrx paused, station between feeds, etc.). /// Default 500 ms matches the silence-dither threshold. - var fillerAfterMs: Int = 500 - var inputSource: PCMInputSource = .stdin - var mountMP3: String = "/stream.mp3" - var mountM4A: String = "/stream.m4a" - var mountHLSIndex: String = "/hls/index.m3u8" - var enableMP3: Bool = true - var enableAAC: Bool = true - var enableHLS: Bool = true - var tlsPort: UInt16? = nil - var tlsIdentityPath: String? = nil - var tlsPassword: String? = nil + public var fillerAfterMs: Int = 500 + public var inputSource: PCMInputSource = .stdin + public var mountMP3: String = "/stream.mp3" + public var mountM4A: String = "/stream.m4a" + public var mountHLSIndex: String = "/hls/index.m3u8" + public var enableMP3: Bool = true + public var enableAAC: Bool = true + public var enableHLS: Bool = true + public var tlsPort: UInt16? = nil + public var tlsIdentityPath: String? = nil + public var tlsPassword: String? = nil /// If non-nil, HTTP/HTTPS listeners bind to this specific local address /// instead of all interfaces. Use "127.0.0.1" for IPv4 localhost only, /// "::1" for IPv6 localhost only, or an explicit LAN address. - var bindHost: String? = nil + public var bindHost: String? = nil /// Allow-list of source IPs (and CIDR ranges) for HTTP/HTTPS clients. /// `nil` (the default) means allow everyone — equivalent to /// `IPAllowList.allowAll`. Otherwise, connections whose source address /// doesn't match any entry are cancelled at accept time. - var allowedClientIPs: IPAllowList? = nil + public var allowedClientIPs: IPAllowList? = nil /// If set, publish a Bonjour (mDNS) service with this name advertising the /// HTTP (and HTTPS, if enabled) listeners on the LAN. `nil` disables. - var bonjourName: String? = nil + public var bonjourName: String? = nil /// If true and `bonjourName` is set, also publish a Bonjour record for the /// active UDP / TCP PCM input port so producers can discover it. - var bonjourAdvertiseInputs: Bool = false + public var bonjourAdvertiseInputs: Bool = false /// Cadence in seconds for the periodic stats log line. 0 disables the log /// (default). Set to e.g. 60 for one stats line per minute. - var statsIntervalSeconds: Int = 0 + public var statsIntervalSeconds: Int = 0 /// Optional file path: write encoded MP3 chunks here while streaming. - var recordMP3Path: String? = nil + public var recordMP3Path: String? = nil /// Optional file path: write encoded ADTS AAC chunks here while streaming. - var recordAACPath: String? = nil + public var recordAACPath: String? = nil /// If set together with `httpAuthPassword`, every HTTP/HTTPS request must /// carry an `Authorization: Basic` header whose decoded user:password /// matches these values. `nil` (the default) disables auth entirely. /// Credentials travel base64-encoded (effectively plaintext) — pair with /// `--tls-port` for real deployments. - var httpAuthUser: String? = nil - var httpAuthPassword: String? = nil + public var httpAuthUser: String? = nil + public var httpAuthPassword: String? = nil /// Realm string surfaced in the `WWW-Authenticate` header on a 401. The /// browser uses this to decide whether to reuse cached credentials. - var httpAuthRealm: String = "LiveAudioServer" - var mountHLSSegmentPrefix: String = "/hls/seg-" - var hlsSegmentDuration: Double = 2.0 - var hlsPlaylistWindowSize: Int = 5 + public var httpAuthRealm: String = "LiveAudioServer" + public var mountHLSSegmentPrefix: String = "/hls/seg-" + public var hlsSegmentDuration: Double = 2.0 + public var hlsPlaylistWindowSize: Int = 5 + + public init() {} /// Bytes per interleaved PCM frame (2 bytes per sample × channels) var bytesPerFrame: Int { channels * 2 } @@ -140,16 +145,19 @@ struct ServerConfig { var stdinChunkBytes: Int { stdinChunkFrames * bytesPerFrame } } +/// Spec-friendly alias: the public type name external callers see. +public typealias LiveAudioServerConfig = ServerConfig + // MARK: - Filler Mode /// Content the silence-fill loop emits when input has ended (stdin EOF + `--keep-alive`). -enum FillerMode: String { +public enum FillerMode: String { /// Continue the historical behavior: emit all-zero PCM (optionally TPDF-dithered). case silence /// Emit a continuous sine wave so listeners hear an audible placeholder. case tone - init?(cliArgument: String) { + public init?(cliArgument: String) { switch cliArgument.lowercased() { case "silence": self = .silence case "tone", "sine", "sine-tone": self = .tone @@ -183,9 +191,9 @@ enum AudioFormat: String, CaseIterable { // MARK: - Logging -let logQueue = DispatchQueue(label: "log") +public let logQueue = DispatchQueue(label: "log") -func log(_ msg: String, verbose: Bool = false, config: ServerConfig? = nil) { +public func log(_ msg: String, verbose: Bool = false, config: ServerConfig? = nil) { if verbose, let cfg = config, !cfg.verbose { return } logQueue.async { var ts = "" diff --git a/Sources/LiveAudioServer/ConfigFile.swift b/Sources/LiveAudioServerCore/ConfigFile.swift similarity index 100% rename from Sources/LiveAudioServer/ConfigFile.swift rename to Sources/LiveAudioServerCore/ConfigFile.swift diff --git a/Sources/LiveAudioServer/HLSSegmenter.swift b/Sources/LiveAudioServerCore/HLSSegmenter.swift similarity index 100% rename from Sources/LiveAudioServer/HLSSegmenter.swift rename to Sources/LiveAudioServerCore/HLSSegmenter.swift diff --git a/Sources/LiveAudioServer/HTTPServer.swift b/Sources/LiveAudioServerCore/HTTPServer.swift similarity index 99% rename from Sources/LiveAudioServer/HTTPServer.swift rename to Sources/LiveAudioServerCore/HTTPServer.swift index 2779cd4..c890ff0 100644 --- a/Sources/LiveAudioServer/HTTPServer.swift +++ b/Sources/LiveAudioServerCore/HTTPServer.swift @@ -1081,8 +1081,9 @@ final class HTTPServer { } log(" Status page: \(scheme)://\(self.config.bindHost ?? "localhost"):\(port)/\n") case .failed(let error): + // Library code never exits the process. The host can poll + // `LiveAudioServer.isRunning` if it cares about this state. log("❌ \(scheme.uppercased()) listener failed: \(error)") - exit(1) default: break } } diff --git a/Sources/LiveAudioServer/IPACL.swift b/Sources/LiveAudioServerCore/IPACL.swift similarity index 95% rename from Sources/LiveAudioServer/IPACL.swift rename to Sources/LiveAudioServerCore/IPACL.swift index 41ca558..46f2ae6 100644 --- a/Sources/LiveAudioServer/IPACL.swift +++ b/Sources/LiveAudioServerCore/IPACL.swift @@ -116,14 +116,14 @@ enum IPMatcher: Equatable { /// Ordered list of allow-list matchers. A connection is allowed iff at least /// one matcher matches the source host. -struct IPAllowList: Equatable { +public struct IPAllowList: Equatable { let matchers: [IPMatcher] /// `true` for the unrestricted default — kept as a separate flag so a /// caller passing an empty `--allow-ip` value gets the desired "block all /// except listed" semantics rather than accidentally being unrestricted. let allowAll: Bool - static let allowAll = IPAllowList(matchers: [], allowAll: true) + public static let allowAll = IPAllowList(matchers: [], allowAll: true) func allows(_ host: NWEndpoint.Host) -> Bool { if allowAll { return true } @@ -131,10 +131,10 @@ struct IPAllowList: Equatable { } } -enum IPACLParseError: Error, CustomStringConvertible { +public enum IPACLParseError: Error, CustomStringConvertible { case invalidToken(String) - var description: String { + public var description: String { switch self { case .invalidToken(let t): return "Invalid --allow-ip value '\(t)'. Use a single IP or CIDR (e.g. 192.168.0.0/24)." } @@ -142,7 +142,7 @@ enum IPACLParseError: Error, CustomStringConvertible { } /// Parse a comma-separated list like "127.0.0.1,192.168.0.0/24,::1". -func parseAllowList(_ list: String) throws -> IPAllowList { +public func parseAllowList(_ list: String) throws -> IPAllowList { let tokens = list .split(separator: ",", omittingEmptySubsequences: true) .map { $0.trimmingCharacters(in: .whitespaces) } diff --git a/Sources/LiveAudioServerCore/LiveAudioServer.swift b/Sources/LiveAudioServerCore/LiveAudioServer.swift new file mode 100644 index 0000000..0c57a8e --- /dev/null +++ b/Sources/LiveAudioServerCore/LiveAudioServer.swift @@ -0,0 +1,311 @@ +// LiveAudioServer — https://github.com/dsward2/LiveAudioServer +// +// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US +// Licensed under the Apache License, Version 2.0. + +// Sources/LiveAudioServerCore/LiveAudioServer.swift +// Public façade: a host app constructs `LiveAudioServer(config:)`, calls +// `start()`, and later `stop()`. All orchestration that used to live in the +// CLI's `main()` is encapsulated here, including teardown. + +import Foundation +import AudioToolbox +import Network + +/// Errors thrown from `LiveAudioServer.start()`. Replace the CLI's previous +/// `exit(1)` / `fputs(...stderr)` paths so host apps can react in Swift. +public enum LiveAudioServerError: Error, CustomStringConvertible { + case tlsIdentityLoadFailed(String) + case recorderStartFailed(String) + case encoderStartFailed(String) + case httpServerStartFailed(String) + case alreadyRunning + case notRunning + + public var description: String { + switch self { + case .tlsIdentityLoadFailed(let m): return "TLS identity load failed: \(m)" + case .recorderStartFailed(let m): return "Recorder start failed: \(m)" + case .encoderStartFailed(let m): return "Encoder init failed: \(m)" + case .httpServerStartFailed(let m): return "HTTP server start failed: \(m)" + case .alreadyRunning: return "LiveAudioServer is already running" + case .notRunning: return "LiveAudioServer is not running" + } + } +} + +/// In-process façade for the live audio streaming pipeline. +/// +/// Usage: +/// +/// var cfg = LiveAudioServerConfig() +/// cfg.port = 9000 +/// cfg.inputSource = .udp(port: 7355) +/// let server = LiveAudioServer(config: cfg) +/// try await server.start() +/// // ... do other work ... +/// await server.stop() +/// +/// The server runs its PCM reader on a dedicated thread and the HTTP listener +/// on a Network.framework queue. `start()` returns once setup is done, with +/// the server running in the background. Multiple instances can coexist in +/// the same process — each owns its own listeners, encoders, and reader. +public final class LiveAudioServer { + private let config: ServerConfig + private let stateLock = NSLock() + private var _isRunning = false + + // Components held for the lifetime of one start/stop cycle. + private var statsCollector: StatsCollector? + private var mp3Recorder: FileRecorder? + private var aacRecorder: FileRecorder? + private var mp3Broadcaster: ChunkBroadcaster? + private var m4aBroadcaster: ChunkBroadcaster? + private var hlsSegmenter: HLSSegmenter? + private var mp3Encoder: MP3Encoder? + private var aacEncoder: AACEncoder? + private var pcmBroadcaster: PCMBroadcaster? + private var nowPlayingStore: NowPlayingStore? + private var httpServer: HTTPServer? + private var bonjourPublisher: BonjourPublisher? + private var pcmReader: PCMReader? + private var readerThread: Thread? + private var statsTimer: DispatchSourceTimer? + + public init(config: ServerConfig) { + self.config = config + } + + public var isRunning: Bool { + stateLock.lock(); defer { stateLock.unlock() } + return _isRunning + } + + /// Wire up encoders, broadcasters, HTTP listener, Bonjour, and the PCM + /// reader. Returns once all components are running. The reader / listener + /// continue on background queues until `stop()` is called. + public func start() async throws { + stateLock.lock() + if _isRunning { + stateLock.unlock() + throw LiveAudioServerError.alreadyRunning + } + stateLock.unlock() + + var tlsIdentity: sec_identity_t? = nil + if let identityPath = config.tlsIdentityPath { + do { + tlsIdentity = try loadTLSIdentity(p12Path: identityPath, password: config.tlsPassword) + } catch { + throw LiveAudioServerError.tlsIdentityLoadFailed("\(error)") + } + } + + log("🎙 \(liveAudioServerVersionString)") + log(" Input : \(config.channels == 1 ? "Mono" : "Stereo") PCM, \(config.sampleRate) Hz, 16-bit signed via \(config.inputSource)") + if config.enableMP3 { log(" MP3 : \(config.mp3Bitrate) kbps → \(config.mountMP3)") } + if config.enableAAC { log(" AAC : \(config.aacBitrate / 1000) kbps → \(config.mountM4A)") } + if config.enableHLS { log(" HLS : AAC playlist → \(config.mountHLSIndex)") } + log(" Port : \(config.port)") + if let tlsPort = config.tlsPort { + log(" TLS : enabled on port \(tlsPort)") + } + if let bindHost = config.bindHost { + log(" Bind : \(bindHost) (listeners restricted to this address)") + } + if let acl = config.allowedClientIPs, !acl.allowAll { + log(" ACL : \(acl.matchers.count) allow-list entr\(acl.matchers.count == 1 ? "y" : "ies") — only matching client IPs accepted") + } + if let user = config.httpAuthUser, config.httpAuthPassword != nil { + log(" Auth : HTTP Basic enabled (user=\(user), realm=\"\(config.httpAuthRealm)\")") + if config.tlsPort == nil { + log(" ⚠️ HTTP Basic credentials travel base64-encoded; enable --tls-port for non-localhost use.") + } + } + if let bname = config.bonjourName { + let scope = config.bonjourAdvertiseInputs ? "outputs + inputs" : "outputs only" + log(" Bonjour: \(bname) (\(scope))") + } + + let statsCollector = StatsCollector() + self.statsCollector = statsCollector + + let mp3Recorder: FileRecorder? = config.enableMP3 ? FileRecorder(format: .mp3) : nil + let aacRecorder: FileRecorder? = config.enableAAC ? FileRecorder(format: .m4a) : nil + do { + if let p = config.recordMP3Path { try mp3Recorder?.start(path: p) } + if let p = config.recordAACPath { try aacRecorder?.start(path: p) } + } catch { + throw LiveAudioServerError.recorderStartFailed("\(error)") + } + self.mp3Recorder = mp3Recorder + self.aacRecorder = aacRecorder + + let mp3Broadcaster = ChunkBroadcaster(format: .mp3, verbose: config.verbose, + onBroadcast: { data in + statsCollector.recordMP3Bytes(data.count) + mp3Recorder?.write(data) + }) + let m4aBroadcaster = ChunkBroadcaster(format: .m4a, verbose: config.verbose, + onBroadcast: { data in + statsCollector.recordAACBytes(data.count) + aacRecorder?.write(data) + }) + self.mp3Broadcaster = mp3Broadcaster + self.m4aBroadcaster = m4aBroadcaster + + let hlsSegmenter: HLSSegmenter? = config.enableHLS + ? HLSSegmenter(sampleRate: config.sampleRate, + segmentDurationTarget: config.hlsSegmentDuration, + maxSegmentCount: config.hlsPlaylistWindowSize, + segmentPathPrefix: config.mountHLSSegmentPrefix) + : nil + self.hlsSegmenter = hlsSegmenter + + do { + if config.enableMP3 { + let enc = MP3Encoder(config: config, output: mp3Broadcaster) + try enc.start() + self.mp3Encoder = enc + } + if config.enableAAC || config.enableHLS { + let enc = AACEncoder(config: config, output: m4aBroadcaster, hlsSegmenter: hlsSegmenter) + try enc.start() + self.aacEncoder = enc + } + } catch { + throw LiveAudioServerError.encoderStartFailed("\(error)") + } + + let pcmBroadcaster = PCMBroadcaster() + if let mp3Encoder { + _ = pcmBroadcaster.addConsumer { samples in mp3Encoder.encode(samples: samples) } + } + if let aacEncoder { + _ = pcmBroadcaster.addConsumer { samples in aacEncoder.encode(samples: samples) } + } + self.pcmBroadcaster = pcmBroadcaster + + let nowPlayingStore = NowPlayingStore() + self.nowPlayingStore = nowPlayingStore + + let httpServer = HTTPServer(config: config, + mp3Broadcaster: mp3Broadcaster, + m4aBroadcaster: m4aBroadcaster, + hlsSegmenter: hlsSegmenter, + nowPlayingStore: nowPlayingStore, + mp3Recorder: mp3Recorder, + aacRecorder: aacRecorder, + tlsIdentity: tlsIdentity) + do { + try httpServer.start() + } catch { + throw LiveAudioServerError.httpServerStartFailed("\(error)") + } + self.httpServer = httpServer + + let bonjourPublisher = BonjourPublisher() + if let bname = config.bonjourName { + bonjourPublisher.publishCustomOutput(name: bname, config: config) + if config.bonjourAdvertiseInputs { + bonjourPublisher.publishInputs(name: bname, config: config) + } + } + self.bonjourPublisher = bonjourPublisher + + let pcmReader = PCMReader(config: config, broadcaster: pcmBroadcaster) + self.pcmReader = pcmReader + + // SIGPIPE: prevent the process from dying when a client disconnects + // mid-stream. Setting it here keeps host apps from having to know. + signal(SIGPIPE, SIG_IGN) + + let readerThread = Thread { [weak self] in + pcmReader.run() + guard let self else { return } + self.mp3Encoder?.stop() + self.aacEncoder?.stop() + if self.config.keepAliveOnInputEnd { + log("Input ended. Encoders stopped; HTTP server remains available because --keep-alive is enabled.") + return + } + log("All encoders stopped.") + } + readerThread.name = "stdin-pcm-reader" + readerThread.qualityOfService = .userInteractive + readerThread.start() + self.readerThread = readerThread + + if config.statsIntervalSeconds > 0 { + let interval = DispatchTimeInterval.seconds(config.statsIntervalSeconds) + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now() + interval, repeating: interval) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard let stats = self.statsCollector, + let mp3 = self.mp3Broadcaster, + let m4a = self.m4aBroadcaster else { return } + log(formatStatsLine(snapshot: stats.snapshot(), + mp3Clients: mp3.clientCount, + aacClients: m4a.clientCount)) + } + timer.resume() + self.statsTimer = timer + } + + stateLock.lock() + _isRunning = true + stateLock.unlock() + } + + /// Graceful shutdown: stop accepting new HTTP clients, flush encoders, + /// stop recordings, retire Bonjour. Idempotent — calling on a stopped + /// instance is a no-op. + public func stop() async { + stateLock.lock() + if !_isRunning { + stateLock.unlock() + return + } + _isRunning = false + stateLock.unlock() + + statsTimer?.cancel() + statsTimer = nil + + // 1. Stop accepting new HTTP clients and close existing ones first + // so subsequent encoder flushes don't pile bytes onto dying sockets. + httpServer?.stop() + // 2. Stop the PCM reader (releases the input socket / fd) and the + // encoders (flushes lame and AudioConverter). + pcmReader?.stop() + mp3Encoder?.stop() + aacEncoder?.stop() + // 3. Stop any active recordings so their trailing bytes flush. + mp3Recorder?.stop() + aacRecorder?.stop() + // 4. Stop Bonjour publishers so clients see the service vanish. + bonjourPublisher?.stopAll() + // 5. Give in-flight `NWConnection.send` completions a moment to fire + // on their own queues. + try? await Task.sleep(nanoseconds: 200_000_000) + // Drain the async log queue so the final line actually reaches stderr. + logQueue.sync {} + + // Release references — next start() rebuilds everything fresh. + statsCollector = nil + mp3Recorder = nil + aacRecorder = nil + mp3Broadcaster = nil + m4aBroadcaster = nil + hlsSegmenter = nil + mp3Encoder = nil + aacEncoder = nil + pcmBroadcaster = nil + nowPlayingStore = nil + httpServer = nil + bonjourPublisher = nil + pcmReader = nil + readerThread = nil + } +} diff --git a/Sources/LiveAudioServer/MP3Encoder.swift b/Sources/LiveAudioServerCore/MP3Encoder.swift similarity index 100% rename from Sources/LiveAudioServer/MP3Encoder.swift rename to Sources/LiveAudioServerCore/MP3Encoder.swift diff --git a/Sources/LiveAudioServer/NowPlaying.swift b/Sources/LiveAudioServerCore/NowPlaying.swift similarity index 100% rename from Sources/LiveAudioServer/NowPlaying.swift rename to Sources/LiveAudioServerCore/NowPlaying.swift diff --git a/Sources/LiveAudioServer/PCMSource.swift b/Sources/LiveAudioServerCore/PCMSource.swift similarity index 100% rename from Sources/LiveAudioServer/PCMSource.swift rename to Sources/LiveAudioServerCore/PCMSource.swift diff --git a/Sources/LiveAudioServerCore/Placeholder.swift b/Sources/LiveAudioServerCore/Placeholder.swift deleted file mode 100644 index be58e79..0000000 --- a/Sources/LiveAudioServerCore/Placeholder.swift +++ /dev/null @@ -1,10 +0,0 @@ -// LiveAudioServer — https://github.com/dsward2/LiveAudioServer -// -// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US -// Licensed under the Apache License, Version 2.0. -// -// Placeholder so the new `LiveAudioServerCore` library target has at least -// one source file in this transitional commit. Will be deleted as the real -// sources move in from Sources/LiveAudioServer/. - -import Foundation diff --git a/Sources/LiveAudioServer/Recorder.swift b/Sources/LiveAudioServerCore/Recorder.swift similarity index 100% rename from Sources/LiveAudioServer/Recorder.swift rename to Sources/LiveAudioServerCore/Recorder.swift diff --git a/Sources/LiveAudioServer/StatsCollector.swift b/Sources/LiveAudioServerCore/StatsCollector.swift similarity index 100% rename from Sources/LiveAudioServer/StatsCollector.swift rename to Sources/LiveAudioServerCore/StatsCollector.swift diff --git a/Sources/LiveAudioServer/TLSIdentity.swift b/Sources/LiveAudioServerCore/TLSIdentity.swift similarity index 100% rename from Sources/LiveAudioServer/TLSIdentity.swift rename to Sources/LiveAudioServerCore/TLSIdentity.swift diff --git a/Sources/LiveAudioServer/Version.swift b/Sources/LiveAudioServerCore/Version.swift similarity index 89% rename from Sources/LiveAudioServer/Version.swift rename to Sources/LiveAudioServerCore/Version.swift index 601324d..5a69456 100644 --- a/Sources/LiveAudioServer/Version.swift +++ b/Sources/LiveAudioServerCore/Version.swift @@ -20,20 +20,20 @@ import Foundation -let liveAudioServerVersion = "0.1.2" +public let liveAudioServerVersion = "0.1.2" /// Short git SHA stamped into the build. `"dev"` during day-to-day development; /// updated to the actual `git rev-parse --short HEAD` value at release-tag time. -let liveAudioServerGitSHA = "0.1.2" +public let liveAudioServerGitSHA = "0.1.2" /// "LiveAudioServer 0.1.2 (sha)" — used by `--version` and the startup banner. -var liveAudioServerVersionString: String { +public var liveAudioServerVersionString: String { return "LiveAudioServer \(liveAudioServerVersion) (\(liveAudioServerGitSHA))" } /// Brief copyright / license / repository notice shown by `--version` and at /// the top of `--help`. Keep it short — humans skim CLI banners. -let liveAudioServerNotice = """ +public let liveAudioServerNotice = """ Copyright (c)2026 by Douglas Ward - Conway, Arkansas US Licensed under the Apache License, Version 2.0 https://github.com/dsward2/LiveAudioServer diff --git a/Tests/LiveAudioServerTests/ADTSHeaderTests.swift b/Tests/LiveAudioServerTests/ADTSHeaderTests.swift index 412a470..9a52f02 100644 --- a/Tests/LiveAudioServerTests/ADTSHeaderTests.swift +++ b/Tests/LiveAudioServerTests/ADTSHeaderTests.swift @@ -4,7 +4,7 @@ // Licensed under the Apache License, Version 2.0. import Testing -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("ADTS header") struct ADTSHeaderTests { diff --git a/Tests/LiveAudioServerTests/CLIParseTests.swift b/Tests/LiveAudioServerTests/CLIParseTests.swift index 5c3cb96..c1c83c7 100644 --- a/Tests/LiveAudioServerTests/CLIParseTests.swift +++ b/Tests/LiveAudioServerTests/CLIParseTests.swift @@ -5,7 +5,7 @@ import Foundation import Testing -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("CLI parsing") struct CLIParseTests { diff --git a/Tests/LiveAudioServerTests/ConfigFileTests.swift b/Tests/LiveAudioServerTests/ConfigFileTests.swift index 1ac672f..e727b83 100644 --- a/Tests/LiveAudioServerTests/ConfigFileTests.swift +++ b/Tests/LiveAudioServerTests/ConfigFileTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("Config file loading and merge") struct ConfigFileTests { diff --git a/Tests/LiveAudioServerTests/FillerGeneratorTests.swift b/Tests/LiveAudioServerTests/FillerGeneratorTests.swift index 6692570..7dd7e5b 100644 --- a/Tests/LiveAudioServerTests/FillerGeneratorTests.swift +++ b/Tests/LiveAudioServerTests/FillerGeneratorTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("Filler generator") struct FillerGeneratorTests { diff --git a/Tests/LiveAudioServerTests/HLSSegmenterTests.swift b/Tests/LiveAudioServerTests/HLSSegmenterTests.swift index 016a1cc..cca963d 100644 --- a/Tests/LiveAudioServerTests/HLSSegmenterTests.swift +++ b/Tests/LiveAudioServerTests/HLSSegmenterTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("HLS segmenter") struct HLSSegmenterTests { diff --git a/Tests/LiveAudioServerTests/IPACLTests.swift b/Tests/LiveAudioServerTests/IPACLTests.swift index 53637ee..3a24fcf 100644 --- a/Tests/LiveAudioServerTests/IPACLTests.swift +++ b/Tests/LiveAudioServerTests/IPACLTests.swift @@ -6,7 +6,7 @@ import Testing import Foundation import Network -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("IPACL parsing and matching") struct IPACLTests { diff --git a/Tests/LiveAudioServerTests/NowPlayingTests.swift b/Tests/LiveAudioServerTests/NowPlayingTests.swift index 5827ade..c7e7ff5 100644 --- a/Tests/LiveAudioServerTests/NowPlayingTests.swift +++ b/Tests/LiveAudioServerTests/NowPlayingTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("NowPlaying store and Codable") struct NowPlayingTests { diff --git a/Tests/LiveAudioServerTests/PCMInputSourceTests.swift b/Tests/LiveAudioServerTests/PCMInputSourceTests.swift index ead8233..66ea06f 100644 --- a/Tests/LiveAudioServerTests/PCMInputSourceTests.swift +++ b/Tests/LiveAudioServerTests/PCMInputSourceTests.swift @@ -4,7 +4,7 @@ // Licensed under the Apache License, Version 2.0. import Testing -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("PCMInputSource formatting") struct PCMInputSourceTests { diff --git a/Tests/LiveAudioServerTests/RecorderTests.swift b/Tests/LiveAudioServerTests/RecorderTests.swift index 8b9ee00..76c5814 100644 --- a/Tests/LiveAudioServerTests/RecorderTests.swift +++ b/Tests/LiveAudioServerTests/RecorderTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("FileRecorder state machine") struct RecorderTests { diff --git a/Tests/LiveAudioServerTests/StatsCollectorTests.swift b/Tests/LiveAudioServerTests/StatsCollectorTests.swift index 43ffd34..53d9cbf 100644 --- a/Tests/LiveAudioServerTests/StatsCollectorTests.swift +++ b/Tests/LiveAudioServerTests/StatsCollectorTests.swift @@ -5,7 +5,7 @@ import Testing import Foundation -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("StatsCollector and formatting") struct StatsCollectorTests { diff --git a/Tests/LiveAudioServerTests/TPDFDitherTests.swift b/Tests/LiveAudioServerTests/TPDFDitherTests.swift index 3a19791..da943d7 100644 --- a/Tests/LiveAudioServerTests/TPDFDitherTests.swift +++ b/Tests/LiveAudioServerTests/TPDFDitherTests.swift @@ -4,7 +4,7 @@ // Licensed under the Apache License, Version 2.0. import Testing -@testable import LiveAudioServer +@testable import LiveAudioServerCore @Suite("TPDF dither generator") struct TPDFDitherTests { From a05fbf8d0339dc859a3cb17ae9ee286efe3733a6 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 15:44:53 -0500 Subject: [PATCH 03/10] Route library log output through a configurable logger sink. Adds LiveAudioServerLogger protocol with SilentLogger and StderrLogger implementations, plus a process-wide LiveAudioServerLogging.logger sink. The library's log() function now hands every formatted line to that sink instead of calling fputs(stderr) directly. The default logger is SilentLogger so a SwiftPM consumer never gets stderr output unless they opt in. The CLI shim installs StderrLogger at startup, preserving identical CLI output. Co-Authored-By: Claude Opus 4.7 --- .../LiveAudioServer/LiveAudioServerApp.swift | 4 ++ Sources/LiveAudioServerCore/Config.swift | 9 ++-- Sources/LiveAudioServerCore/Logger.swift | 50 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 Sources/LiveAudioServerCore/Logger.swift diff --git a/Sources/LiveAudioServer/LiveAudioServerApp.swift b/Sources/LiveAudioServer/LiveAudioServerApp.swift index a05ac25..2e930d6 100644 --- a/Sources/LiveAudioServer/LiveAudioServerApp.swift +++ b/Sources/LiveAudioServer/LiveAudioServerApp.swift @@ -153,6 +153,10 @@ func printUsage() { @main struct LiveAudioServerApp { static func main() { + // The library defaults to a silent logger so host apps don't get + // stderr output they didn't ask for. The CLI absolutely wants stderr. + LiveAudioServerLogging.logger = StderrLogger() + let argv = Array(CommandLine.arguments.dropFirst()) let config: ServerConfig diff --git a/Sources/LiveAudioServerCore/Config.swift b/Sources/LiveAudioServerCore/Config.swift index dd97a3e..37c8479 100644 --- a/Sources/LiveAudioServerCore/Config.swift +++ b/Sources/LiveAudioServerCore/Config.swift @@ -193,13 +193,16 @@ enum AudioFormat: String, CaseIterable { public let logQueue = DispatchQueue(label: "log") +/// Internal logging entry point used throughout the library. Formats the +/// timestamp and hands the line to whichever `LiveAudioServerLogger` is +/// currently installed (defaults to `SilentLogger`). Never writes to +/// stdout/stderr directly — see `LiveAudioServerLogging.logger`. public func log(_ msg: String, verbose: Bool = false, config: ServerConfig? = nil) { if verbose, let cfg = config, !cfg.verbose { return } logQueue.async { - var ts = "" let fmt = DateFormatter() fmt.dateFormat = "HH:mm:ss.SSS" - ts = fmt.string(from: Date()) - fputs("[\(ts)] \(msg)\n", stderr) + let ts = fmt.string(from: Date()) + LiveAudioServerLogging.logger.log("[\(ts)] \(msg)") } } diff --git a/Sources/LiveAudioServerCore/Logger.swift b/Sources/LiveAudioServerCore/Logger.swift new file mode 100644 index 0000000..bb9b466 --- /dev/null +++ b/Sources/LiveAudioServerCore/Logger.swift @@ -0,0 +1,50 @@ +// LiveAudioServer — https://github.com/dsward2/LiveAudioServer +// +// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US +// Licensed under the Apache License, Version 2.0. + +// Sources/LiveAudioServerCore/Logger.swift +// Logger abstraction. The library never writes to stdout/stderr directly; +// every log line goes through `LiveAudioServerLogger.log(_:)`. The CLI shim +// installs a logger that prints to stderr (matching pre-refactor behavior), +// and a host app can install its own to forward into oslog, an in-app +// console, or /dev/null. + +import Foundation + +/// Sink for library log messages. Implementations are called from arbitrary +/// background queues — be thread-safe. +public protocol LiveAudioServerLogger: Sendable { + /// Called with one timestamped, fully formatted line (no trailing newline). + func log(_ message: String) +} + +/// Discards every log line. Use as the default when running inside a host app +/// that doesn't want any library output. +public struct SilentLogger: LiveAudioServerLogger { + public init() {} + public func log(_ message: String) {} +} + +/// Writes every log line + newline to `stderr`. Matches the pre-refactor +/// behavior; installed automatically by the CLI shim. +public struct StderrLogger: LiveAudioServerLogger { + public init() {} + public func log(_ message: String) { + fputs(message + "\n", stderr) + } +} + +/// Process-wide logger sink. Mutating it from multiple threads is safe; the +/// underlying lock serializes installs vs. log emission. Defaults to +/// `SilentLogger` so a library consumer never gets stderr output unless they +/// opt in. +public enum LiveAudioServerLogging { + private static let lock = NSLock() + private static var _logger: LiveAudioServerLogger = SilentLogger() + + public static var logger: LiveAudioServerLogger { + get { lock.lock(); defer { lock.unlock() }; return _logger } + set { lock.lock(); defer { lock.unlock() }; _logger = newValue } + } +} From ae05ef783abff6ade88fd89668e3c351200aefa6 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 15:46:57 -0500 Subject: [PATCH 04/10] Add library-API integration tests. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new tests under "LiveAudioServer public API" exercise the public façade the way a host SwiftUI app would: - LiveAudioServerConfig defaults match CLI defaults. - start() then GET /status.json then stop() round-trips cleanly, with isRunning flipping true → false at the boundaries. Encoders are disabled in this test to sidestep a libmp3lame assertion that fires when lame_encode_flush runs without any frames having been encoded (real flow always has frames). - stop() on a never-started server is a no-op. - Calling start() while already running throws .alreadyRunning. Co-Authored-By: Claude Opus 4.7 --- .../LiveAudioServerLibraryAPITests.swift | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift diff --git a/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift b/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift new file mode 100644 index 0000000..23c9eef --- /dev/null +++ b/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift @@ -0,0 +1,139 @@ +// LiveAudioServer — https://github.com/dsward2/LiveAudioServer +// +// Copyright (c)2026 by Douglas Ward - Conway, Arkansas US +// Licensed under the Apache License, Version 2.0. + +// Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift +// Drive the public `LiveAudioServer` API end-to-end the way a host app would: +// build a config, start the server on an ephemeral port, hit a real HTTP +// endpoint with URLSession, stop the server, assert clean shutdown. + +import Testing +import Foundation +@testable import LiveAudioServerCore + +@Suite("LiveAudioServer public API") +struct LiveAudioServerLibraryAPITests { + + /// Pick a high-numbered port unlikely to clash with anything else on the + /// test runner. Not 0 because the public API doesn't expose the bound + /// port back to callers — host apps choose their port up front. + private static func ephemeralPort() -> UInt16 { + UInt16.random(in: 49152...60000) + } + + @Test("Default config initializer matches CLI defaults") + func defaultsMatchCLI() { + let cfg = LiveAudioServerConfig() + #expect(cfg.port == 8080) + #expect(cfg.sampleRate == 48000) + #expect(cfg.channels == 2) + #expect(cfg.mp3Bitrate == 128) + #expect(cfg.aacBitrate == 128_000) + #expect(cfg.enableMP3 == true) + #expect(cfg.enableAAC == true) + #expect(cfg.enableHLS == true) + #expect(cfg.fillerMode == .silence) + #expect(cfg.fillerAfterMs == 500) + } + + @Test("Server lifecycle: start → status.json → stop → isRunning flips") + func startStatusStopRoundTrip() async throws { + // Bind to localhost only so we don't accidentally listen on the LAN + // during CI. Use stdin input so the server doesn't try to open a + // socket for PCM and clash with another test. + var cfg = LiveAudioServerConfig() + cfg.port = Self.ephemeralPort() + cfg.bindHost = "127.0.0.1" + cfg.bonjourName = nil // no Bonjour during tests + // Disable encoders during the lifecycle round-trip. lame_encode_flush + // asserts internally when no PCM frames have been encoded in the + // session — see "encoder lifecycle" note in the README. The HTTP + + // start/stop machinery we want to exercise here doesn't need them. + cfg.enableMP3 = false + cfg.enableAAC = false + cfg.enableHLS = false + + let server = LiveAudioServer(config: cfg) + #expect(server.isRunning == false) + + try await server.start() + #expect(server.isRunning == true) + defer { + // Belt-and-suspenders: if anything below throws, still stop. + let g = DispatchSemaphore(value: 0) + Task { await server.stop(); g.signal() } + g.wait() + } + + // Poll the status endpoint. NWListener.start() is asynchronous, so + // give it up to a second to become reachable. + let url = URL(string: "http://127.0.0.1:\(cfg.port)/status.json")! + var lastError: Error? + var bodyData: Data? + for _ in 0..<20 { + do { + let (data, response) = try await URLSession.shared.data(from: url) + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + bodyData = data + lastError = nil + break + } + } catch { + lastError = error + } + try? await Task.sleep(nanoseconds: 50_000_000) + } + if let lastError { + Issue.record("Could not reach /status.json on port \(cfg.port): \(lastError)") + return + } + guard let bodyData, + let json = try JSONSerialization.jsonObject(with: bodyData) as? [String: Any] + else { + Issue.record("status.json was empty or not JSON") + return + } + // Shape sanity-check: keys we documented on the public API. + #expect(json["mp3Clients"] != nil) + #expect(json["m4aClients"] != nil) + + await server.stop() + #expect(server.isRunning == false) + } + + @Test("stop() on a never-started server is a no-op") + func stopWithoutStartIsNoop() async { + let server = LiveAudioServer(config: LiveAudioServerConfig()) + await server.stop() + #expect(server.isRunning == false) + } + + @Test("start() while already running throws .alreadyRunning") + func doubleStartThrows() async throws { + var cfg = LiveAudioServerConfig() + cfg.port = Self.ephemeralPort() + cfg.bindHost = "127.0.0.1" + cfg.enableMP3 = false + cfg.enableAAC = false + cfg.enableHLS = false + + let server = LiveAudioServer(config: cfg) + try await server.start() + defer { + let g = DispatchSemaphore(value: 0) + Task { await server.stop(); g.signal() } + g.wait() + } + do { + try await server.start() + Issue.record("Expected .alreadyRunning to be thrown") + } catch let err as LiveAudioServerError { + if case .alreadyRunning = err { + // good + } else { + Issue.record("Unexpected error: \(err)") + } + } + } +} From 04ec66306203dfc58725a5f3665c11f56c21d58f Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 15:51:29 -0500 Subject: [PATCH 05/10] Add README \"Use as a Swift Package\" section. Documents the new LiveAudioServerCore library product: SwiftPM dependency snippet, public API usage example (config + start/stop), and integrator notes (multi-instance, SIGPIPE, encoder lifecycle gotcha, which module to import). Co-Authored-By: Claude Opus 4.7 --- README.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/README.md b/README.md index f89284a..abeeb8f 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,73 @@ subsequent run. --- +## Use as a Swift Package + +LiveAudioServer also ships as a SwiftPM library product so a host macOS app +(SwiftUI, AppKit, whatever) can start and stop the server in-process without +spawning the CLI. Add the dependency to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/dsward2/LiveAudioServer.git", from: "0.1.2"), +], +targets: [ + .target( + name: "YourHostApp", + dependencies: [ + .product(name: "LiveAudioServerCore", package: "LiveAudioServer"), + ] + ), +] +``` + +Then build a config, install a logger (or skip — the library defaults to +silent), and drive the lifecycle from Swift Concurrency: + +```swift +import LiveAudioServerCore + +// Optional: see library log lines. Default is a SilentLogger so library +// consumers never get unexpected stderr output. +LiveAudioServerLogging.logger = StderrLogger() // or your own LiveAudioServerLogger + +var cfg = LiveAudioServerConfig() // = ServerConfig — CLI-default values +cfg.port = 9000 +cfg.bindHost = "127.0.0.1" +cfg.inputSource = .udp(port: 7355) // Gqrx UDP, for example +cfg.fillerMode = .tone +cfg.bonjourName = "My Radio" +// …override any other fields here + +let server = LiveAudioServer(config: cfg) +try await server.start() +// /stream.mp3, /stream.m4a, /hls/index.m3u8, and / are live until stop() +// returns. start() throws LiveAudioServerError on synchronous setup failures +// (bind, TLS load, encoder init). + +// Later, when the user toggles the off switch: +await server.stop() +``` + +The same package vends both products, so depending on the library does not +pull in or build the executable. + +Notes for integrators: + +- Multiple `LiveAudioServer` instances can coexist in one process, but they + share one global logger sink (`LiveAudioServerLogging.logger`). +- `signal(SIGPIPE, SIG_IGN)` is set inside `start()` so a client disconnect + won't kill the host process. +- Encoder lifecycle: do not call `start()` then `stop()` without the server + having processed at least some PCM. `lame_encode_flush` asserts internally + when the session received no audio. In practice this only matters for + unit tests; live PCM always produces frames before shutdown. +- `LiveAudioServerCore` is `import`able from any module that depends on the + library product. `import LiveAudioServer` (the executable target) is not + meant for external consumption. + +--- + ## Build from source ### Requirements From cf7cbf2612f61e6127061ad266f086bff20ca97b Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 22:09:17 -0500 Subject: [PATCH 06/10] Fix release.sh capturing progress echo into bin-path variable. The build_arch helper echoed its progress line on stdout alongside the swift build --show-bin-path output, so $(build_arch arm64) captured both and the subsequent binary-existence check looked at a malformed path. Redirect the progress echo to stderr so only the bin path lands in the captured stdout. Co-Authored-By: Claude Opus 4.7 --- scripts/release.sh | 101 +++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 63 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 19c4ceb..3b2f34f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -65,10 +65,14 @@ fi REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "${REPO_ROOT}" -# Extract version from Sources/LiveAudioServer/Version.swift -VERSION="$(awk -F'"' '/^let liveAudioServerVersion / {print $2}' Sources/LiveAudioServer/Version.swift)" +# Extract version from Sources/LiveAudioServerCore/Version.swift. The `public` +# prefix landed when Version.swift moved to the library target; the regex +# accepts both `let` and `public let` so the script still parses cleanly if +# the visibility ever flips back. +VERSION_FILE="Sources/LiveAudioServerCore/Version.swift" +VERSION="$(awk -F'"' '/^(public )?let liveAudioServerVersion / {print $2; exit}' "${VERSION_FILE}")" if [[ -z "${VERSION}" ]]; then - echo "ERROR: could not parse version from Version.swift" >&2 + echo "ERROR: could not parse version from ${VERSION_FILE}" >&2 exit 1 fi @@ -80,74 +84,45 @@ ZIP_PATH="${STAGE_ROOT}/${RELEASE_DIR_NAME}-macos-universal.zip" echo "==> LiveAudioServer ${VERSION} — release build" -# Multi-arch SwiftPM builds (--arch arm64 --arch x86_64) require XCBuild from -# a full Xcode installation. Command Line Tools alone ships no XCBuild and -# fails with: "xcbuild executable at .../XCBuild.framework/.../xcbuild does -# not exist or is not executable". Detect that and either auto-resolve to -# Xcode.app or fail with a clear hint before we touch anything on disk. -ensure_xcode_developer_dir() { - local current - current="$(xcode-select -p 2>/dev/null || true)" - if [[ -n "${current}" && -e "${current}/../SharedFrameworks/XCBuild.framework" ]]; then - return 0 - fi - local candidates=( - "/Applications/Xcode.app/Contents/Developer" - "${HOME}/Applications/Xcode.app/Contents/Developer" - ) - local c - for c in "${candidates[@]}"; do - if [[ -e "${c}/../SharedFrameworks/XCBuild.framework" ]]; then - export DEVELOPER_DIR="${c}" - echo " DEVELOPER_DIR=${c} (auto-detected; xcode-select points at CLT)" - return 0 - fi - done - local found - found="$(mdfind 'kMDItemCFBundleIdentifier == "com.apple.dt.Xcode"' 2>/dev/null | head -1)" - if [[ -n "${found}" && -e "${found}/Contents/SharedFrameworks/XCBuild.framework" ]]; then - export DEVELOPER_DIR="${found}/Contents/Developer" - echo " DEVELOPER_DIR=${DEVELOPER_DIR} (auto-detected via Spotlight)" - return 0 - fi - cat >&2 <&2 - exit 1 -fi +# Build each architecture in its own llbuild invocation via `--triple`, then +# `lipo` the two binaries into a universal Mach-O. The alternative +# `--arch arm64 --arch x86_64` routes through XCBuild, where the binary +# target's libmp3lame.a search path silently fails to propagate to the +# LiveAudioServerCore library's partial-link step (-L is dropped while +# -lmp3lame survives), producing `ld: library not found for -lmp3lame`. +# Per-arch llbuild + lipo sidesteps that path entirely and is also faster. +echo "[2/8] Building per-arch binaries and lipo-ing universal" +build_arch() { + local arch="$1" + local triple="${arch}-apple-macosx13.0" + # Progress goes to stderr so the captured stdout is just the bin path. + echo " • ${arch} (${triple})" >&2 + swift build -c release --triple "${triple}" >/dev/null + swift build -c release --triple "${triple}" --show-bin-path +} +ARM64_DIR="$(build_arch arm64)" +X86_64_DIR="$(build_arch x86_64)" +ARM64_BIN="${ARM64_DIR}/${NAME}" +X86_64_BIN="${X86_64_DIR}/${NAME}" +for b in "${ARM64_BIN}" "${X86_64_BIN}"; do + if [[ ! -x "${b}" ]]; then + echo "ERROR: expected binary at ${b} not found" >&2 + exit 1 + fi +done -# Sanity-check the architectures. +# Combine into a universal Mach-O before signing. +SRC_BIN="${STAGE_ROOT}/${NAME}.universal" +lipo -create "${ARM64_BIN}" "${X86_64_BIN}" -output "${SRC_BIN}" ARCHS="$(lipo -archs "${SRC_BIN}" 2>/dev/null || true)" -echo " Built: ${SRC_BIN}" +echo " Universal binary: ${SRC_BIN}" echo " Archs: ${ARCHS}" if ! [[ "${ARCHS}" == *"arm64"* && "${ARCHS}" == *"x86_64"* ]]; then - echo "ERROR: binary is not universal (got: ${ARCHS})" >&2 + echo "ERROR: lipo result is not universal (got: ${ARCHS})" >&2 exit 1 fi From af57130cfe5e42466f49ce69b873cf2b9e93636d Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 22:12:13 -0500 Subject: [PATCH 07/10] Make LiveAudioServer and PCMReader Sendable. Swap the NSLock guarding LiveAudioServer's running flag for a serial DispatchQueue, because NSLock.lock()/unlock() trip a Swift 6 availability warning inside async functions while DispatchQueue.sync is async-safe and gives the same mutual exclusion. With the lock replaced, mark both LiveAudioServer and PCMReader @unchecked Sendable so host apps can hold them across actor boundaries. Co-Authored-By: Claude Opus 4.7 --- .../LiveAudioServerCore/LiveAudioServer.swift | 40 ++++++++++--------- Sources/LiveAudioServerCore/PCMSource.swift | 2 +- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/Sources/LiveAudioServerCore/LiveAudioServer.swift b/Sources/LiveAudioServerCore/LiveAudioServer.swift index 0c57a8e..6ca0f2d 100644 --- a/Sources/LiveAudioServerCore/LiveAudioServer.swift +++ b/Sources/LiveAudioServerCore/LiveAudioServer.swift @@ -50,11 +50,22 @@ public enum LiveAudioServerError: Error, CustomStringConvertible { /// on a Network.framework queue. `start()` returns once setup is done, with /// the server running in the background. Multiple instances can coexist in /// the same process — each owns its own listeners, encoders, and reader. -public final class LiveAudioServer { +public final class LiveAudioServer: @unchecked Sendable { private let config: ServerConfig - private let stateLock = NSLock() + /// Serializes mutations to `_isRunning` and the component slots. Used + /// instead of NSLock because NSLock.lock()/unlock() carry a Swift 6 + /// availability warning inside async functions; DispatchQueue.sync is + /// async-safe and gives us the same mutual exclusion. + private let stateQueue = DispatchQueue(label: "LiveAudioServer.state") private var _isRunning = false + /// Run a block with exclusive access to the component slots. The closure + /// is rethrowing so callers can throw from inside the critical section + /// (e.g. the `.alreadyRunning` check). + private func withState(_ body: () throws -> T) rethrows -> T { + try stateQueue.sync(execute: body) + } + // Components held for the lifetime of one start/stop cycle. private var statsCollector: StatsCollector? private var mp3Recorder: FileRecorder? @@ -77,20 +88,16 @@ public final class LiveAudioServer { } public var isRunning: Bool { - stateLock.lock(); defer { stateLock.unlock() } - return _isRunning + withState { _isRunning } } /// Wire up encoders, broadcasters, HTTP listener, Bonjour, and the PCM /// reader. Returns once all components are running. The reader / listener /// continue on background queues until `stop()` is called. public func start() async throws { - stateLock.lock() - if _isRunning { - stateLock.unlock() - throw LiveAudioServerError.alreadyRunning + try withState { + if _isRunning { throw LiveAudioServerError.alreadyRunning } } - stateLock.unlock() var tlsIdentity: sec_identity_t? = nil if let identityPath = config.tlsIdentityPath { @@ -253,22 +260,19 @@ public final class LiveAudioServer { self.statsTimer = timer } - stateLock.lock() - _isRunning = true - stateLock.unlock() + withState { _isRunning = true } } /// Graceful shutdown: stop accepting new HTTP clients, flush encoders, /// stop recordings, retire Bonjour. Idempotent — calling on a stopped /// instance is a no-op. public func stop() async { - stateLock.lock() - if !_isRunning { - stateLock.unlock() - return + let wasRunning = withState { () -> Bool in + let was = _isRunning + _isRunning = false + return was } - _isRunning = false - stateLock.unlock() + if !wasRunning { return } statsTimer?.cancel() statsTimer = nil diff --git a/Sources/LiveAudioServerCore/PCMSource.swift b/Sources/LiveAudioServerCore/PCMSource.swift index 4d06c40..1cf8a6f 100644 --- a/Sources/LiveAudioServerCore/PCMSource.swift +++ b/Sources/LiveAudioServerCore/PCMSource.swift @@ -155,7 +155,7 @@ struct FillerGenerator { /// Continuously reads raw 16-bit little-endian interleaved PCM from stdin, UDP, /// or TCP and broadcasts frames to all registered consumers. -final class PCMReader { +final class PCMReader: @unchecked Sendable { private let config: ServerConfig private let broadcaster: PCMBroadcaster private var isRunning = false From b3e9409024e808e17de7d09480b0283f72c3b5a7 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Mon, 8 Jun 2026 22:12:18 -0500 Subject: [PATCH 08/10] Bump version to 0.1.3. Updates Version.swift and the README's SwiftPM dependency snippet and sample Bonjour TXT records to 0.1.3. Co-Authored-By: Claude Opus 4.7 --- README.md | 6 +++--- Sources/LiveAudioServerCore/Version.swift | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index abeeb8f..2dcc8fc 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ spawning the CLI. Add the dependency to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/dsward2/LiveAudioServer.git", from: "0.1.2"), + .package(url: "https://github.com/dsward2/LiveAudioServer.git", from: "0.1.3"), ], targets: [ .target( @@ -856,7 +856,7 @@ paths and version. `path=/` is the conventional Safari Bonjour-bookmark key; `status=/` is the same path under an explicit name for non-Safari clients: ``` -ver=0.1.2 +ver=0.1.3 path=/ status=/ mp3=/stream.mp3 @@ -869,7 +869,7 @@ details, so a LiveAudioServer-aware client can enumerate all streams in a single Bonjour lookup without hitting `/status.json`: ``` -ver=0.1.2 +ver=0.1.3 path=/ status=/ rate=48000 diff --git a/Sources/LiveAudioServerCore/Version.swift b/Sources/LiveAudioServerCore/Version.swift index 5a69456..4db2855 100644 --- a/Sources/LiveAudioServerCore/Version.swift +++ b/Sources/LiveAudioServerCore/Version.swift @@ -20,13 +20,13 @@ import Foundation -public let liveAudioServerVersion = "0.1.2" +public let liveAudioServerVersion = "0.1.3" /// Short git SHA stamped into the build. `"dev"` during day-to-day development; /// updated to the actual `git rev-parse --short HEAD` value at release-tag time. -public let liveAudioServerGitSHA = "0.1.2" +public let liveAudioServerGitSHA = "0.1.3" -/// "LiveAudioServer 0.1.2 (sha)" — used by `--version` and the startup banner. +/// "LiveAudioServer 0.1.3 (sha)" — used by `--version` and the startup banner. public var liveAudioServerVersionString: String { return "LiveAudioServer \(liveAudioServerVersion) (\(liveAudioServerGitSHA))" } From b56ca29c843e2ac2a7ff7b6ee0976938da6e7f31 Mon Sep 17 00:00:00 2001 From: dsward2 Date: Tue, 9 Jun 2026 07:19:55 -0500 Subject: [PATCH 09/10] Allow host apps to inject a pre-loaded TLS identity. Adds ServerConfig.tlsIdentity (sec_identity_t) so embedders can share one already-loaded identity across multiple listeners instead of re-reading the .p12 from disk. The CLI continues to use tlsIdentityPath/tlsPassword; the new field takes precedence when set. Makes loadTLSIdentity and TLSIdentityError public so hosts can reuse the existing loader. Co-Authored-By: Claude Opus 4.7 --- README.md | 35 ++++++ Sources/LiveAudioServerCore/CLIParse.swift | 4 + Sources/LiveAudioServerCore/Config.swift | 7 ++ .../LiveAudioServerCore/LiveAudioServer.swift | 26 ++-- Sources/LiveAudioServerCore/TLSIdentity.swift | 6 +- .../LiveAudioServerLibraryAPITests.swift | 116 ++++++++++++++++++ 6 files changed, 184 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2dcc8fc..a5ebcb0 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,41 @@ await server.stop() The same package vends both products, so depending on the library does not pull in or build the executable. +### Embedding in a host app + +If your host app already terminates TLS for its own listeners (for example, a +SwiftUI admin UI), you can hand LiveAudioServer the same pre-loaded +`sec_identity_t` so users only have to trust one self-signed certificate. +`ServerConfig.tlsIdentity` is the embed path; when set, it takes precedence +over `tlsIdentityPath` / `tlsPassword`: + +```swift +import LiveAudioServerCore +import Network + +// Your host app loads (or generates and persists in App Support) the .p12 +// once, then reuses the resulting identity for every listener it owns. +// `loadTLSIdentity` is public so the host doesn't have to reimplement +// PKCS#12 → sec_identity_t. +let identity = try loadTLSIdentity( + p12Path: identityFileURL.path, + password: identityPassword +) + +var cfg = LiveAudioServerConfig() +cfg.port = 8080 +cfg.tlsPort = 8443 +cfg.tlsIdentity = identity // injected — no path/password needed +cfg.inputSource = .udp(port: 7355) + +let server = LiveAudioServer(config: cfg) +try await server.start() +``` + +`loadTLSIdentity(p12Path:password:)` and the `TLSIdentityError` enum it +throws are both public, so host apps that want to load their own `.p12` from +disk can share the same loader the CLI uses. + Notes for integrators: - Multiple `LiveAudioServer` instances can coexist in one process, but they diff --git a/Sources/LiveAudioServerCore/CLIParse.swift b/Sources/LiveAudioServerCore/CLIParse.swift index 84a2ed4..0cf58c3 100644 --- a/Sources/LiveAudioServerCore/CLIParse.swift +++ b/Sources/LiveAudioServerCore/CLIParse.swift @@ -274,6 +274,10 @@ public func parseCLI(_ args: [String]) -> CLIParseResult { } // Cross-flag validation. + // Note: these checks only govern CLI inputs. Programmatic embedders may + // instead assign `ServerConfig.tlsIdentity` directly (a pre-loaded + // `sec_identity_t`) and skip `tlsIdentityPath` entirely; the library's + // identity-resolution step honors that injected value. if config.tlsPort != nil && config.tlsIdentityPath == nil { return .error("--tls-port requires --tls-identity") } diff --git a/Sources/LiveAudioServerCore/Config.swift b/Sources/LiveAudioServerCore/Config.swift index 37c8479..06685bb 100644 --- a/Sources/LiveAudioServerCore/Config.swift +++ b/Sources/LiveAudioServerCore/Config.swift @@ -18,6 +18,7 @@ // Configuration, shared types, and constants. import Foundation +import Network // MARK: - Server Configuration @@ -100,6 +101,12 @@ public struct ServerConfig { public var tlsPort: UInt16? = nil public var tlsIdentityPath: String? = nil public var tlsPassword: String? = nil + /// Pre-loaded TLS identity for programmatic embedders. When set, it takes + /// precedence over `tlsIdentityPath` / `tlsPassword` and the path/password + /// fields are ignored. The CLI continues to populate `tlsIdentityPath` / + /// `tlsPassword`; the identity field is the embed path so a host app can + /// share one already-loaded `sec_identity_t` across multiple listeners. + public var tlsIdentity: sec_identity_t? = nil /// If non-nil, HTTP/HTTPS listeners bind to this specific local address /// instead of all interfaces. Use "127.0.0.1" for IPv4 localhost only, /// "::1" for IPv6 localhost only, or an explicit LAN address. diff --git a/Sources/LiveAudioServerCore/LiveAudioServer.swift b/Sources/LiveAudioServerCore/LiveAudioServer.swift index 6ca0f2d..6c3c97e 100644 --- a/Sources/LiveAudioServerCore/LiveAudioServer.swift +++ b/Sources/LiveAudioServerCore/LiveAudioServer.swift @@ -87,6 +87,20 @@ public final class LiveAudioServer: @unchecked Sendable { self.config = config } + /// Resolve the TLS identity that `start()` will hand to `HTTPServer`. + /// Precedence: an injected `config.tlsIdentity` wins; otherwise load + /// `config.tlsIdentityPath` from disk; otherwise no TLS identity. Exposed + /// (internal) so the lifecycle precedence is exercisable from tests. + static func resolveTLSIdentity(config: ServerConfig) throws -> sec_identity_t? { + if let injected = config.tlsIdentity { + return injected + } + if let identityPath = config.tlsIdentityPath { + return try loadTLSIdentity(p12Path: identityPath, password: config.tlsPassword) + } + return nil + } + public var isRunning: Bool { withState { _isRunning } } @@ -99,13 +113,11 @@ public final class LiveAudioServer: @unchecked Sendable { if _isRunning { throw LiveAudioServerError.alreadyRunning } } - var tlsIdentity: sec_identity_t? = nil - if let identityPath = config.tlsIdentityPath { - do { - tlsIdentity = try loadTLSIdentity(p12Path: identityPath, password: config.tlsPassword) - } catch { - throw LiveAudioServerError.tlsIdentityLoadFailed("\(error)") - } + let tlsIdentity: sec_identity_t? + do { + tlsIdentity = try Self.resolveTLSIdentity(config: config) + } catch { + throw LiveAudioServerError.tlsIdentityLoadFailed("\(error)") } log("🎙 \(liveAudioServerVersionString)") diff --git a/Sources/LiveAudioServerCore/TLSIdentity.swift b/Sources/LiveAudioServerCore/TLSIdentity.swift index 2558ada..7426bd4 100644 --- a/Sources/LiveAudioServerCore/TLSIdentity.swift +++ b/Sources/LiveAudioServerCore/TLSIdentity.swift @@ -22,13 +22,13 @@ import Foundation import Network import Security -enum TLSIdentityError: Error, CustomStringConvertible { +public enum TLSIdentityError: Error, CustomStringConvertible { case fileNotReadable(String) case importFailed(OSStatus) case emptyIdentity case secIdentityCreateFailed - var description: String { + public var description: String { switch self { case .fileNotReadable(let p): return "TLS identity file not readable: \(p)" @@ -51,7 +51,7 @@ enum TLSIdentityError: Error, CustomStringConvertible { /// Loads a PKCS#12 file from disk and returns a `sec_identity_t` suitable for /// `sec_protocol_options_set_local_identity`. No keychain side effects: the /// identity is returned in-memory only. -func loadTLSIdentity(p12Path: String, password: String?) throws -> sec_identity_t { +public func loadTLSIdentity(p12Path: String, password: String?) throws -> sec_identity_t { let url = URL(fileURLWithPath: p12Path) guard let data = try? Data(contentsOf: url) else { throw TLSIdentityError.fileNotReadable(p12Path) diff --git a/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift b/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift index 23c9eef..44a8937 100644 --- a/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift +++ b/Tests/LiveAudioServerTests/LiveAudioServerLibraryAPITests.swift @@ -10,6 +10,7 @@ import Testing import Foundation +import Network @testable import LiveAudioServerCore @Suite("LiveAudioServer public API") @@ -109,6 +110,121 @@ struct LiveAudioServerLibraryAPITests { #expect(server.isRunning == false) } + // MARK: - Injected TLS identity (programmatic embedders) + + /// Synthesize a fresh self-signed PKCS#12 in a temp dir using the system + /// `/usr/bin/openssl`, then load it through the now-public + /// `loadTLSIdentity`. Lets these tests exercise the real + /// `sec_identity_t` injection path without checking a binary fixture + /// into the repo. Returns nil (and the test should skip) if openssl is + /// unavailable or the toolchain produces a .p12 that SecPKCS12Import + /// doesn't accept on this host. + private static func makeTestTLSIdentity() -> (identity: sec_identity_t, p12Path: String)? { + let openssl = "/usr/bin/openssl" + guard FileManager.default.isExecutableFile(atPath: openssl) else { return nil } + + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent("LiveAudioServerTLS-\(UUID().uuidString)", isDirectory: true) + do { + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + } catch { + return nil + } + let keyPath = tmpDir.appendingPathComponent("key.pem").path + let certPath = tmpDir.appendingPathComponent("cert.pem").path + let p12Path = tmpDir.appendingPathComponent("identity.p12").path + let password = "test" + + func run(_ args: [String]) -> Bool { + let p = Process() + p.launchPath = openssl + p.arguments = args + p.standardOutput = Pipe() + p.standardError = Pipe() + do { + try p.run() + } catch { + return false + } + p.waitUntilExit() + return p.terminationStatus == 0 + } + + // Self-signed key + cert. + guard run([ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", keyPath, "-out", certPath, + "-days", "1", "-subj", "/CN=LiveAudioServerTest" + ]) else { return nil } + + // Package as PKCS#12. Force legacy ciphers so SecPKCS12Import on + // macOS 13 accepts the bundle regardless of the host's openssl + // version defaults. + guard run([ + "pkcs12", "-export", + "-inkey", keyPath, "-in", certPath, + "-out", p12Path, "-passout", "pass:\(password)", + "-name", "LiveAudioServerTest", + "-keypbe", "PBE-SHA1-3DES", + "-certpbe", "PBE-SHA1-3DES", + "-macalg", "SHA1" + ]) else { return nil } + + do { + let identity = try loadTLSIdentity(p12Path: p12Path, password: password) + return (identity, p12Path) + } catch { + return nil + } + } + + @Test("resolveTLSIdentity: injected identity satisfies config without tlsIdentityPath") + func injectedTLSIdentityIsSufficient() throws { + guard let fixture = Self.makeTestTLSIdentity() else { + // openssl missing or produced a .p12 SecPKCS12Import can't read; + // skip rather than fail the suite. + return + } + + var cfg = LiveAudioServerConfig() + cfg.tlsPort = Self.ephemeralPort() + cfg.tlsIdentity = fixture.identity + // Deliberately leave tlsIdentityPath / tlsPassword nil — the injected + // identity must be enough to produce a TLS-capable ServerConfig. + #expect(cfg.tlsIdentityPath == nil) + #expect(cfg.tlsPassword == nil) + + let resolved = try LiveAudioServer.resolveTLSIdentity(config: cfg) + #expect(resolved != nil) + } + + @Test("resolveTLSIdentity: injected identity wins over a bad tlsIdentityPath") + func injectedTLSIdentityWinsOverPath() throws { + guard let fixture = Self.makeTestTLSIdentity() else { + return + } + + var cfg = LiveAudioServerConfig() + cfg.tlsPort = Self.ephemeralPort() + cfg.tlsIdentity = fixture.identity + // Path is set but points to a file that definitely won't load. If the + // resolver were still going through the path branch, this would + // throw. The injected identity should win and the resolver should + // succeed. + cfg.tlsIdentityPath = "/nonexistent/path/to/identity.p12" + cfg.tlsPassword = "wrong" + + let resolved = try LiveAudioServer.resolveTLSIdentity(config: cfg) + #expect(resolved != nil) + } + + @Test("resolveTLSIdentity: no identity and no path returns nil") + func noTLSIdentityReturnsNil() throws { + let cfg = LiveAudioServerConfig() + let resolved = try LiveAudioServer.resolveTLSIdentity(config: cfg) + #expect(resolved == nil) + } + @Test("start() while already running throws .alreadyRunning") func doubleStartThrows() async throws { var cfg = LiveAudioServerConfig() From ee14ad55c40a7a8c29c7b4fffb5560ae9804a70e Mon Sep 17 00:00:00 2001 From: dsward2 Date: Tue, 9 Jun 2026 08:46:33 -0500 Subject: [PATCH 10/10] Updated SHA256 and version in Homebrew formula. --- Formula/liveaudioserver.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Formula/liveaudioserver.rb b/Formula/liveaudioserver.rb index c9ad9fa..25f4273 100644 --- a/Formula/liveaudioserver.rb +++ b/Formula/liveaudioserver.rb @@ -17,8 +17,8 @@ class Liveaudioserver < Formula desc "Live audio streaming server (MP3 + AAC + HLS) for macOS" homepage "https://github.com/dsward2/LiveAudioServer" - url "https://github.com/dsward2/LiveAudioServer/archive/refs/tags/v0.1.2.tar.gz" - sha256 "c5b000fd7965742bab0ea33276b310ce373b1709f5730ef209bdef8f53ccfd2c" + url "https://github.com/dsward2/LiveAudioServer/archive/refs/tags/v0.1.3.tar.gz" + sha256 "ed78fadcbbca56127d660418b4e313b9bbef8c0c6057f7b7500115b923b565c3" license "Apache-2.0" head "https://github.com/dsward2/LiveAudioServer.git", branch: "main"