diff --git a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AudioProbe.swift b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AudioProbe.swift index dcf74b298e..10660a8cc8 100644 --- a/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AudioProbe.swift +++ b/apple/macos-helper/Sources/AgentDeviceMacOSHelper/AudioProbe.swift @@ -31,7 +31,7 @@ private struct AudioProbeBucket { var peak: Double = 0 } -private final class AudioProbeStatusWriter { +final class AudioProbeStatusWriter { private let outPath: String private let startedAt = Date() private let durationMs: Int @@ -61,6 +61,9 @@ private final class AudioProbeStatusWriter { else { return } + // AVAudioPCMBuffer starts with frameLength 0, so every mDataByteSize is 0 until + // frameLength is set. Copying first leaves the destination empty (-12731). + pcmBuffer.frameLength = frameCount let status = CMSampleBufferCopyPCMDataIntoAudioBufferList( sampleBuffer, at: 0, @@ -70,7 +73,6 @@ private final class AudioProbeStatusWriter { guard status == noErr else { return } - pcmBuffer.frameLength = frameCount add(audioBufferList: pcmBuffer.mutableAudioBufferList, format: format) } diff --git a/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/AudioProbeTests.swift b/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/AudioProbeTests.swift new file mode 100644 index 0000000000..054575a685 --- /dev/null +++ b/apple/macos-helper/Tests/AgentDeviceMacOSHelperTests/AudioProbeTests.swift @@ -0,0 +1,127 @@ +import AVFoundation +import CoreMedia +import XCTest + +@testable import AgentDeviceMacOSHelper + +/// AudioProbe must set AVAudioPCMBuffer.frameLength before the PCM copy. +final class AudioProbeTests: XCTestCase { + func testAddSampleBufferRecordsANonSilentPeakFromRealPCM() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent( + "audio-probe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let outPath = dir.appendingPathComponent("probe.json").path + let writer = AudioProbeStatusWriter(outPath: outPath, durationMs: 1_000, bucketMs: 100) + writer.add(sampleBuffer: try makeFloatPCMSampleBuffer(frames: 512, amplitude: 0.5)) + try writer.finish(reason: "test") + + let response = try JSONDecoder().decode( + AudioProbeResponse.self, from: Data(contentsOf: URL(fileURLWithPath: outPath))) + XCTAssertTrue(response.heard, "a 0.5 amplitude buffer must clear the silence floor") + XCTAssertFalse(response.peakDbfs.isEmpty) + XCTAssertGreaterThan(response.peakDbfs.max() ?? -90, -90) + } +} + +private func makeFloatPCMSampleBuffer(frames: Int, amplitude: Float) throws -> CMSampleBuffer { + var asbd = AudioStreamBasicDescription( + mSampleRate: 48_000, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked + | kAudioFormatFlagIsNonInterleaved, + mBytesPerPacket: UInt32(MemoryLayout.size), + mFramesPerPacket: 1, + mBytesPerFrame: UInt32(MemoryLayout.size), + mChannelsPerFrame: 1, + mBitsPerChannel: UInt32(MemoryLayout.size * 8), + mReserved: 0 + ) + + var formatDescription: CMAudioFormatDescription? + let formatStatus = CMAudioFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + asbd: &asbd, + layoutSize: 0, + layout: nil, + magicCookieSize: 0, + magicCookie: nil, + extensions: nil, + formatDescriptionOut: &formatDescription + ) + guard formatStatus == noErr, let formatDescription else { + throw NSError( + domain: "AudioProbeTests", code: Int(formatStatus), + userInfo: [NSLocalizedDescriptionKey: "CMAudioFormatDescriptionCreate failed"]) + } + + let byteCount = frames * MemoryLayout.size + var blockBuffer: CMBlockBuffer? + let blockStatus = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, + memoryBlock: nil, + blockLength: byteCount, + blockAllocator: nil, + customBlockSource: nil, + offsetToData: 0, + dataLength: byteCount, + flags: 0, + blockBufferOut: &blockBuffer + ) + guard blockStatus == noErr, let blockBuffer else { + throw NSError( + domain: "AudioProbeTests", code: Int(blockStatus), + userInfo: [NSLocalizedDescriptionKey: "CMBlockBufferCreateWithMemoryBlock failed"]) + } + let assureStatus = CMBlockBufferAssureBlockMemory(blockBuffer) + guard assureStatus == noErr else { + throw NSError( + domain: "AudioProbeTests", code: Int(assureStatus), + userInfo: [NSLocalizedDescriptionKey: "CMBlockBufferAssureBlockMemory failed"]) + } + + var lengthAtOffset = 0 + var totalLength = 0 + var dataPointer: UnsafeMutablePointer? + let pointerStatus = CMBlockBufferGetDataPointer( + blockBuffer, + atOffset: 0, + lengthAtOffsetOut: &lengthAtOffset, + totalLengthOut: &totalLength, + dataPointerOut: &dataPointer + ) + guard pointerStatus == noErr, let dataPointer else { + throw NSError( + domain: "AudioProbeTests", code: Int(pointerStatus), + userInfo: [NSLocalizedDescriptionKey: "CMBlockBufferGetDataPointer failed"]) + } + let floatPointer = UnsafeMutableRawPointer(dataPointer).bindMemory(to: Float.self, capacity: frames) + for index in 0..