Skip to content
Closed
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
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ let package = Package(
.testTarget(
name: "PerchTests",
dependencies: ["Perch"],
path: "Tests/PerchTests"
path: "Tests/PerchTests",
resources: [
.copy("Fixtures")
]
)
]
)
84 changes: 84 additions & 0 deletions Scripts/make-audio-fixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env swift
// Regenerates Tests/PerchTests/Fixtures/clip.mov, the checked-in source for
// ShelfTransformTests.testExtractAudioWritesM4AWithoutChangingSource.
//
// The fixture is a 1-second, 44.1 kHz mono QuickTime movie whose only track is
// AAC audio. It is committed rather than generated at test time so the test
// never depends on the host having a working AAC encoder.
//
// swift Scripts/make-audio-fixture.swift Tests/PerchTests/Fixtures/clip.mov

import AVFoundation
import Foundation

let outputURL = URL(fileURLWithPath: CommandLine.arguments[1])
try? FileManager.default.removeItem(at: outputURL)

let sampleRate = 44_100.0
let frameCount = AVAudioFrameCount(sampleRate)
let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1)!
let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount)!
buffer.frameLength = frameCount
let samples = buffer.floatChannelData![0]
for frame in 0..<Int(frameCount) {
samples[frame] = sin(2 * .pi * 440 * Float(frame) / Float(sampleRate)) * 0.25
}

let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov)
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 64_000
])
input.expectsMediaDataInRealTime = false
guard writer.canAdd(input) else { fatalError("writer rejected the audio input") }
writer.add(input)
guard writer.startWriting() else { fatalError("startWriting: \(writer.error!)") }
writer.startSession(atSourceTime: .zero)

guard let sample = buffer.toCMSampleBuffer() else { fatalError("no sample buffer") }
while !input.isReadyForMoreMediaData { usleep(1000) }
guard input.append(sample) else { fatalError("append: \(writer.error!)") }
input.markAsFinished()

let done = DispatchSemaphore(value: 0)
writer.finishWriting { done.signal() }
done.wait()
guard writer.status == .completed else { fatalError("finishWriting: \(writer.error!)") }

let size = try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as! Int
print("wrote \(outputURL.lastPathComponent) (\(size) bytes)")

extension AVAudioPCMBuffer {
func toCMSampleBuffer() -> CMSampleBuffer? {
var formatDescription: CMFormatDescription?
guard CMAudioFormatDescriptionCreate(
allocator: kCFAllocatorDefault,
asbd: format.streamDescription,
layoutSize: 0, layout: nil, magicCookieSize: 0, magicCookie: nil,
extensions: nil, formatDescriptionOut: &formatDescription
) == noErr else { return nil }

var sampleBuffer: CMSampleBuffer?
guard CMSampleBufferCreate(
allocator: kCFAllocatorDefault, dataBuffer: nil, dataReady: false,
makeDataReadyCallback: nil, refcon: nil,
formatDescription: formatDescription, sampleCount: CMItemCount(frameLength),
sampleTimingEntryCount: 1,
sampleTimingArray: [CMSampleTimingInfo(
duration: CMTime(value: 1, timescale: CMTimeScale(format.sampleRate)),
presentationTimeStamp: .zero, decodeTimeStamp: .invalid
)],
sampleSizeEntryCount: 0, sampleSizeArray: nil,
sampleBufferOut: &sampleBuffer
) == noErr else { return nil }

guard CMSampleBufferSetDataBufferFromAudioBufferList(
sampleBuffer!, blockBufferAllocator: kCFAllocatorDefault,
blockBufferMemoryAllocator: kCFAllocatorDefault, flags: 0,
bufferList: mutableAudioBufferList
) == noErr else { return nil }
return sampleBuffer
}
}
200 changes: 162 additions & 38 deletions Sources/Perch/Transforms/ShelfTransform.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,16 +362,10 @@ enum ShelfTransformAction: Hashable, Sendable {
throw ShelfTransformError.sourceMissing(input.filename)
}
let asset = AVURLAsset(url: input.sourceURL)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
guard !audioTracks.isEmpty else {
guard let track = try await asset.loadTracks(withMediaType: .audio).first else {
throw ShelfTransformError.noAudioTrack(input.filename)
}
guard let exporter = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetAppleM4A
), exporter.supportedFileTypes.contains(.m4a) else {
throw ShelfTransformError.audioExportUnavailable(input.filename)
}
let sourceFormat = try await track.load(.formatDescriptions).first

