Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Sources/LiveAudioServer/LiveAudioServerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,18 @@ struct LiveAudioServerApp {
// Graceful shutdown wired to SIGINT/SIGTERM. Ignoring the kernel
// default first means the DispatchSourceSignal sees the signal
// instead of the process being torn down.
// One-shot: the parent watchdog keeps firing every 0.5 s after the
// parent dies, and a second SIGTERM can land mid-teardown. A repeat
// call would see stop() return early and exit(0) while the first
// shutdown is still disposing encoders.
let shutdownLock = NSLock()
var shutdownStarted = false
let runGracefulShutdown: (String) -> Void = { reason in
shutdownLock.lock()
let alreadyStarted = shutdownStarted
shutdownStarted = true
shutdownLock.unlock()
if alreadyStarted { return }
log("\n\(reason) received — graceful shutdown")
Task {
await server.stop()
Expand Down
19 changes: 18 additions & 1 deletion Sources/LiveAudioServerCore/AACEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ final class AACEncoder {

private var outputBuf: [UInt8]

/// Serializes encode / flush / stop. The PCM reader thread encodes (and
/// flushes on EOF) while `LiveAudioServer.stop()` tears down from a Swift
/// task; without this, AudioConverterReset/Dispose ran concurrently on the
/// same converter and crashed in aacClose ("pointer being freed was not
/// allocated").
private let lock = NSLock()
private var isStopped = false

init(config: AudioEncoderConfig, onEncoded: @escaping (Data) -> Void) {
self.config = config
self.onEncoded = onEncoded
Expand Down Expand Up @@ -112,8 +120,13 @@ final class AACEncoder {
encoderLog("AAC encoder ready: \(config.aacBitrate/1000)kbps, \(config.channels)ch, \(config.sampleRate)Hz", config: config)
}

/// Idempotent. Disposes the converter exactly once; later `encode` calls
/// are no-ops. No reset first: AudioConverterReset only discards buffered
/// input (it emits nothing), and Dispose frees the codec anyway.
func stop() {
flush()
lock.lock(); defer { lock.unlock() }
guard !isStopped else { return }
isStopped = true
if let conv = converter {
AudioConverterDispose(conv)
converter = nil
Expand All @@ -123,6 +136,8 @@ final class AACEncoder {
// MARK: - Encoding

func encode(samples: UnsafeBufferPointer<Int16>) {
lock.lock(); defer { lock.unlock() }
guard !isStopped else { return }
if samples.count == 0 {
flush()
return
Expand All @@ -133,6 +148,7 @@ final class AACEncoder {

// MARK: - Internal

/// Caller must hold `lock`.
private func drainQueue() {
guard let converter = converter else { return }
let samplesNeeded = framesPerPacket * config.channels
Expand Down Expand Up @@ -200,6 +216,7 @@ final class AACEncoder {
}
}

/// Caller must hold `lock`.
private func flush() {
if let conv = converter {
AudioConverterReset(conv)
Expand Down
20 changes: 18 additions & 2 deletions Sources/LiveAudioServerCore/MP3Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ final class MP3Encoder {
private var lame: lame_t?
private var mp3Buf: [UInt8]

/// Serializes encode / flush / stop. The PCM reader thread encodes (and
/// flushes on EOF) while `LiveAudioServer.stop()` tears down from a Swift
/// task; without this, lame_encode_flush / lame_close ran concurrently on
/// the same handle and aborted inside LAME.
private let lock = NSLock()
private var isStopped = false
private var isFlushed = false

// LAME recommends output buffer = 1.25 * samples + 7200
private var mp3BufSize: Int { Int(Double(config.chunkFrames) * 1.25) + 7200 }

Expand Down Expand Up @@ -38,7 +46,12 @@ final class MP3Encoder {
encoderLog("MP3 encoder ready: \(config.mp3Bitrate)kbps, \(config.channels)ch, \(config.sampleRate)Hz", config: config)
}

/// Idempotent. Flushes (if the EOF path hasn't already) and closes LAME
/// exactly once; later `encode` calls are no-ops.
func stop() {
lock.lock(); defer { lock.unlock() }
guard !isStopped else { return }
isStopped = true
flush()
if lame != nil {
lame_close(lame)
Expand All @@ -50,7 +63,8 @@ final class MP3Encoder {

/// Called with each PCM chunk. count==0 signals EOF/flush.
func encode(samples: UnsafeBufferPointer<Int16>) {
guard let lame = lame else { return }
lock.lock(); defer { lock.unlock() }
guard !isStopped, let lame = lame else { return }

if samples.count == 0 {
flush()
Expand Down Expand Up @@ -105,8 +119,10 @@ final class MP3Encoder {

// MARK: - Private

/// Caller must hold `lock`. Runs at most once per encoder.
private func flush() {
guard let lame = lame else { return }
guard !isFlushed, let lame = lame else { return }
isFlushed = true
var flushBuf = [UInt8](repeating: 0, count: 7200)
let n = flushBuf.withUnsafeMutableBytes { ptr in
lame_encode_flush(lame,
Expand Down
Loading