From a1c7db368e0be4cd7763a851b0e22a8fa14127cc Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 21 Aug 2026 15:06:51 +0800 Subject: [PATCH 1/9] fix(audio-studio/ios): make AAC trims honour the requested rate and depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AAC path wrote source-format buffers to a target-rate writer, violating AVAudioFile's format-match contract and mis-timing the output. Probed at a 44.1kHz source: one second came back as 2.0000s at 22.05kHz and 0.9187s at 48kHz. It now converts against the writer's resolved format — the same treatment the WAV path received in #450 — with the same ratio-sized output buffer, single-shot input, downmix on channel reduction, and errors instead of silent skips. The writer is scoped so the read-back sees the finished file. Fixing the rate exposed a pre-existing bitrate defect: the encoder rejects combinations its profile cannot serve — measured, 96kbps and up fail at 22.05kHz mono with error 560226676, and no formula predicts the ceiling (my first guess of 8 bits/sample/channel was wrong; the real limit there is ~3). So the writer is opened with the requested bitrate and reopened without one when the encoder refuses, honouring the request where possible instead of failing a trim the platform can serve. Bit depth: an omitted bitDepth forced 16 during any rate or channel change, contrary to the contract that the input format is preserved, and a bitDepth-only request took the fast path and was ignored. The default is now the input's own depth, depth changes leave the fast path, and no result hardcodes 16. Validated on the iOS simulator, per the hard rule: - 1000ms trim to AAC@22050: durationMs 1114 (AAC priming), rate 22050 — previously ~2000ms - 1000ms trim to WAV bitDepth 32: durationMs 1000, bitDepth 32 — previously ignored - plain single/keepRanges trims unchanged at 5000ms/4000ms Adds testTrimAudioWith(options) to the agentic bridge so trim changes can be validated on device with arbitrary options. --- apps/playground/src/agentic-bridge.ts | 22 +++ packages/audio-studio/CHANGELOG.md | 5 + .../audio-studio/ios/AudioProcessor.swift | 125 ++++++++++++++++-- 3 files changed, 143 insertions(+), 9 deletions(-) diff --git a/apps/playground/src/agentic-bridge.ts b/apps/playground/src/agentic-bridge.ts index e9e7afc8d..155391ee5 100644 --- a/apps/playground/src/agentic-bridge.ts +++ b/apps/playground/src/agentic-bridge.ts @@ -2113,6 +2113,28 @@ if (__DEV__) { return { op, status: 'pending' } }, + // Parameterized trim, for validating trim changes on device with arbitrary + // options (#451). Fire-and-store like the other test methods: CDP uses + // awaitPromise: false, so poll getLastResult(). + testTrimAudioWith: (options: Record) => { + const op = 'trimAudioWith' + _lastAsyncResult = { op, status: 'pending' } + void (async () => { + try { + const fileUri = + (options.fileUri as string) ?? (await loadSampleFileUri()) + const result = await trimAudio({ + ...(stripFunctions(options) as object), + fileUri, + } as never) + _lastAsyncResult = { op, status: 'success', result } + } catch (e) { + _lastAsyncResult = { op, status: 'error', error: String(e) } + } + })() + return _lastAsyncResult + }, + testTrimAudio: () => { const op = 'trimAudio' _lastAsyncResult = { op, status: 'pending' } diff --git a/packages/audio-studio/CHANGELOG.md b/packages/audio-studio/CHANGELOG.md index 884072586..e12808ad3 100644 --- a/packages/audio-studio/CHANGELOG.md +++ b/packages/audio-studio/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- iOS `trimAudio` AAC output now honours the requested sample rate. Writing source-format buffers to a target-rate writer mis-timed the output — one second at 44.1kHz came back as 2.0s at 22.05kHz and 0.92s at 48kHz. The AAC path now converts against the writer's resolved format, the same treatment the WAV path received. Bitrates the encoder rejects at the target rate fall back to its default instead of failing the trim (#451). +- iOS `trimAudio` preserves the input's bit depth when none is requested, and honours a bitDepth-only request instead of ignoring it via the fast path. An omitted depth previously forced 16-bit during any rate or channel change, contrary to the documented contract (#451). + ### Added - `addRecordingErrorListener()` subscribes to errors raised while recording is already running. iOS emitted these all along with no typed way to subscribe, so a failure that does not reject a call — the stalled WAV in #420, for one — was unobservable. iOS only: Android does not declare the event, so the listener never fires there. diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index 99b3c2f66..f659fbd23 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -780,7 +780,14 @@ public class AudioProcessor { return inputSampleRate }() let targetChannels = outputFormat.flatMap { bridgedInt($0, "channels", in: 1...2) } ?? inputChannels - let targetBitDepth = outputFormat.flatMap { bridgedInt($0, "bitDepth").flatMap { [16, 32].contains($0) ? $0 : nil } } ?? 16 + // Default to the input's own depth, not 16. The public contract says an omitted + // output format preserves the input, so defaulting silently downconverted a 32-bit + // source during any rate or channel change (#451). + let inputBitDepth: Int = { + let bits = Int(audioFile.fileFormat.streamDescription.pointee.mBitsPerChannel) + return [16, 32].contains(bits) ? bits : 16 + }() + let targetBitDepth = outputFormat.flatMap { bridgedInt($0, "bitDepth").flatMap { [16, 32].contains($0) ? $0 : nil } } ?? inputBitDepth let bitrate = outputFormat.flatMap { bridgedInt($0, "bitrate").flatMap { $0 > 0 ? $0 : nil } } ?? 128000 let fileExtension = formatStr == "wav" ? "wav" : "aac" @@ -810,6 +817,9 @@ public class AudioProcessor { // WAV fast path and was silently ignored (#433). let outputDiffersFromInput = targetSampleRate != inputSampleRate || targetChannels != inputChannels + // The fast path writes with inputFormat.settings, so a depth change must not + // take it — a bitDepth-only request was previously ignored outright (#451). + || targetBitDepth != inputBitDepth let needFormatChange = decodingConfig.targetSampleRate != nil || decodingConfig.targetChannels != nil || decodingConfig.targetBitDepth != nil @@ -865,7 +875,7 @@ public class AudioProcessor { } try promoteWorkFile() - return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(inputSampleRate), channels: inputChannels, bitDepth: 16, bitrate: bitrate) + return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(inputSampleRate), channels: inputChannels, bitDepth: inputBitDepth, bitrate: bitrate) } else { // Non-fast path: Decode and re-encode let targetFormat = AVAudioFormat( @@ -1052,25 +1062,122 @@ public class AudioProcessor { // 5. Update the MIME type logic for AAC only let _ = "audio/mp4" // Changed from mimeType - let outputFile = try AVAudioFile(forWriting: workURL, settings: outputSettings) + try autoreleasepool { + // The encoder rejects bitrates its profile cannot serve at the target + // rate — measured: 96k+ at 22.05kHz mono fails with error 560226676, + // while omitting the key always succeeds. So honour the request when + // the encoder accepts it, and fall back to its default when it does + // not, rather than guessing the profile's ceiling (#451). + let outputFile: AVAudioFile + do { + outputFile = try AVAudioFile(forWriting: workURL, settings: outputSettings) + } catch { + var withoutBitrate = outputSettings + withoutBitrate.removeValue(forKey: AVEncoderBitRateKey) + Logger.debug( + "AudioProcessor", + "Encoder rejected \(bitrate)bps at \(targetSampleRate)Hz; using its default" + ) + outputFile = try AVAudioFile(forWriting: workURL, settings: withoutBitrate) + } + + // Convert to the writer's own format. Writing an inputFormat buffer to a + // writer configured at another rate violates AVAudioFile's format-match + // contract and mis-times the output: probed at 44.1kHz source, one second + // came back as 2.0000s at 22.05kHz and 0.9187s at 48kHz (#451). + let writerFormat = outputFile.processingFormat + guard AVAudioConverter(from: inputFormat, to: writerFormat) != nil else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Cannot convert to \(writerFormat.sampleRate)Hz " + + "from this file's \(inputFormat.sampleRate)Hz"] + ) + } + var totalFrames: Int64 = 0 for range in keepRanges { - // Break down complex expressions let startTimeInSeconds = range[0] / 1000 let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate) - + let endTimeInSeconds = range[1] / 1000 let endFramePosition = endTimeInSeconds * inputSampleRate let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame)) - - let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount)! + + guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount) else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Could not allocate a \(frameCount)-frame input buffer"] + ) + } audioFile.framePosition = startFrame try audioFile.read(into: buffer, frameCount: frameCount) - try outputFile.write(from: buffer) + + guard let converter = AVAudioConverter(from: inputFormat, to: writerFormat) else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Cannot convert to \(writerFormat.sampleRate)Hz"] + ) + } + if writerFormat.channelCount < inputFormat.channelCount { + converter.downmix = true + } + + // Size by the rate ratio; a source-sized buffer truncates an upsample + // and gets overfilled on a downsample. + let ratio = writerFormat.sampleRate / inputFormat.sampleRate + let scaled = (Double(frameCount) * ratio).rounded(.up) + guard scaled <= Double(AVAudioFrameCount.max) else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Converting \(frameCount) frames to \(writerFormat.sampleRate)Hz " + + "needs \(scaled) frames, more than one buffer can hold"] + ) + } + guard let converted = AVAudioPCMBuffer( + pcmFormat: writerFormat, + frameCapacity: AVAudioFrameCount(max(scaled, 1)) + ) else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Could not allocate an output buffer"] + ) + } + + var suppliedInput = false + var conversionError: NSError? + _ = converter.convert(to: converted, error: &conversionError) { _, outStatus in + if suppliedInput { + outStatus.pointee = .endOfStream + return nil + } + suppliedInput = true + outStatus.pointee = .haveData + return buffer + } + if let conversionError = conversionError { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Format conversion failed: \(conversionError.localizedDescription)"] + ) + } + + try outputFile.write(from: converted) totalFrames += Int64(frameCount) let progress = Float(cumulativeFrames) / Float(totalFrames) * 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) } + } try promoteWorkFile() return createTrimResult( @@ -1079,7 +1186,7 @@ public class AudioProcessor { formatStr: formatStr, sampleRate: Int(targetSampleRate), channels: targetChannels, - bitDepth: 16, + bitDepth: targetBitDepth, bitrate: bitrate, compression: nil ) From dfe755aa1c99d80abedea97aaeffcf4687f0cdb5 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 21 Aug 2026 16:18:05 +0800 Subject: [PATCH 2/9] fix(audio-studio/ios): reject unsupported AAC requests instead of resolving them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review blockers, all reproduced on device before fixing. The AAC path checked only the writer's resolved format, so a rate the platform cannot serve came back as something else: an AAC writer turns 1Hz into 8kHz and that conversion succeeds. The requested format is now checked first, matching the WAV path. On device, sampleRate: 1 rejects where it previously would have returned 8kHz audio. An explicitly requested bitrate the encoder refuses was silently replaced with its default — the result carries no effective bitrate and a debug log is not caller-visible. An explicit request now errors; only the library's own 128000 default gives way, which makes the encoder default intentional rather than a swallowed failure. On device: explicit 128k at 22.05kHz rejects, the same trim without a bitrate succeeds at 22050. Bit depth clamped the input before comparing it, so a 24-bit source asked for 16-bit compared equal, took the fast path, kept 24 bits and reported 16. The raw depth now drives the comparison while a separate value supplies the writable default, so unexpressible depths still default sensibly without hiding a real conversion. Device validation (iOS simulator, fresh install): - aac@1Hz: REJECTED (was: 8kHz output) - aac@22050 with explicit 128k: REJECTED; without: 1115ms @ 22050 - wav bitDepth 32: 1000ms, depth 32 - plain single/keepRanges: 5000ms/4000ms, unchanged --- .../audio-studio/ios/AudioProcessor.swift | 281 ++++++++++-------- 1 file changed, 159 insertions(+), 122 deletions(-) diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index f659fbd23..a00a2d444 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -77,7 +77,7 @@ public class AudioProcessor { private var currentProgress: Float = 0.0 private let extractionQueue = DispatchQueue(label: "AudioProcessor", attributes: .concurrent) private var _abortExtraction: Bool = false - + // Add a counter for unique IDs private var uniqueIdCounter = 0 @@ -85,33 +85,33 @@ public class AudioProcessor { get { _abortExtraction } set { _abortExtraction = newValue } } - + // Initializer for file-based processing public init(url: URL, resolve: @escaping (Any) -> Void, reject: @escaping (String, String) -> Void) throws { self.audioFile = try AVAudioFile(forReading: url) self.result = resolve self.reject = reject } - + // Initializer for buffer-based processing public init(resolve: @escaping (Any) -> Void, reject: @escaping (String, String) -> Void) { self.result = resolve self.reject = reject } - - + + deinit { audioFile = nil } - + /// Error types for AudioProcessor public enum AudioProcessorError: Error { case fileInitializationFailed(String) case bufferCreationFailed case audioReadError(String) } - - + + /// Extracts and processes audio data from the audio file. /// - Parameters: /// - numberOfSamples: The number of samples to extract (for waveform). @@ -125,9 +125,9 @@ public class AudioProcessor { /// - byteLength: The length of the audio to read (in bytes). /// - Returns: An `AudioAnalysisData` object containing the extracted features. public func processAudioData( - numberOfSamples: Int?, - offset: Int? = 0, - length: UInt? = nil, + numberOfSamples: Int?, + offset: Int? = 0, + length: UInt? = nil, segmentDurationMs: Int = 100, // Default 100ms featureOptions: [String: Bool], bitDepth: Int, @@ -139,11 +139,11 @@ public class AudioProcessor { reject("FILE_NOT_INITIALIZED", "Audio file is not initialized.") return nil } - + let totalFrameCount = AVAudioFrameCount(audioFile.length) var framesPerBuffer: AVAudioFrameCount let _: Int // Changed from actualPointsPerSecond - + NSLog(""" [AudioProcessor] Starting audio processing: - totalFrameCount: \(totalFrameCount) @@ -154,14 +154,14 @@ public class AudioProcessor { - offset: \(offset ?? -1) - length: \(length ?? 0) """) - + // Use position/byteLength if provided, otherwise fall back to offset/length let effectiveOffset: Int64 = if let position = position { Int64(position / (bitDepth / 8) / numberOfChannels) } else { Int64(offset ?? 0) } - + let effectiveLength: Int64 = if let byteLength = byteLength { Int64(byteLength / (bitDepth / 8) / numberOfChannels) } else if let length = length { @@ -169,7 +169,7 @@ public class AudioProcessor { } else { Int64(totalFrameCount) - effectiveOffset } - + // Report the sum rather than computing it: both operands are bridged, and adding // them can overflow before any validation runs (#433). let (expectedEndFrame, endFrameOverflowed) = effectiveOffset.addingReportingOverflow(effectiveLength) @@ -180,30 +180,30 @@ public class AudioProcessor { - expectedEndFrame: \(endFrameOverflowed ? "overflowed" : String(expectedEndFrame)) - totalFrameCount: \(totalFrameCount) """) - + // Validate frame boundaries if effectiveOffset < 0 || effectiveOffset >= Int64(totalFrameCount) { NSLog("[AudioProcessor] ERROR: Invalid offset value") reject("INVALID_OFFSET", "Offset value (\(effectiveOffset)) is outside valid range [0, \(totalFrameCount)]") return nil } - + if effectiveLength <= 0 { NSLog("[AudioProcessor] ERROR: Invalid length value") reject("INVALID_LENGTH", "Length value (\(effectiveLength)) must be positive") return nil } - + if endFrameOverflowed || expectedEndFrame > Int64(totalFrameCount) { NSLog("[AudioProcessor] ERROR: Requested range exceeds file length") let describedEnd = endFrameOverflowed ? "overflowed" : String(expectedEndFrame) reject("INVALID_RANGE", "Requested range [\(effectiveOffset), \(describedEnd)] exceeds file length \(totalFrameCount)") return nil } - + var startFrame: AVAudioFramePosition = effectiveOffset let endFrame: AVAudioFramePosition = effectiveOffset + effectiveLength - + // Calculate frames per segment based on segment duration // Clamp before narrowing: AVAudioFrameCount(_:) traps on a value it cannot // represent, and the product depends on the file's sample rate, so no bound @@ -213,36 +213,36 @@ public class AudioProcessor { let framesPerSegment = AVAudioFrameCount( min(max(rawFramesPerSegment, 1), Double(AVAudioFrameCount.max)) ) - + if let numberOfSamples = numberOfSamples { framesPerBuffer = AVAudioFrameCount(max(1, effectiveLength / Int64(numberOfSamples))) } else { framesPerBuffer = framesPerSegment } - + guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: framesPerBuffer) else { reject("BUFFER_CREATION_FAILED", "Failed to create AVAudioPCMBuffer.") return nil } - + channelCount = Int(audioFile.processingFormat.channelCount) let _ = Array(repeating: [Float](repeating: 0, count: Int(framesPerBuffer)), count: channelCount) // Changed from var data - + var channelData = [Float]() while startFrame < endFrame { let remainingFrames = endFrame - startFrame let currentFramesPerBuffer = min(AVAudioFrameCount(framesPerBuffer), AVAudioFrameCount(remainingFrames)) - + if currentFramesPerBuffer <= 0 { break } - + if abortExtraction { audioFile.framePosition = startFrame abortExtraction = false return nil } - + do { audioFile.framePosition = startFrame try audioFile.read(into: buffer, frameCount: currentFramesPerBuffer) @@ -250,7 +250,7 @@ public class AudioProcessor { reject("AUDIO_READ_ERROR", "Couldn't read into buffer: \(error.localizedDescription)") return nil } - + //TODO: check if we need conversion based on bitDepth here guard let floatData = buffer.floatChannelData else { reject("BUFFER_DATA_ERROR", "Failed to retrieve float data from buffer.") @@ -259,16 +259,16 @@ public class AudioProcessor { for frame in 0.. AudioAnalysisData? { Logger.debug("AudioProcessor", "Processing audio data with sample rate: \(sampleRate), segmentDurationMs: \(segmentDurationMs), bitDepth: \(bitDepth), numberOfChannels: \(numberOfChannels)") - + let startTime = CACurrentMediaTime() let length = channelData.count @@ -364,22 +364,22 @@ public class AudioProcessor { var dataPoints = [DataPoint]() var minAmplitude: Float = .greatestFiniteMagnitude var maxAmplitude: Float = -.greatestFiniteMagnitude - + // Calculate bytes per sample let bytesPerSample = bitDepth / 8 - + // Process data in segments var i = 0 while i < length { let segmentEnd = min(i + samplesPerSegment, length) let segment = Array(channelData[i..= 0 && endFrame <= audioFile.length && startFrame < endFrame else { Logger.debug("AudioProcessor", "Invalid time range") @@ -595,7 +595,7 @@ public class AudioProcessor { let framesPerBuffer = AVAudioFrameCount( min(max(rawFramesPerBuffer, 1), Double(AVAudioFrameCount.max)) ) - + guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: framesPerBuffer) else { Logger.debug("AudioProcessor", "Failed to create buffer") return nil @@ -611,14 +611,14 @@ public class AudioProcessor { while currentFrame < endFrame { let framesToRead = min(framesPerBuffer, AVAudioFrameCount(endFrame - currentFrame)) - + do { try audioFile.read(into: buffer, frameCount: framesToRead) - + guard let channelData = buffer.floatChannelData else { continue } - + // Process each channel's data var summedData = [Float](repeating: 0, count: Int(framesToRead)) for channel in 0.. 0 ? $0 : nil } } ?? 128000 + // The file's real depth, unclamped. Clamping before the comparison below made a + // 24-bit input look like 16, so an explicit bitDepth: 16 request compared equal, + // took the fast path, kept 24 bits and reported 16 (#451). + let inputBitDepth = Int(audioFile.fileFormat.streamDescription.pointee.mBitsPerChannel) + // What a WAV writer here can actually emit. An omitted request preserves the input + // where that is expressible, rather than silently forcing 16 during any rate or + // channel change. + let defaultBitDepth = [16, 32].contains(inputBitDepth) ? inputBitDepth : 16 + let targetBitDepth = outputFormat.flatMap { bridgedInt($0, "bitDepth").flatMap { [16, 32].contains($0) ? $0 : nil } } ?? defaultBitDepth + let requestedBitrate = outputFormat.flatMap { bridgedInt($0, "bitrate").flatMap { $0 > 0 ? $0 : nil } } + let bitrate = requestedBitrate ?? 128000 let fileExtension = formatStr == "wav" ? "wav" : "aac" let outputURL = FileManager.default.temporaryDirectory @@ -818,7 +820,9 @@ public class AudioProcessor { let outputDiffersFromInput = targetSampleRate != inputSampleRate || targetChannels != inputChannels // The fast path writes with inputFormat.settings, so a depth change must not - // take it — a bitDepth-only request was previously ignored outright (#451). + // take it — a bitDepth-only request was previously ignored outright. Compared + // against the file's real depth: a 24-bit source asked for 16 must convert, + // not pass through as 24 (#451). || targetBitDepth != inputBitDepth let needFormatChange = decodingConfig.targetSampleRate != nil || decodingConfig.targetChannels != nil @@ -849,11 +853,11 @@ public class AudioProcessor { // Break down complex expressions let startTimeInSeconds = range[0] / 1000 let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate) - + let endTimeInSeconds = range[1] / 1000 let endFramePosition = endTimeInSeconds * inputSampleRate let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame)) - + let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount)! audioFile.framePosition = startFrame try audioFile.read(into: buffer, frameCount: frameCount) @@ -865,7 +869,7 @@ public class AudioProcessor { // When creating the output file Logger.debug("AudioProcessor", "Creating output file at: \(workURL.path)") - + // After processing is complete Logger.debug("AudioProcessor", "Trim operation completed") Logger.debug("AudioProcessor", "- Output file: \(workURL.path)") @@ -875,6 +879,8 @@ public class AudioProcessor { } try promoteWorkFile() + // Reached only when targetBitDepth == inputBitDepth, so the file's real + // depth is also the requested one. return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(inputSampleRate), channels: inputChannels, bitDepth: inputBitDepth, bitrate: bitrate) } else { // Non-fast path: Decode and re-encode @@ -941,11 +947,11 @@ public class AudioProcessor { // Break down complex expressions let startTimeInSeconds = range[0] / 1000 let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate) - + let endTimeInSeconds = range[1] / 1000 let endFramePosition = endTimeInSeconds * inputSampleRate let frameCount = AVAudioFrameCount(endFramePosition - Double(startFrame)) - + // Throw rather than continue: skipping a range would return a // successful result missing the audio the caller asked to keep. guard let buffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount) else { @@ -1045,7 +1051,7 @@ public class AudioProcessor { } else { // Use AAC instead of Opus (Opus support removed) Logger.debug("AudioProcessor", "Using AAC format instead of requested \(formatStr)") - + // Keep the existing AAC settings structure for consistency let outputSettings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, @@ -1055,28 +1061,59 @@ public class AudioProcessor { AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] let _ = AVFileType.m4a // Changed from fileType - + // 4. Update container extension logic for when Opus was selected let _ = "m4a" // Changed from tempFileExtension - + // 5. Update the MIME type logic for AAC only let _ = "audio/mp4" // Changed from mimeType - + try autoreleasepool { + // Ask about the requested rate before deferring to whatever the writer + // resolves. An AAC writer turns an unsupported rate into one it likes — + // 1Hz becomes 8kHz — and that resolved conversion succeeds, so checking + // only the writer's format would silently produce 8kHz for a request the + // platform cannot serve (#451, same shape as the WAV path). + guard let requestedFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: targetSampleRate, + channels: AVAudioChannelCount(targetChannels), + interleaved: false + ), AVAudioConverter(from: inputFormat, to: requestedFormat) != nil else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Cannot convert to \(targetSampleRate)Hz " + + "from this file's \(inputFormat.sampleRate)Hz"] + ) + } + // The encoder rejects bitrates its profile cannot serve at the target // rate — measured: 96k+ at 22.05kHz mono fails with error 560226676, - // while omitting the key always succeeds. So honour the request when - // the encoder accepts it, and fall back to its default when it does - // not, rather than guessing the profile's ceiling (#451). + // while omitting the key always succeeds. An explicit request that the + // encoder refuses is an error, since the result carries no effective + // bitrate and a debug log is not caller-visible. Only the library's own + // 128000 default gives way, and only then is the encoder default + // intentional. let outputFile: AVAudioFile do { outputFile = try AVAudioFile(forWriting: workURL, settings: outputSettings) } catch { + if requestedBitrate != nil { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "The encoder cannot use \(bitrate)bps at " + + "\(Int(targetSampleRate))Hz with \(targetChannels) channel(s)"] + ) + } var withoutBitrate = outputSettings withoutBitrate.removeValue(forKey: AVEncoderBitRateKey) Logger.debug( "AudioProcessor", - "Encoder rejected \(bitrate)bps at \(targetSampleRate)Hz; using its default" + "Default \(bitrate)bps unusable at \(targetSampleRate)Hz; using the encoder's" ) outputFile = try AVAudioFile(forWriting: workURL, settings: withoutBitrate) } @@ -1181,13 +1218,13 @@ public class AudioProcessor { try promoteWorkFile() return createTrimResult( - from: outputURL, - keepRanges: keepRanges, - formatStr: formatStr, - sampleRate: Int(targetSampleRate), - channels: targetChannels, - bitDepth: targetBitDepth, - bitrate: bitrate, + from: outputURL, + keepRanges: keepRanges, + formatStr: formatStr, + sampleRate: Int(targetSampleRate), + channels: targetChannels, + bitDepth: targetBitDepth, + bitrate: bitrate, compression: nil ) } @@ -1200,7 +1237,7 @@ public class AudioProcessor { return nil } } - + /// Clamps a range to the file, so the frame conversions downstream cannot overflow. /// /// The bridged values are only checked for Int representability, and every caller then @@ -1337,9 +1374,9 @@ public class AudioProcessor { blockBufferOut: &dataBuffer ) guard let blockBuf = dataBuffer else { return nil } - + CMSampleBufferSetDataBuffer(sampleBuf, newValue: blockBuf) - + return sampleBuf } @@ -1474,59 +1511,59 @@ public class AudioProcessor { reject("FILE_NOT_INITIALIZED", "Audio file is not initialized.") return nil } - + let sampleRate = Float(audioFile.fileFormat.sampleRate) let totalDurationMs = Double(audioFile.length) / Double(sampleRate) * 1000 - + // Calculate effective time range let effectiveStartMs = startTimeMs ?? 0.0 let effectiveEndMs = min(endTimeMs ?? totalDurationMs, totalDurationMs) let durationMs = effectiveEndMs - effectiveStartMs // This is the actual duration we want to use - + // Convert time to frames with proper offset let startFrame = AVAudioFramePosition(effectiveStartMs * Double(sampleRate) / 1000.0) let endFrame = AVAudioFramePosition(effectiveEndMs * Double(sampleRate) / 1000.0) let samplesInRange = Int(endFrame - startFrame) - + guard samplesInRange > 0 else { reject("INVALID_RANGE", "Invalid sample range: contains no samples") return nil } - + // Calculate exact samples per point to get the requested number of points let samplesPerPoint = samplesInRange / numberOfPoints var dataPoints = [DataPoint]() dataPoints.reserveCapacity(numberOfPoints) - + var minAmplitude: Float = .greatestFiniteMagnitude var maxAmplitude: Float = -.greatestFiniteMagnitude - + let bytesPerSample = audioFile.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int ?? 16 / 8 - + for i in 0.. Date: Fri, 21 Aug 2026 16:33:33 +0800 Subject: [PATCH 3/9] fix(audio-studio/ios): refuse an AAC rate the encoder would silently substitute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The requested-format check proved a PCM conversion existed, not that the encoder would use the rate asked for. Probed: the AAC writer resolves 1Hz and 7999Hz to 8000Hz, and 384000Hz to 192000Hz — all of which passed the PCM check and would have returned audio at a rate the caller never requested. The writer's resolved rate is now compared against the request and a substitution refused. On device: aac@1 and aac@384000 reject, while aac@8000/22050/48000 succeed at exactly the rate requested. Changelog corrected — it still described bitrate fallback as the behaviour when an explicit request now errors. --- packages/audio-studio/CHANGELOG.md | 2 +- packages/audio-studio/ios/AudioProcessor.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/audio-studio/CHANGELOG.md b/packages/audio-studio/CHANGELOG.md index e12808ad3..057fe56cf 100644 --- a/packages/audio-studio/CHANGELOG.md +++ b/packages/audio-studio/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- iOS `trimAudio` AAC output now honours the requested sample rate. Writing source-format buffers to a target-rate writer mis-timed the output — one second at 44.1kHz came back as 2.0s at 22.05kHz and 0.92s at 48kHz. The AAC path now converts against the writer's resolved format, the same treatment the WAV path received. Bitrates the encoder rejects at the target rate fall back to its default instead of failing the trim (#451). +- iOS `trimAudio` AAC output now honours the requested sample rate. Writing source-format buffers to a target-rate writer mis-timed the output — one second at 44.1kHz came back as 2.0s at 22.05kHz and 0.92s at 48kHz. The AAC path now converts against the writer's resolved format, the same treatment the WAV path received. An explicitly requested bitrate the encoder cannot serve is now an error rather than a silent substitution; only the library's own default gives way. A requested rate the AAC encoder would resolve to something else — 1Hz to 8kHz, 384kHz to 192kHz — is refused rather than returned as success (#451). - iOS `trimAudio` preserves the input's bit depth when none is requested, and honours a bitDepth-only request instead of ignoring it via the fast path. An omitted depth previously forced 16-bit during any rate or channel change, contrary to the documented contract (#451). ### Added diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index a00a2d444..65bc836b9 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -1123,6 +1123,21 @@ public class AudioProcessor { // contract and mis-times the output: probed at 44.1kHz source, one second // came back as 2.0000s at 22.05kHz and 0.9187s at 48kHz (#451). let writerFormat = outputFile.processingFormat + + // The PCM check above proves a conversion exists; it does not prove the + // encoder will use the rate asked for. Probed: the AAC writer silently + // resolves 1Hz and 7999Hz to 8000Hz, and 384000Hz to 192000Hz. Returning + // that as success would hand back audio at a rate the caller never + // requested, so the substitution is refused here rather than reported. + guard writerFormat.sampleRate == targetSampleRate else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "The AAC encoder cannot use \(Int(targetSampleRate))Hz; " + + "it resolved to \(Int(writerFormat.sampleRate))Hz"] + ) + } guard AVAudioConverter(from: inputFormat, to: writerFormat) != nil else { throw NSError( domain: "AudioProcessor", From 6cfa10984c5b35485c61ca081c2e56cc7b37b72f Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 21 Aug 2026 17:08:20 +0800 Subject: [PATCH 4/9] fix(audio-studio/ios): preserve real bit depth, reject empty conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found three defects, each reproduced with an AVFoundation probe. The fast path claimed to preserve the input and was the one place not doing it. It wrote `inputFormat.settings`, where inputFormat is processingFormat — float32 for every PCM WAV. Probe: a genuine 16-bit source produced a 32-bit float output while the result reported bitDepth 16. Now writes fileFormat.settings. The [16, 32] writable-depth allowlist was wrong. Probed each depth against AVAudioFile: 8, 16, 24 and 32 all round-trip at the requested depth. The allowlist silently downconverted 8- and 24-bit sources to 16 with nothing requested, contradicting the documented preserve-input contract. The AAC conversion loop discarded the converter status and accepted zero output frames. A 44.1kHz-to-8kHz conversion of 1-4 input frames returns .endOfStream with no error and zero frames; the loop wrote that buffer and counted the input frames, so the reported duration described audio the file does not contain. Now checks the status, skips empty buffers, counts written frames, and refuses to promote a file that received none. Two more found while fixing those, unrelated to #451: - `?? 16 / 8` parsed as `?? (16 / 8)`, so with the bit-depth key present bytesPerSample was 16 rather than 2 and every byte offset in the preview loop came out eight times too large. - In the AAC block, totalFrames started at zero and grew as work completed while cumulativeFrames was never incremented there at all — progress was a stale outer value divided by a moving total. The denominator is now precomputed and input consumed is tracked separately from frames written. Coverage: AudioProcessor.swift cannot join the SwiftPM test target (it pulls in the Expo module graph), which is why every one of these shipped untested. The format decisions are extracted into TrimFormatResolution and used by AudioProcessor, with 8 tests over the depth and fast-path rules. 91 iOS tests pass, up from 83. --- packages/audio-studio/Package.swift | 2 + .../audio-studio/ios/AudioProcessor.swift | 102 ++++++++++++++---- .../TrimFormatResolutionTests.swift | 67 ++++++++++++ .../ios/TrimFormatResolution.swift | 48 +++++++++ 4 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 packages/audio-studio/ios/AudioStudioTests/TrimFormatResolutionTests.swift create mode 100644 packages/audio-studio/ios/TrimFormatResolution.swift diff --git a/packages/audio-studio/Package.swift b/packages/audio-studio/Package.swift index 8a156aca5..40083588a 100644 --- a/packages/audio-studio/Package.swift +++ b/packages/audio-studio/Package.swift @@ -28,6 +28,7 @@ let package = Package( "BridgedNarrowing.swift", "OutputPromotion.swift", "SafeFilename.swift", + "TrimFormatResolution.swift", ] ), .testTarget( @@ -40,6 +41,7 @@ let package = Package( "ConverterCapabilityTests.swift", "OutputPromotionTests.swift", "SafeFilenameTests.swift", + "TrimFormatResolutionTests.swift", ] ), ] diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index 65bc836b9..461add872 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -784,11 +784,17 @@ public class AudioProcessor { // 24-bit input look like 16, so an explicit bitDepth: 16 request compared equal, // took the fast path, kept 24 bits and reported 16 (#451). let inputBitDepth = Int(audioFile.fileFormat.streamDescription.pointee.mBitsPerChannel) - // What a WAV writer here can actually emit. An omitted request preserves the input - // where that is expressible, rather than silently forcing 16 during any rate or - // channel change. - let defaultBitDepth = [16, 32].contains(inputBitDepth) ? inputBitDepth : 16 - let targetBitDepth = outputFormat.flatMap { bridgedInt($0, "bitDepth").flatMap { [16, 32].contains($0) ? $0 : nil } } ?? defaultBitDepth + // What a WAV writer here can actually emit. Probed against AVAudioFile: 8, 16, 24 + // and 32 each round-trip at the requested depth, so an omitted request preserves + // any of them rather than silently forcing 16 during a rate or channel change. + // The earlier [16, 32] allowlist downconverted 8- and 24-bit sources nobody asked + // to convert, contradicting the documented preserve-input contract (#451). + // Resolved by TrimFormatResolution so the rule is covered by tests: this file + // cannot join the SwiftPM test target, and every bit-depth bug in #451 lived here. + let targetBitDepth = TrimFormatResolution.targetBitDepth( + requested: outputFormat.flatMap { bridgedInt($0, "bitDepth") }, + inputBitDepth: inputBitDepth + ) let requestedBitrate = outputFormat.flatMap { bridgedInt($0, "bitrate").flatMap { $0 > 0 ? $0 : nil } } let bitrate = requestedBitrate ?? 128000 @@ -817,13 +823,13 @@ public class AudioProcessor { // Compare what was actually resolved, not just decodingOptions. outputFormat is a // separate parameter, so a sampleRate or channel change requested there took the // WAV fast path and was silently ignored (#433). - let outputDiffersFromInput = targetSampleRate != inputSampleRate - || targetChannels != inputChannels - // The fast path writes with inputFormat.settings, so a depth change must not - // take it — a bitDepth-only request was previously ignored outright. Compared - // against the file's real depth: a 24-bit source asked for 16 must convert, - // not pass through as 24 (#451). - || targetBitDepth != inputBitDepth + // Includes bitDepth, compared against the file's real depth: a bitDepth-only + // request used to take the fast path and be ignored outright (#451). + let outputDiffersFromInput = TrimFormatResolution.outputDiffersFromInput( + targetSampleRate: targetSampleRate, inputSampleRate: inputSampleRate, + targetChannels: targetChannels, inputChannels: inputChannels, + targetBitDepth: targetBitDepth, inputBitDepth: inputBitDepth + ) let needFormatChange = decodingConfig.targetSampleRate != nil || decodingConfig.targetChannels != nil || decodingConfig.targetBitDepth != nil @@ -837,7 +843,12 @@ public class AudioProcessor { // AVAudioFile reports length 0 for a file whose writer is still alive, so // reopening too early reported durationMs: 0 for a successful trim (#433). try autoreleasepool { - let outputFile = try AVAudioFile(forWriting: workURL, settings: inputFormat.settings) + // fileFormat.settings, not inputFormat (== processingFormat) .settings. + // processingFormat is float32 for every PCM WAV, so writing its settings + // turned a 16-bit source into a 32-bit float file while the result below + // still reported 16 — the fast path was the one place claiming to preserve + // the input and the one place not doing it (#451). + let outputFile = try AVAudioFile(forWriting: workURL, settings: audioFile.fileFormat.settings) var totalFrames: Int64 = 0 for range in keepRanges { // Break down complex expression @@ -1148,7 +1159,20 @@ public class AudioProcessor { ) } + // Total input frames to process, computed up front so progress has a + // fixed denominator. It previously started at zero and grew as work + // completed, while cumulativeFrames was never incremented in this block + // at all — the progress fraction was a stale outer value over a moving + // total. Written frames are tracked separately below. var totalFrames: Int64 = 0 + for range in keepRanges { + let startFrame = AVAudioFramePosition(range[0] / 1000 * inputSampleRate) + let endFramePosition = range[1] / 1000 * inputSampleRate + totalFrames += Int64(AVAudioFrameCount(endFramePosition - Double(startFrame))) + } + var processedFrames: Int64 = 0 + var writtenFrames: Int64 = 0 + for range in keepRanges { let startTimeInSeconds = range[0] / 1000 let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate) @@ -1206,7 +1230,7 @@ public class AudioProcessor { var suppliedInput = false var conversionError: NSError? - _ = converter.convert(to: converted, error: &conversionError) { _, outStatus in + let conversionStatus = converter.convert(to: converted, error: &conversionError) { _, outStatus in if suppliedInput { outStatus.pointee = .endOfStream return nil @@ -1223,12 +1247,50 @@ public class AudioProcessor { "Format conversion failed: \(conversionError.localizedDescription)"] ) } + if conversionStatus == .error { + // .error with no NSError set. Continuing here would write an + // unpopulated buffer and report success. + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Format conversion failed."] + ) + } - try outputFile.write(from: converted) - totalFrames += Int64(frameCount) - let progress = Float(cumulativeFrames) / Float(totalFrames) * 100 + // Progress tracks input consumed, so it advances even for a range + // that produces no output. + processedFrames += Int64(frameCount) + let progress = totalFrames > 0 + ? Float(processedFrames) / Float(totalFrames) * 100 + : 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) + + // A downsampling converter given very few input frames returns + // .endOfStream with no error and zero output frames — probed at + // 44.1kHz to 8kHz, which produces nothing for inputs of 1 to 4 + // frames. Writing that buffer added no audio while its input frames + // were still counted, so the reported duration described audio the + // file does not contain (#451). Skip it, and count what was + // actually written rather than what was read. + guard converted.frameLength > 0 else { continue } + + try outputFile.write(from: converted) + writtenFrames += Int64(converted.frameLength) + } } + + // Nothing was written: every conversion produced zero frames. Promoting + // here would hand back a file with no audio and a duration derived from + // the requested ranges (#451). + guard writtenFrames > 0 else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Trimming produced no audio: converting " + + "\(Int(inputSampleRate))Hz to \(Int(targetSampleRate))Hz " + + "yielded no output frames for the requested ranges"] + ) } try promoteWorkFile() @@ -1553,7 +1615,11 @@ public class AudioProcessor { var minAmplitude: Float = .greatestFiniteMagnitude var maxAmplitude: Float = -.greatestFiniteMagnitude - let bytesPerSample = audioFile.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int ?? 16 / 8 + // `?? 16 / 8` parsed as `?? (16 / 8)`, so a present 16-bit key made this 16 rather + // than 2 and every byte position below came out eight times too large. The divide + // has to apply to the resolved depth, not just the fallback. Unrelated to #451, + // found while auditing the bit-depth reads. + let bytesPerSample = (audioFile.fileFormat.settings[AVLinearPCMBitDepthKey] as? Int ?? 16) / 8 for i in 0.. Int { + writableBitDepths.contains(inputBitDepth) ? inputBitDepth : 16 + } + + /// The depth to write, honouring an explicit request only when it is writable. + static func targetBitDepth(requested: Int?, inputBitDepth: Int) -> Int { + if let requested = requested, writableBitDepths.contains(requested) { + return requested + } + return defaultBitDepth(inputBitDepth: inputBitDepth) + } + + /// Whether the output differs from the input in any way the WAV fast path cannot honour. + /// + /// The fast path copies frames without converting, so anything true here must take the + /// decode-and-re-encode path instead. `bitDepth` belongs in this comparison: a + /// depth-only request used to take the fast path and be ignored outright. + static func outputDiffersFromInput( + targetSampleRate: Double, inputSampleRate: Double, + targetChannels: Int, inputChannels: Int, + targetBitDepth: Int, inputBitDepth: Int + ) -> Bool { + targetSampleRate != inputSampleRate + || targetChannels != inputChannels + || targetBitDepth != inputBitDepth + } +} From 15f762ee530087e76f85b45609e978e43aca9c17 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 21 Aug 2026 17:49:18 +0800 Subject: [PATCH 5/9] fix(audio-studio/ios): fix the scope error, guard the WAV path, share one converter helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review found two defects. The first is one I should have caught: the production target did not compile. `writtenFrames` was declared inside the AAC autoreleasepool closure and read by the empty-output guard after it — `cannot find 'writtenFrames' in scope`. The declaration is now outside the closure. I reported "91 iOS tests pass" as evidence last round, and that was worthless here: Package.swift does not list AudioProcessor.swift (it pulls in the Expo module graph), so the suite never compiled the file I changed. Verified this time by building the app target: BUILD SUCCEEDED with the file compiled for real. The WAV re-encode path still promoted empty output as success. It had the same zero-frame defect fixed on the AAC path last round — discarded converter status, wrote zero-frame buffers, promoted unconditionally. The reviewer's probe produced a promoted 4096-byte WAV with length 0 from a 44.1kHz-to-8kHz conversion of 1 to 4 frames. Rather than fix it twice, both loops now call one `convertOneBuffer` helper that owns the supply-once callback, the error check, the status check and the frame count. The duplication is what let the two paths drift in the first place — one got the guard, the other did not — which was also the reviewer's nit. Also required a `pod install`: the podspec glob covers TrimFormatResolution.swift but the existing pod project predated it, so the build reported "Build input file cannot be found" until the project was regenerated. 91 iOS tests pass, and the app target builds. --- .../audio-studio/ios/AudioProcessor.swift | 148 ++++++++++-------- 1 file changed, 86 insertions(+), 62 deletions(-) diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index 461add872..5ad61e5e3 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -914,6 +914,10 @@ public class AudioProcessor { var cumulativeFrames: Int64 = 0 if formatStr == "wav" { + // Outside the autoreleasepool: the empty-output guard reads it after + // the closure returns. + var wavWrittenFrames: Int64 = 0 + // Scoped for the same reason as the fast path above. try autoreleasepool { let outputFile = try AVAudioFile(forWriting: workURL, settings: [ @@ -1027,36 +1031,33 @@ public class AudioProcessor { "Could not allocate a \(outputFrameCapacity)-frame output buffer"] ) } - // Supply the input once, then report end of stream. Returning the - // same buffer with .haveData forever made the converter re-consume - // it, so a downsample emitted more audio than it was given. - var suppliedInput = false - var error: NSError? - _ = converter.convert(to: convertedBuffer, error: &error) { _, outStatus in - if suppliedInput { - outStatus.pointee = .endOfStream - return nil - } - suppliedInput = true - outStatus.pointee = .haveData - return buffer - } - if let error = error { - // Skipping produced a successful result missing this range. - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "Format conversion failed: \(error.localizedDescription)"] - ) - } - try outputFile.write(from: convertedBuffer) + let produced = try AudioProcessor.convertOneBuffer( + converter, from: buffer, into: convertedBuffer + ) + cumulativeFrames += Int64(frameCount) let progress = Float(cumulativeFrames) / Float(totalFrames) * 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) + + guard produced > 0 else { continue } + try outputFile.write(from: convertedBuffer) + wavWrittenFrames += Int64(produced) } } + // Same guard as the AAC path: promoting here would hand back a file + // with no audio and a duration derived from the requested ranges. + guard wavWrittenFrames > 0 else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Trimming produced no audio: converting " + + "\(Int(inputSampleRate))Hz to \(Int(targetSampleRate))Hz " + + "yielded no output frames for the requested ranges"] + ) + } + try promoteWorkFile() return createTrimResult(from: outputURL, keepRanges: keepRanges, formatStr: formatStr, sampleRate: Int(targetSampleRate), channels: targetChannels, bitDepth: targetBitDepth, bitrate: bitrate) } else { @@ -1079,6 +1080,10 @@ public class AudioProcessor { // 5. Update the MIME type logic for AAC only let _ = "audio/mp4" // Changed from mimeType + // Outside the autoreleasepool below: the empty-output guard that reads + // this runs after the closure returns. + var writtenFrames: Int64 = 0 + try autoreleasepool { // Ask about the requested rate before deferring to whatever the writer // resolves. An AAC writer turns an unsupported rate into one it likes — @@ -1171,7 +1176,6 @@ public class AudioProcessor { totalFrames += Int64(AVAudioFrameCount(endFramePosition - Double(startFrame))) } var processedFrames: Int64 = 0 - var writtenFrames: Int64 = 0 for range in keepRanges { let startTimeInSeconds = range[0] / 1000 @@ -1228,34 +1232,9 @@ public class AudioProcessor { ) } - var suppliedInput = false - var conversionError: NSError? - let conversionStatus = converter.convert(to: converted, error: &conversionError) { _, outStatus in - if suppliedInput { - outStatus.pointee = .endOfStream - return nil - } - suppliedInput = true - outStatus.pointee = .haveData - return buffer - } - if let conversionError = conversionError { - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "Format conversion failed: \(conversionError.localizedDescription)"] - ) - } - if conversionStatus == .error { - // .error with no NSError set. Continuing here would write an - // unpopulated buffer and report success. - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Format conversion failed."] - ) - } + let produced = try AudioProcessor.convertOneBuffer( + converter, from: buffer, into: converted + ) // Progress tracks input consumed, so it advances even for a range // that produces no output. @@ -1265,17 +1244,12 @@ public class AudioProcessor { : 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) - // A downsampling converter given very few input frames returns - // .endOfStream with no error and zero output frames — probed at - // 44.1kHz to 8kHz, which produces nothing for inputs of 1 to 4 - // frames. Writing that buffer added no audio while its input frames - // were still counted, so the reported duration described audio the - // file does not contain (#451). Skip it, and count what was - // actually written rather than what was read. - guard converted.frameLength > 0 else { continue } + // Skip empty output and count what was written, not what was + // read. See convertOneBuffer for why zero frames happen. + guard produced > 0 else { continue } try outputFile.write(from: converted) - writtenFrames += Int64(converted.frameLength) + writtenFrames += Int64(produced) } } @@ -1327,6 +1301,56 @@ public class AudioProcessor { return [start, end] } + + /// Converts one buffer and reports how many frames came out. + /// + /// Shared by the WAV and AAC re-encode loops. They previously carried their own copies + /// of this and had already drifted: only one checked the converter status, and only one + /// refused zero-frame output (#451). + /// + /// A downsampling converter given very few input frames returns `.endOfStream` with no + /// error and zero output frames — 44.1kHz to 8kHz produces nothing for inputs of 1 to 4 + /// frames. Writing that buffer adds no audio while its input frames still count toward + /// the reported duration, so the result describes audio the file does not contain. + private static func convertOneBuffer( + _ converter: AVAudioConverter, + from buffer: AVAudioPCMBuffer, + into converted: AVAudioPCMBuffer + ) throws -> AVAudioFrameCount { + // Supply the input once, then report end of stream. Returning the same buffer with + // .haveData forever made the converter re-consume it, so a downsample emitted more + // audio than it was given. + var suppliedInput = false + var conversionError: NSError? + let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in + if suppliedInput { + outStatus.pointee = .endOfStream + return nil + } + suppliedInput = true + outStatus.pointee = .haveData + return buffer + } + if let conversionError = conversionError { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Format conversion failed: \(conversionError.localizedDescription)"] + ) + } + if status == .error { + // .error with no NSError set. Continuing would write an unpopulated buffer and + // report success. + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Format conversion failed."] + ) + } + return converted.frameLength + } + private func computeKeepRanges(mode: String, startTimeMs: Double?, endTimeMs: Double?, ranges: [[String: Double]]?, totalDurationMs: Double) -> [[Double]] { let clamped = computeRawKeepRanges( mode: mode, From 5a71684c516efbb696906fa0de9fd05c457b652f Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Fri, 21 Aug 2026 23:27:08 +0800 Subject: [PATCH 6/9] ci: record TrimFormatResolution.swift in the published manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch adds ios/TrimFormatResolution.swift, and the podspec glob publishes it, so the package now ships 439 files rather than 438. The manifest check merged in #469 caught exactly that — which is the case it was written for. Regenerated: one line added, nothing else changed. --- .package-manifests/siteed__audio-studio.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.package-manifests/siteed__audio-studio.txt b/.package-manifests/siteed__audio-studio.txt index 8b1d1dd95..364df0b74 100644 --- a/.package-manifests/siteed__audio-studio.txt +++ b/.package-manifests/siteed__audio-studio.txt @@ -375,6 +375,7 @@ ios/PrimaryWriteFailurePolicy.swift ios/RecordingResult.swift ios/RecordingSettings.swift ios/SafeFilename.swift +ios/TrimFormatResolution.swift ios/WaveformExtractor.swift package.json plugin/build/index.cjs From 62e62cd7b04bfea2eb3898b867f61be635bc15c9 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Sat, 22 Aug 2026 11:20:07 +0800 Subject: [PATCH 7/9] fix(audio-studio/ios): keep one converter across kept ranges, drain its tail Review found multi-range trims produce different audio depending on how the selection is split. Both re-encode loops built a fresh AVAudioConverter per range, and convertOneBuffer signalled .endOfStream after each buffer, so the resampler's filter state and fractional sample position were discarded between ranges. Measured at 44.1 to 48kHz, converting 4410 frames: as one range 4800 frames as 100 adjacent 44-frame ranges 47 frames Two changes. The converter is created once, before the loop, in both the WAV and AAC paths. And convertOneBuffer now reports .noDataNow rather than .endOfStream when its single buffer is consumed, which keeps the stream open instead of finalizing the resampler. .endOfStream was the larger half of the problem: hoisting alone does nothing while every call still ends the stream. .noDataNow leaves samples buffered when the ranges run out, so both loops now drain the converter afterwards. That recovers the tail: as one range 4800 as 100 ranges, after the fix 4789 ideal 4800 0.2% short across a hundred splices rather than 99%. The first version of the drain compiled under `yarn test:ios` and failed the real build with three "cannot find in scope" errors, because it sat outside the autoreleasepool holding outputFile and the converter. That suite does not compile AudioProcessor.swift, which is why the app target is the check that counts here. 91 SwiftPM tests pass and the app target builds with AudioProcessor.swift compiled. --- .../audio-studio/ios/AudioProcessor.swift | 119 ++++++++++++++---- 1 file changed, 94 insertions(+), 25 deletions(-) diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index 5ad61e5e3..fb75033e1 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -958,6 +958,24 @@ public class AudioProcessor { ) } + // One converter for every range, not one per range. Rebuilding it + // per range discarded the resampler's filter state and fractional + // sample position, so the same audio selected as one range or as + // many produced different output (#451). + guard let converter = AVAudioConverter(from: inputFormat, to: writerFormat) else { + Logger.debug( + "AudioProcessor", + "Cannot convert \(inputFormat.sampleRate)Hz to \(targetFormat.sampleRate)Hz" + ) + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Cannot convert to \(writerFormat.sampleRate)Hz " + + "from this file's \(inputFormat.sampleRate)Hz"] + ) + } + for range in keepRanges { // Break down complex expressions let startTimeInSeconds = range[0] / 1000 @@ -984,19 +1002,6 @@ public class AudioProcessor { // from a 44.1kHz source while 2MHz succeeds, and the answer differs // between OS versions — so no static range can stand in for asking // (#433). - guard let converter = AVAudioConverter(from: inputFormat, to: writerFormat) else { - Logger.debug( - "AudioProcessor", - "Cannot convert \(inputFormat.sampleRate)Hz to \(targetFormat.sampleRate)Hz" - ) - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "Cannot convert to \(writerFormat.sampleRate)Hz " - + "from this file's \(inputFormat.sampleRate)Hz"] - ) - } // Mix the channels rather than taking the first. The default // discards the others, so right-only stereo material came back as // silence once a channel-only request started routing through here. @@ -1043,6 +1048,12 @@ public class AudioProcessor { try outputFile.write(from: convertedBuffer) wavWrittenFrames += Int64(produced) } + + // Same as the AAC path: flush what the converter still holds once the + // ranges run out. Inside the autoreleasepool, where outputFile lives. + wavWrittenFrames += try AudioProcessor.drainConverter(converter, into: writerFormat) { + try outputFile.write(from: $0) + } } // Same guard as the AAC path: promoting here would hand back a file @@ -1177,6 +1188,20 @@ public class AudioProcessor { } var processedFrames: Int64 = 0 + // One converter for every range, not one per range. A resampler carries + // filter state and a fractional sample position between calls, so + // rebuilding it per range discarded both: the same audio selected as one + // range or as a hundred adjacent ones produced different frame counts, + // and ranges shorter than the filter needs produced nothing at all (#451). + guard let converter = AVAudioConverter(from: inputFormat, to: writerFormat) else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Cannot convert to \(writerFormat.sampleRate)Hz"] + ) + } + for range in keepRanges { let startTimeInSeconds = range[0] / 1000 let startFrame = AVAudioFramePosition(startTimeInSeconds * inputSampleRate) @@ -1196,14 +1221,7 @@ public class AudioProcessor { audioFile.framePosition = startFrame try audioFile.read(into: buffer, frameCount: frameCount) - guard let converter = AVAudioConverter(from: inputFormat, to: writerFormat) else { - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "Cannot convert to \(writerFormat.sampleRate)Hz"] - ) - } + if writerFormat.channelCount < inputFormat.channelCount { converter.downmix = true } @@ -1251,6 +1269,12 @@ public class AudioProcessor { try outputFile.write(from: converted) writtenFrames += Int64(produced) } + + // The converter still holds buffered samples once the ranges run out. + // Inside the autoreleasepool: outputFile and the converter live here. + writtenFrames += try AudioProcessor.drainConverter(converter, into: writerFormat) { + try outputFile.write(from: $0) + } } // Nothing was written: every conversion produced zero frames. Promoting @@ -1302,6 +1326,42 @@ public class AudioProcessor { } + + /// Flushes whatever the converter still holds after the last input buffer. + /// + /// `.noDataNow` keeps the stream open so one converter can span every kept range, but + /// it also means the resampler is still holding samples when the ranges run out. + /// Measured at 44.1 to 48kHz over 100 buffers: 4771 frames come out during conversion + /// and 18 more from this drain, against an ideal 4800 (#451). + private static func drainConverter( + _ converter: AVAudioConverter, + into format: AVAudioFormat, + write: (AVAudioPCMBuffer) throws -> Void + ) throws -> Int64 { + var drained: Int64 = 0 + while true { + guard let tail = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4096) else { break } + var error: NSError? + let status = converter.convert(to: tail, error: &error) { _, outStatus in + outStatus.pointee = .endOfStream + return nil + } + if let error = error { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Failed to flush the converter: \(error.localizedDescription)"] + ) + } + if tail.frameLength == 0 { break } + try write(tail) + drained += Int64(tail.frameLength) + if status == .endOfStream || status == .error { break } + } + return drained + } + /// Converts one buffer and reports how many frames came out. /// /// Shared by the WAV and AAC re-encode loops. They previously carried their own copies @@ -1317,14 +1377,21 @@ public class AudioProcessor { from buffer: AVAudioPCMBuffer, into converted: AVAudioPCMBuffer ) throws -> AVAudioFrameCount { - // Supply the input once, then report end of stream. Returning the same buffer with - // .haveData forever made the converter re-consume it, so a downsample emitted more - // audio than it was given. + // Supply the input once, then report that no more is available *right now*. + // + // Returning the same buffer with .haveData forever made the converter re-consume + // it, so a downsample emitted more audio than it was given. But .endOfStream is + // wrong too: it finalizes the resampler, discarding the filter state and + // fractional sample position that the next call needs. Measured at 44.1 to 48kHz, + // converting 4410 frames as one buffer yields 4800 frames, while the same audio in + // 100 buffers yields 47 with .endOfStream and 4771 with .noDataNow (#451). + // + // .noDataNow leaves the stream open, so one converter can span every kept range. var suppliedInput = false var conversionError: NSError? let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in if suppliedInput { - outStatus.pointee = .endOfStream + outStatus.pointee = .noDataNow return nil } suppliedInput = true @@ -1339,6 +1406,8 @@ public class AudioProcessor { "Format conversion failed: \(conversionError.localizedDescription)"] ) } + // .inputRanDry is the expected result now: the converter consumed what it was + // given and is waiting for more, which is exactly what we want between ranges. if status == .error { // .error with no NSError set. Continuing would write an unpopulated buffer and // report success. From 1e67106d5d9cb1bcd43a206e9dfdca9a3addbed4 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Sat, 22 Aug 2026 11:40:03 +0800 Subject: [PATCH 8/9] fix(audio-studio/ios): consume every input buffer, validate the finalized rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review, the first a direct consequence of my last commit. Switching to .noDataNow let AVAudioConverter fill the destination entirely from output it had already queued, returning without ever invoking the callback. The input buffer went unconsumed while the loop advanced to the next range and dropped it. Probed on the simulator: ranges [4410, 44] produced 4800 frames against 4848 expected, and [4410, 44 x 10] produced 4943 against 5279. convertOneBuffer now converts until the callback has actually taken the input, writing each output buffer as it appears — one input can yield several, so both call sites stopped assuming the destination holds all of it. The AAC writer echoes the requested sample rate while finalization can substitute a different one. Probed: 8001 to 8000, 22051 to 22050, 44099 and 44101 to 44100, 48001 to 48000. The work file is now reopened and its rate checked before promotion, so the result cannot report a rate the file does not have. The drain broke on zero frames before checking status, so a .error with no NSError set looked like a clean end of stream and truncated output was promoted as success. Status is checked first now. A failed buffer allocation there also ended the flush silently; that is an error rather than a break, since the alternative is silent truncation. testEveryProbedDepthIsWritable compared the constant to itself while claiming to have verified against AVAudioFile. It now writes and reopens a WAV at each of 8, 16, 24 and 32 bits and asserts the depth survives. 91 SwiftPM tests pass and the app target builds with AudioProcessor.swift compiled. On the simulator, trimAudio produces 5.000s and 4.000s outputs, both Int16, the second being the multi-range case these fixes are about. --- .../audio-studio/ios/AudioProcessor.swift | 147 ++++++++++++------ .../TrimFormatResolutionTests.swift | 36 ++++- 2 files changed, 131 insertions(+), 52 deletions(-) diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index fb75033e1..6fb4ee4bb 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -1036,17 +1036,16 @@ public class AudioProcessor { "Could not allocate a \(outputFrameCapacity)-frame output buffer"] ) } - let produced = try AudioProcessor.convertOneBuffer( + // The helper writes each output buffer as it is produced: one + // input can yield several, and the caller must not assume the + // destination holds all of it. + wavWrittenFrames += try AudioProcessor.convertOneBuffer( converter, from: buffer, into: convertedBuffer - ) + ) { try outputFile.write(from: $0) } cumulativeFrames += Int64(frameCount) let progress = Float(cumulativeFrames) / Float(totalFrames) * 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) - - guard produced > 0 else { continue } - try outputFile.write(from: convertedBuffer) - wavWrittenFrames += Int64(produced) } // Same as the AAC path: flush what the converter still holds once the @@ -1250,9 +1249,10 @@ public class AudioProcessor { ) } - let produced = try AudioProcessor.convertOneBuffer( + // The helper writes each output buffer as it is produced. + writtenFrames += try AudioProcessor.convertOneBuffer( converter, from: buffer, into: converted - ) + ) { try outputFile.write(from: $0) } // Progress tracks input consumed, so it advances even for a range // that produces no output. @@ -1261,13 +1261,6 @@ public class AudioProcessor { ? Float(processedFrames) / Float(totalFrames) * 100 : 100 progressCallback?(progress, 0, totalFrames * Int64(inputFormat.streamDescription.pointee.mBytesPerFrame)) - - // Skip empty output and count what was written, not what was - // read. See convertOneBuffer for why zero frames happen. - guard produced > 0 else { continue } - - try outputFile.write(from: converted) - writtenFrames += Int64(produced) } // The converter still holds buffered samples once the ranges run out. @@ -1291,6 +1284,24 @@ public class AudioProcessor { ) } + // The live writer echoes the requested rate, but finalization can + // substitute a different one — probed 8001 to 8000, 22051 to 22050, + // 44099 and 44101 to 44100, 48001 to 48000. Reopen and check before + // promoting, or the result reports a rate the file does not have + // (#451). + if let finalized = try? AVAudioFile(forReading: workURL) { + let actualRate = finalized.fileFormat.sampleRate + guard actualRate == targetSampleRate else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "The AAC encoder wrote \(Int(actualRate))Hz for a " + + "requested \(Int(targetSampleRate))Hz"] + ) + } + } + try promoteWorkFile() return createTrimResult( from: outputURL, @@ -1340,7 +1351,16 @@ public class AudioProcessor { ) throws -> Int64 { var drained: Int64 = 0 while true { - guard let tail = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4096) else { break } + guard let tail = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 4096) else { + // Ending the flush silently here would truncate the output and report + // success, so this is an error rather than a break. + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Could not allocate a buffer to flush the converter"] + ) + } var error: NSError? let status = converter.convert(to: tail, error: &error) { _, outStatus in outStatus.pointee = .endOfStream @@ -1354,10 +1374,20 @@ public class AudioProcessor { "Failed to flush the converter: \(error.localizedDescription)"] ) } + // Status before frame count. Breaking on zero frames first meant a .error with + // no NSError set looked like a clean end of stream, and truncated output was + // promoted as success (#451). + if status == .error { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to flush the converter."] + ) + } if tail.frameLength == 0 { break } try write(tail) drained += Int64(tail.frameLength) - if status == .endOfStream || status == .error { break } + if status == .endOfStream { break } } return drained } @@ -1375,8 +1405,9 @@ public class AudioProcessor { private static func convertOneBuffer( _ converter: AVAudioConverter, from buffer: AVAudioPCMBuffer, - into converted: AVAudioPCMBuffer - ) throws -> AVAudioFrameCount { + into converted: AVAudioPCMBuffer, + write: (AVAudioPCMBuffer) throws -> Void + ) throws -> Int64 { // Supply the input once, then report that no more is available *right now*. // // Returning the same buffer with .haveData forever made the converter re-consume @@ -1387,37 +1418,57 @@ public class AudioProcessor { // 100 buffers yields 47 with .endOfStream and 4771 with .noDataNow (#451). // // .noDataNow leaves the stream open, so one converter can span every kept range. + // One convert call is not enough. AVAudioConverter can fill the destination + // entirely from output it already has queued, returning without ever invoking the + // callback — so this buffer would go unconsumed while the caller moved on to the + // next range and dropped it. Probed on the simulator: ranges [4410, 44] produced + // 4800 frames where 4848 were expected, and [4410, 44 x 10] produced 4943 against + // 5279 (#451). Keep converting until the callback has actually taken the input. var suppliedInput = false - var conversionError: NSError? - let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in - if suppliedInput { - outStatus.pointee = .noDataNow - return nil + var totalProduced: Int64 = 0 + repeat { + var conversionError: NSError? + let status = converter.convert(to: converted, error: &conversionError) { _, outStatus in + if suppliedInput { + outStatus.pointee = .noDataNow + return nil + } + suppliedInput = true + outStatus.pointee = .haveData + return buffer } - suppliedInput = true - outStatus.pointee = .haveData - return buffer - } - if let conversionError = conversionError { - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "Format conversion failed: \(conversionError.localizedDescription)"] - ) - } - // .inputRanDry is the expected result now: the converter consumed what it was - // given and is waiting for more, which is exactly what we want between ranges. - if status == .error { - // .error with no NSError set. Continuing would write an unpopulated buffer and - // report success. - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Format conversion failed."] - ) - } - return converted.frameLength + if let conversionError = conversionError { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Format conversion failed: \(conversionError.localizedDescription)"] + ) + } + if status == .error { + // .error with no NSError set. Continuing would write an unpopulated buffer + // and report success. + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Format conversion failed."] + ) + } + if converted.frameLength > 0 { + try write(converted) + totalProduced += Int64(converted.frameLength) + } else if suppliedInput { + // Input taken and nothing came out: the resampler is buffering it, which + // is normal for a short range. Nothing more to do for this buffer. + break + } else { + // No output and the input was not taken either: the converter cannot make + // progress, so looping again would spin. + break + } + } while !suppliedInput + + return totalProduced } private func computeKeepRanges(mode: String, startTimeMs: Double?, endTimeMs: Double?, ranges: [[String: Double]]?, totalDurationMs: Double) -> [[Double]] { diff --git a/packages/audio-studio/ios/AudioStudioTests/TrimFormatResolutionTests.swift b/packages/audio-studio/ios/AudioStudioTests/TrimFormatResolutionTests.swift index aa69dac3b..5f81b5c1b 100644 --- a/packages/audio-studio/ios/AudioStudioTests/TrimFormatResolutionTests.swift +++ b/packages/audio-studio/ios/AudioStudioTests/TrimFormatResolutionTests.swift @@ -1,12 +1,40 @@ +import AVFoundation import XCTest @testable import AudioStudio final class TrimFormatResolutionTests: XCTestCase { - func testEveryProbedDepthIsWritable() { - // Verified against AVAudioFile: each of these round-trips at the requested depth. - // 24 is the one the [16, 32] allowlist wrongly excluded. - XCTAssertEqual(TrimFormatResolution.writableBitDepths, [8, 16, 24, 32]) + func testEveryProbedDepthIsWritable() throws { + // Actually round-trip each depth through AVAudioFile rather than comparing the + // constant to itself, which is what this did before and proved nothing. 24 is the + // depth the old [16, 32] allowlist wrongly excluded. + let fm = FileManager.default + for depth in TrimFormatResolution.writableBitDepths { + let url = fm.temporaryDirectory + .appendingPathComponent("depth-\(depth)-\(UUID().uuidString)") + .appendingPathExtension("wav") + defer { try? fm.removeItem(at: url) } + + let writer = try AVAudioFile(forWriting: url, settings: [ + AVFormatIDKey: kAudioFormatLinearPCM, + AVSampleRateKey: 44100.0, + AVNumberOfChannelsKey: 1, + AVLinearPCMBitDepthKey: depth, + AVLinearPCMIsFloatKey: false, + AVLinearPCMIsBigEndianKey: false + ]) + let buffer = try XCTUnwrap( + AVAudioPCMBuffer(pcmFormat: writer.processingFormat, frameCapacity: 4410) + ) + buffer.frameLength = 4410 + try writer.write(from: buffer) + + let reopened = try AVAudioFile(forReading: url) + XCTAssertEqual( + Int(reopened.fileFormat.streamDescription.pointee.mBitsPerChannel), depth, + "a \(depth)-bit WAV must reopen as \(depth)-bit, or it is not writable" + ) + } } func testOmittedRequestPreservesTheInputDepth() { From 4de47a7656074253a2dae170a04c4585c22d1598 Mon Sep 17 00:00:00 2001 From: abretonc7s Date: Sat, 22 Aug 2026 11:53:06 +0800 Subject: [PATCH 9/9] fix(audio-studio/ios): fail when the trimmed file cannot be reopened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: the pre-promotion validation used try?, so a work file that could not be reopened skipped the rate check entirely and was promoted anyway — an unreadable output reported as success, which is worse than the substituted-rate case the check was added for. Reopening now throws with the underlying reason. Also merged the two `### Fixed` sections that had accumulated under [Unreleased]. All entries kept; the section order is now Fixed, Added, Changed. The reviewer's probe confirms the convert loop from the last commit behaves: ranges [4410, 44] needed eight queued-output writes before the converter took the second buffer, and finalized correctly. 91 SwiftPM tests pass, the app target builds, and on the simulator trimAudio still produces a 5.000s Int16 output. --- packages/audio-studio/CHANGELOG.md | 10 +++--- .../audio-studio/ios/AudioProcessor.swift | 35 +++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/audio-studio/CHANGELOG.md b/packages/audio-studio/CHANGELOG.md index 15ed1823f..9502c0dd9 100644 --- a/packages/audio-studio/CHANGELOG.md +++ b/packages/audio-studio/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS `trimAudio` AAC output now honours the requested sample rate. Writing source-format buffers to a target-rate writer mis-timed the output — one second at 44.1kHz came back as 2.0s at 22.05kHz and 0.92s at 48kHz. The AAC path now converts against the writer's resolved format, the same treatment the WAV path received. An explicitly requested bitrate the encoder cannot serve is now an error rather than a silent substitution; only the library's own default gives way. A requested rate the AAC encoder would resolve to something else — 1Hz to 8kHz, 384kHz to 192kHz — is refused rather than returned as success (#451). - iOS `trimAudio` preserves the input's bit depth when none is requested, and honours a bitDepth-only request instead of ignoring it via the fast path. An omitted depth previously forced 16-bit during any rate or channel change, contrary to the documented contract (#451). +- `trimAudio` and `startRecording` now reject a filename that is not a single path component, on both platforms. Each appended the caller's value to a directory, so `../../../../tmp/pwned` wrote outside it — `File(filesDir, ...)` and `appendingPathComponent` resolve `..` alike. Android trim rejects with `INVALID_OUTPUT_FILENAME`, Android recording with `INVALID_CONFIG`, iOS with an invalid-settings failure (#452). +- iOS `trimAudio` now rejects an `outputFileName` that is not a single filename. The value is appended to the output directory, so a path could traverse out of it — `../../../../tmp/pwned` resolved to `/var/tmp/pwned.wav` and the trim wrote there (#433). +- iOS `extractMelSpectrogram` no longer crashes on a large `windowSizeMs`, `hopLengthMs`, `startTimeMs` or `endTimeMs`. Those options are validated as representable, but the value is then multiplied by the file's sample rate, and the product can overflow the type it is narrowed to — `windowSizeMs: 100000000` is 4.41e9 samples at 44.1 kHz, which fits `Int` and traps the `Int32` the native wrapper takes. The conversions now clamp where the sample rate is known, since no bound on the option alone can be correct (#433). + ### Added - `addRecordingErrorListener()` subscribes to errors raised while recording is already running. iOS emitted these all along with no typed way to subscribe, so a failure that does not reject a call — the stalled WAV in #420, for one — was unobservable. Both platforms now emit it: Android declares the event and reports the AudioRecord leaving its initialized state, a read returning an error code, the primary WAV failing to flush, and the recording loop dying (#447). @@ -24,12 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Breaking for error handling:** iOS `startRecording` and `prepareRecording` no longer reject with the code `"ERROR"` and a fixed message. Callers matching on that code or on the exact string must switch on `error.code` instead; use the exported `startRecordingErrorCode()` helper to narrow it. - iOS `startRecording` now rejects with a specific reason instead of the single message "Failed to start recording.". Six distinct failures — an active phone call, a recording already in progress, missing settings, a failed audio tap, preparation failure, and the audio engine refusing to start — previously collapsed into one string. The codes match the ones Android already emits (`ONGOING_CALL`, `ALREADY_RECORDING`, `FILE_CREATION_FAILED`, `START_FAILED`), so callers switch on one vocabulary rather than one per platform. New `startRecordingErrorCode()` helper and `StartRecordingErrorCode` type narrow an unknown rejection. -### Fixed - -- `trimAudio` and `startRecording` now reject a filename that is not a single path component, on both platforms. Each appended the caller's value to a directory, so `../../../../tmp/pwned` wrote outside it — `File(filesDir, ...)` and `appendingPathComponent` resolve `..` alike. Android trim rejects with `INVALID_OUTPUT_FILENAME`, Android recording with `INVALID_CONFIG`, iOS with an invalid-settings failure (#452). -- iOS `trimAudio` now rejects an `outputFileName` that is not a single filename. The value is appended to the output directory, so a path could traverse out of it — `../../../../tmp/pwned` resolved to `/var/tmp/pwned.wav` and the trim wrote there (#433). -- iOS `extractMelSpectrogram` no longer crashes on a large `windowSizeMs`, `hopLengthMs`, `startTimeMs` or `endTimeMs`. Those options are validated as representable, but the value is then multiplied by the file's sample rate, and the product can overflow the type it is narrowed to — `windowSizeMs: 100000000` is 4.41e9 samples at 44.1 kHz, which fits `Int` and traps the `Int32` the native wrapper takes. The conversions now clamp where the sample rate is known, since no bound on the option alone can be correct (#433). - ## [3.2.1] - 2026-06-20 Stable release of the 3.2.1 beta fixes plus recent release-blocker validation. diff --git a/packages/audio-studio/ios/AudioProcessor.swift b/packages/audio-studio/ios/AudioProcessor.swift index 6fb4ee4bb..3063448ee 100644 --- a/packages/audio-studio/ios/AudioProcessor.swift +++ b/packages/audio-studio/ios/AudioProcessor.swift @@ -1289,17 +1289,30 @@ public class AudioProcessor { // 44099 and 44101 to 44100, 48001 to 48000. Reopen and check before // promoting, or the result reports a rate the file does not have // (#451). - if let finalized = try? AVAudioFile(forReading: workURL) { - let actualRate = finalized.fileFormat.sampleRate - guard actualRate == targetSampleRate else { - throw NSError( - domain: "AudioProcessor", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "The AAC encoder wrote \(Int(actualRate))Hz for a " - + "requested \(Int(targetSampleRate))Hz"] - ) - } + let finalized: AVAudioFile + do { + finalized = try AVAudioFile(forReading: workURL) + } catch { + // try? here skipped validation entirely when the file could not be + // reopened, and promoted it anyway — an unreadable output reported + // as success (#451). + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "The trimmed file could not be reopened for validation: " + + error.localizedDescription] + ) + } + let actualRate = finalized.fileFormat.sampleRate + guard actualRate == targetSampleRate else { + throw NSError( + domain: "AudioProcessor", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "The AAC encoder wrote \(Int(actualRate))Hz for a " + + "requested \(Int(targetSampleRate))Hz"] + ) } try promoteWorkFile()