let base = input.sourceURL.deletingPathExtension().lastPathComponent
let finalURL = ItemStore.nonClobberingURL(
Expand All @@ -383,32 +377,14 @@ enum ShelfTransformAction: Hashable, Sendable {
)
defer { try? FileManager.default.removeItem(at: partialURL) }

if #available(macOS 15.0, *) {
try await exporter.export(to: partialURL, as: .m4a)
} else {
exporter.outputURL = partialURL
exporter.outputFileType = .m4a
let exporterBox = SendableAudioExporter(exporter)
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
exporterBox.session.exportAsynchronously {
switch exporterBox.session.status {
case .completed:
continuation.resume()
case .cancelled:
continuation.resume(throwing: CancellationError())
default:
continuation.resume(throwing:
exporterBox.session.error
?? ShelfTransformError.audioExportFailed(input.filename)
)
}
}
}
} onCancel: {
exporterBox.session.cancelExport()
}
}
let copier = try AudioTrackCopier(
track: track,
of: asset,
sourceFormat: sourceFormat,
to: partialURL,
filename: input.filename
)
try await copier.copy()
try FileManager.default.moveItem(at: partialURL, to: finalURL)
return finalURL
}
Expand Down Expand Up @@ -722,11 +698,159 @@ enum ShelfTransformAction: Hashable, Sendable {
}
}

private final class SendableAudioExporter: @unchecked Sendable {
let session: AVAssetExportSession
/// Writes one audio track into a standalone .m4a.
///
/// Audio that is already AAC is copied through untouched, so extraction is
/// lossless and never instantiates a codec; anything else is decoded to PCM and
/// re-encoded. This deliberately avoids AVAssetExportSession, whose M4A preset
/// always re-encodes and faults instead of erroring on hosts that cannot supply
/// its media services.
private final class AudioTrackCopier: @unchecked Sendable {
private let reader: AVAssetReader
private let output: AVAssetReaderTrackOutput
private let writer: AVAssetWriter
private let input: AVAssetWriterInput
private let filename: String
private let queue = DispatchQueue(label: "dev.perch.audio-extraction")

private let lock = NSLock()
private var continuation: CheckedContinuation<Void, any Error>?
private var outcome: Result<Void, any Error>?
private var isCancelled = false
private var isFinishing = false

init(
track: AVAssetTrack,
of asset: AVAsset,
sourceFormat: CMFormatDescription?,
to outputURL: URL,
filename: String
) throws {
self.filename = filename
reader = try AVAssetReader(asset: asset)
writer = try AVAssetWriter(outputURL: outputURL, fileType: .m4a)

let streamDescription = sourceFormat
.flatMap { CMAudioFormatDescriptionGetStreamBasicDescription($0)?.pointee }
let isAAC = streamDescription?.mFormatID == kAudioFormatMPEG4AAC

output = AVAssetReaderTrackOutput(
track: track,
outputSettings: isAAC ? nil : [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false
]
)
if isAAC {
input = AVAssetWriterInput(
mediaType: .audio,
outputSettings: nil,
sourceFormatHint: sourceFormat
)
} else {
let channels = Int(streamDescription?.mChannelsPerFrame ?? 2)
let sampleRate = streamDescription?.mSampleRate ?? 44_100
input = AVAssetWriterInput(mediaType: .audio, outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: channels,
AVEncoderBitRateKey: 64_000 * max(channels, 1)
])
}
input.expectsMediaDataInRealTime = false

guard reader.canAdd(output), writer.canAdd(input) else {
throw ShelfTransformError.audioExportUnavailable(filename)
}
reader.add(output)
writer.add(input)
}

func copy() async throws {
guard writer.startWriting() else {
throw writer.error ?? ShelfTransformError.audioExportFailed(filename)
}
writer.startSession(atSourceTime: .zero)
guard reader.startReading() else {
throw reader.error ?? ShelfTransformError.audioExportFailed(filename)
}

try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
lock.lock()
if let outcome {
lock.unlock()
continuation.resume(with: outcome)
return
}
self.continuation = continuation
lock.unlock()
input.requestMediaDataWhenReady(on: queue) { [self] in pump() }
}
} onCancel: {
cancel()
}
}

private func pump() {
while input.isReadyForMoreMediaData {
lock.lock()
let stopped = isCancelled
lock.unlock()
guard !stopped else { return }

guard let sample = output.copyNextSampleBuffer() else {
if reader.status == .failed {
settle(.failure(
reader.error ?? ShelfTransformError.audioExportFailed(filename)
))
} else {
lock.lock()
isFinishing = true
lock.unlock()
input.markAsFinished()
writer.finishWriting { [self] in
guard writer.status == .completed else {
settle(.failure(
writer.error ?? ShelfTransformError.audioExportFailed(filename)
))
return
}
settle(.success(()))
}
}
return
}
guard input.append(sample) else {
settle(.failure(writer.error ?? ShelfTransformError.audioExportFailed(filename)))
return
}
}
}

/// Once finishWriting is under way the copy is effectively done, and
/// cancelWriting on top of it is undefined; let it land instead.
private func cancel() {
lock.lock()
guard outcome == nil, !isFinishing else { return lock.unlock() }
isCancelled = true
lock.unlock()
reader.cancelReading()
writer.cancelWriting()
settle(.failure(CancellationError()))
}

init(_ session: AVAssetExportSession) {
self.session = session
private func settle(_ result: Result<Void, any Error>) {
lock.lock()
guard outcome == nil else { return lock.unlock() }
outcome = result
let pending = continuation
continuation = nil
lock.unlock()
pending?.resume(with: result)
}
}

Expand Down
Binary file added Tests/PerchTests/Fixtures/clip.mov
Binary file not shown.
Loading
Loading