From 79fef8af67a2ecf0dd525dc5c0075b737044d2a7 Mon Sep 17 00:00:00 2001 From: maxthegray Date: Tue, 11 Aug 2026 23:25:13 -0400 Subject: [PATCH 1/2] Revert "Test audio extraction with a real movie" This reverts commit f66bc00dd11055fbc40d6ae453a2e4c44ef1e911. --- Tests/PerchTests/ShelfTransformTests.swift | 82 ++-------------------- 1 file changed, 5 insertions(+), 77 deletions(-) diff --git a/Tests/PerchTests/ShelfTransformTests.swift b/Tests/PerchTests/ShelfTransformTests.swift index d8ab585..759ba09 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.makeAudio(named: "clip.wav") let original = try Data(contentsOf: source) let events = await collect( @@ -856,12 +856,8 @@ 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) } + func makeAudio(named name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: false) let sampleRate = 44_100.0 let frameCount = AVAudioFrameCount(sampleRate) let format = try XCTUnwrap(AVAudioFormat( @@ -877,67 +873,8 @@ private final class TransformFixture { for frame in 0.. Date: Tue, 11 Aug 2026 23:42:41 -0400 Subject: [PATCH 2/2] Extract audio by copying the track, not exporting it AVAssetExportSession's AppleM4A preset is what CI dies on: swift test has taken SIGSEGV in testExtractAudioWritesM4AWithoutChangingSource on every macos-15 run since audio extraction landed, under both the old exportAsynchronously path and the newer export(to:as:) one, and with both the WAV and the QuickTime fixture. The preset always re-encodes, and on a headless runner that cannot supply its media services it faults rather than returning an error. Replace it with an AVAssetReader/AVAssetWriter copy. Audio that is already AAC is passed straight through into the .m4a container, so extraction is lossless, faster, and instantiates no codec at all; other formats are decoded to PCM and re-encoded. This also drops the deprecated status/error/exportAsynchronously surface and, with it, the back-deployed export(to:as:) shim that force-unwraps error and calls fatalError on any non-terminal status. Feed the test a committed 4.8 KB AAC movie instead of building one at run time, so it no longer depends on the host having a working encoder, and assert the extracted duration so a truncated copy cannot pass. --- Package.swift | 5 +- Scripts/make-audio-fixture.swift | 84 ++++++++ Sources/Perch/Transforms/ShelfTransform.swift | 200 ++++++++++++++---- Tests/PerchTests/Fixtures/clip.mov | Bin 0 -> 4803 bytes Tests/PerchTests/ShelfTransformTests.swift | 41 ++-- 5 files changed, 271 insertions(+), 59 deletions(-) create mode 100644 Scripts/make-audio-fixture.swift create mode 100644 Tests/PerchTests/Fixtures/clip.mov 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 0000000000000000000000000000000000000000..4aff94f5ed77ca14159e71a80e69dff620ca355c GIT binary patch literal 4803 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 URL { - let url = root.appendingPathComponent(name, isDirectory: false) - let sampleRate = 44_100.0 - let frameCount = AVAudioFrameCount(sampleRate) - let format = try XCTUnwrap(AVAudioFormat( - standardFormatWithSampleRate: sampleRate, - channels: 1 + /// 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" )) - let buffer = try XCTUnwrap(AVAudioPCMBuffer( - pcmFormat: format, - frameCapacity: frameCount - )) - buffer.frameLength = frameCount - let samples = try XCTUnwrap(buffer.floatChannelData?[0]) - for frame in 0..