diff --git a/Package.swift b/Package.swift index 566fff5..be8c9eb 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,10 @@ let package = Package( .testTarget( name: "PerchTests", dependencies: ["Perch"], - path: "Tests/PerchTests" + path: "Tests/PerchTests", + resources: [ + .copy("Fixtures") + ] ) ] ) diff --git a/Scripts/make-audio-fixture.swift b/Scripts/make-audio-fixture.swift new file mode 100644 index 0000000..ba61bf4 --- /dev/null +++ b/Scripts/make-audio-fixture.swift @@ -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.. 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 + } +} diff --git a/Sources/Perch/Transforms/ShelfTransform.swift b/Sources/Perch/Transforms/ShelfTransform.swift index f19f0be..3e59ec4 100644 --- a/Sources/Perch/Transforms/ShelfTransform.swift +++ b/Sources/Perch/Transforms/ShelfTransform.swift @@ -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( @@ -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 } @@ -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? + private var outcome: Result? + 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) { + lock.lock() + guard outcome == nil else { return lock.unlock() } + outcome = result + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(with: result) } } diff --git a/Tests/PerchTests/Fixtures/clip.mov b/Tests/PerchTests/Fixtures/clip.mov new file mode 100644 index 0000000..4aff94f Binary files /dev/null and b/Tests/PerchTests/Fixtures/clip.mov differ diff --git a/Tests/PerchTests/ShelfTransformTests.swift b/Tests/PerchTests/ShelfTransformTests.swift index d8ab585..a85b7ab 100644 --- a/Tests/PerchTests/ShelfTransformTests.swift +++ b/Tests/PerchTests/ShelfTransformTests.swift @@ -147,7 +147,7 @@ final class ShelfTransformTests: XCTestCase { func testExtractAudioWritesM4AWithoutChangingSource() async throws { let fixture = try TransformFixture() defer { fixture.remove() } - let source = try await fixture.makeMovieWithAudio(named: "clip.mov") + let source = try fixture.copyBundledMovie(named: "clip.mov") let original = try Data(contentsOf: source) let events = await collect( @@ -156,11 +156,20 @@ final class ShelfTransformTests: XCTestCase { outputDirectory: fixture.outputDirectory ) let output = try XCTUnwrap(events.outputURL) - let audioTracks = try await AVURLAsset(url: output).loadTracks(withMediaType: .audio) + let extracted = AVURLAsset(url: output) + let audioTracks = try await extracted.loadTracks(withMediaType: .audio) + let sourceDuration = try await AVURLAsset(url: source).load(.duration) + let extractedDuration = try await extracted.load(.duration) XCTAssertEqual(output.lastPathComponent, "clip.m4a") XCTAssertEqual(output.pathExtension, "m4a") XCTAssertFalse(audioTracks.isEmpty) + XCTAssertEqual( + extractedDuration.seconds, + sourceDuration.seconds, + accuracy: 0.05, + "the whole track should be carried over, not just its first samples" + ) XCTAssertEqual(try Data(contentsOf: source), original) } @@ -856,88 +865,17 @@ private final class TransformFixture { return url } - func makeMovieWithAudio(named name: String) async throws -> URL { - let audioURL = root.appendingPathComponent( - "\(UUID().uuidString).m4a", - isDirectory: false - ) - defer { try? FileManager.default.removeItem(at: audioURL) } - let sampleRate = 44_100.0 - let frameCount = AVAudioFrameCount(sampleRate) - let format = try XCTUnwrap(AVAudioFormat( - standardFormatWithSampleRate: sampleRate, - channels: 1 - )) - let buffer = try XCTUnwrap(AVAudioPCMBuffer( - pcmFormat: format, - frameCapacity: frameCount + /// Copies in the checked-in 1-second AAC movie built by + /// Scripts/make-audio-fixture.swift. Committing it keeps the test off the + /// host's audio encoder, which is not dependable on headless CI machines. + func copyBundledMovie(named name: String) throws -> URL { + let bundled = try XCTUnwrap(Bundle.module.url( + forResource: "clip", + withExtension: "mov", + subdirectory: "Fixtures" )) - buffer.frameLength = frameCount - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for frame in 0..