Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import CoreMedia
import Foundation

/// Output writer that encodes PCM audio to M4A/AAC format.
/// Uses a queue-and-drain pattern to avoid dropping frames when the AAC encoder
/// buffer is full (isReadyForMoreMediaData == false), e.g. under CPU/memory pressure.
class M4aFileOutputWriter: AudioOutputWriter {
private let outputPath: String
private var writer: AVAssetWriter?
private var input: AVAssetWriterInput?
private var errorMessage: String?
private var pcmFormat: AVAudioFormat?

private let drainQueue = DispatchQueue(label: "M4aFileOutputWriter.drain")
private var pendingSampleBuffers: [CMSampleBuffer] = []

init(outputPath: String) {
self.outputPath = outputPath
}
Expand Down Expand Up @@ -81,53 +85,87 @@ class M4aFileOutputWriter: AudioOutputWriter {
let input = input,
let writer = writer,
Comment on lines 85 to 86

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In write(...), input and writer are unwrapped but never used anymore (they’re only used for existence checks). This will produce unused-variable warnings; consider changing the guard to boolean nil checks or remove these bindings and rely on the drainQueue-side guard.

Suggested change
let input = input,
let writer = writer,
input != nil,
writer != nil,

Copilot uses AI. Check for mistakes.
let pcmFormat = pcmFormat else { return }

guard input.isReadyForMoreMediaData else { return }


let pts = CMTimeMake(value: framePosition, timescale: Int32(pcmFormat.sampleRate))

guard let sampleBuffer = buffer.toCMSampleBuffer(presentationTime: pts) else {
if errorMessage == nil {
errorMessage = "Failed to create CMSampleBuffer"
}
return
}

let success = input.append(sampleBuffer)
if !success {
if writer.status == .failed {
errorMessage = writer.error?.localizedDescription ?? "Writer failed"
} else if errorMessage == nil {
errorMessage = "Failed to append sample buffer"

drainQueue.async { [weak self] in
self?.enqueueAndDrain(sampleBuffer: sampleBuffer)
}
}

private func enqueueAndDrain(sampleBuffer: CMSampleBuffer) {
guard errorMessage == nil,
let input = input,
let writer = writer else { return }

pendingSampleBuffers.append(sampleBuffer)
drainPendingBuffers()
Comment on lines +103 to +109

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingSampleBuffers can grow without bound if the encoder stays backpressured for an extended period (e.g., prolonged background throttling). That can lead to unbounded memory growth and app termination; consider adding a maximum queue size / duration and setting an explicit error (or a defined dropping policy) once exceeded.

Copilot uses AI. Check for mistakes.
}

private func drainPendingBuffers() {
guard errorMessage == nil,
let input = input,
let writer = writer else { return }

while !pendingSampleBuffers.isEmpty && input.isReadyForMoreMediaData && writer.status != .failed {
let sampleBuffer = pendingSampleBuffers.removeFirst()
let success = input.append(sampleBuffer)
if !success {
if writer.status == .failed {
errorMessage = writer.error?.localizedDescription ?? "Writer failed"
} else if errorMessage == nil {
errorMessage = "Failed to append sample buffer"
}
pendingSampleBuffers.insert(sampleBuffer, at: 0)
return
}
Comment on lines +117 to 128

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Array.removeFirst() (and insert(..., at: 0) on failure) is O(n) per buffer and can become a hot path when the queue is large. Consider a ring buffer / deque approach (head index) to make draining and re-queueing O(1).

Copilot uses AI. Check for mistakes.
}
}

func stop(completion: @escaping () -> Void) {
guard let writer = writer, let input = input else {
guard writer != nil, input != nil else {
completion()
return
}

input.markAsFinished()
writer.finishWriting { [weak self] in
guard let self = self else {

drainQueue.async { [weak self] in
guard let self = self,
let writer = self.writer,
let input = self.input else {
completion()
return
}

if writer.status == .failed {
self.errorMessage = writer.error?.localizedDescription ?? "Unknown error"

self.drainPendingBuffers()
input.markAsFinished()
writer.finishWriting { [weak self] in
Comment on lines +146 to +148

Copilot AI Feb 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop() calls drainPendingBuffers() only once and then immediately markAsFinished() / finishWriting(). If input.isReadyForMoreMediaData is false at that moment, queued buffers remain undrained but the writer is still finalized, so audio can still be truncated. Consider waiting/draining until the pending queue is empty (e.g., via requestMediaDataWhenReady on drainQueue, or a readiness loop with a timeout) before marking the input finished.

Copilot uses AI. Check for mistakes.
guard let self = self else {
completion()
return
}
if writer.status == .failed {
self.errorMessage = writer.error?.localizedDescription ?? "Unknown error"
}
self.pendingSampleBuffers.removeAll()
completion()
}

completion()
}
}

func release() {
writer = nil
input = nil
pcmFormat = nil
drainQueue.sync {
writer = nil
input = nil
pcmFormat = nil
pendingSampleBuffers.removeAll()
}
}

func getOutputPath() -> String? {
Expand Down