@@ -455,6 +455,47 @@ final class AudioTrackMixerTests: XCTestCase {
455455 XCTAssertLessThan ( loudest, 16_500 )
456456 }
457457
458+ /// A microphone does not have to arrive as Float32. A MacBook Pro (M1, macOS 26.6) handed
459+ /// the ScreenCaptureKit microphone output over as 48 kHz mono 24-bit integer, which the
460+ /// decoder rejected — Float32, Int16 and Int32 were the only formats it read — so every take
461+ /// recorded there had a silent microphone and one `audio-source-undecodable` warning to show
462+ /// for it (issue #709). Every integer container CoreAudio describes must decode, at the right
463+ /// level and with the right sign.
464+ func testIntegerMicrophoneFormatsAreHeardAtTheirLevel( ) {
465+ let layouts : [ ( bits: Int , bytes: Int , alignedHigh: Bool , label: String ) ] = [
466+ ( 24 , 3 , false , " 24-bit packed (the reported format) " ) ,
467+ ( 24 , 4 , false , " 24-bit in 4 bytes, low-aligned " ) ,
468+ ( 24 , 4 , true , " 24-bit in 4 bytes, high-aligned " ) ,
469+ ( 16 , 2 , false , " 16-bit " ) ,
470+ ( 32 , 4 , false , " 32-bit " ) ,
471+ ]
472+ for layout in layouts {
473+ let sink = RecordingSink ( )
474+ let clock = TestClock ( . zero)
475+ let mixer = makeMixer ( sink: sink, clock: clock, includesMicrophone: true , microphoneGain: 1 )
476+ mixer. beginTimeline ( at: clock. now)
477+ mixer. ingest (
478+ makeIntegerMonoBuffer (
479+ alternating: 0.5 , frames: sampleRate / 5 , at: clock. now,
480+ bits: layout. bits, bytes: layout. bytes, alignedHigh: layout. alignedHigh
481+ ) ,
482+ from: . microphone
483+ )
484+ clock. advance ( seconds: 0.2 )
485+ mixer. finish ( atSourceTime: clock. now)
486+
487+ let frames = mixedFrames ( sink)
488+ XCTAssertEqual ( seconds ( ofFrames: frames) , 0.2 , accuracy: 0.011 , layout. label)
489+ // Mono feeds both sides at half scale, and +0.5 stays positive: a byte-order or
490+ // sign-extension mistake shows up as the wrong level or a flipped first sample.
491+ for lane in 0 ..< channelCount {
492+ XCTAssertEqual ( Int ( peak ( frames, from: 0 , to: 0.2 , channel: lane) ) , 16_384 , accuracy: 2 , layout. label)
493+ XCTAssertGreaterThan ( frames [ lane] , 16_000 , layout. label)
494+ XCTAssertLessThan ( frames [ channelCount + lane] , - 16_000 , layout. label)
495+ }
496+ }
497+ }
498+
458499 // MARK: - Fixtures
459500
460501 private func makeMixer(
@@ -588,6 +629,79 @@ final class AudioTrackMixerTests: XCTestCase {
588629
589630 // MARK: - Readback
590631
632+ /// A mono, interleaved integer capture buffer, `bits` significant bits in a `bytes`-wide
633+ /// little-endian container, alternating +amplitude / -amplitude frame by frame.
634+ private func makeIntegerMonoBuffer(
635+ alternating amplitude: Float ,
636+ frames frameCount: Int ,
637+ at presentationTime: CMTime ,
638+ bits: Int ,
639+ bytes: Int ,
640+ alignedHigh: Bool
641+ ) -> CMSampleBuffer {
642+ var asbd = AudioStreamBasicDescription (
643+ mSampleRate: Float64 ( sampleRate) ,
644+ mFormatID: kAudioFormatLinearPCM,
645+ mFormatFlags: kAudioFormatFlagIsSignedInteger
646+ | ( bits == bytes * 8 ? kAudioFormatFlagIsPacked : 0 )
647+ | ( alignedHigh ? kAudioFormatFlagIsAlignedHigh : 0 ) ,
648+ mBytesPerPacket: UInt32 ( bytes) ,
649+ mFramesPerPacket: 1 ,
650+ mBytesPerFrame: UInt32 ( bytes) ,
651+ mChannelsPerFrame: 1 ,
652+ mBitsPerChannel: UInt32 ( bits) ,
653+ mReserved: 0
654+ )
655+ var formatDescription : CMAudioFormatDescription ?
656+ XCTAssertEqual (
657+ CMAudioFormatDescriptionCreate (
658+ allocator: kCFAllocatorDefault, asbd: & asbd, layoutSize: 0 , layout: nil ,
659+ magicCookieSize: 0 , magicCookie: nil , extensions: nil ,
660+ formatDescriptionOut: & formatDescription
661+ ) ,
662+ noErr
663+ )
664+ var sampleBuffer : CMSampleBuffer ?
665+ XCTAssertEqual (
666+ CMAudioSampleBufferCreateWithPacketDescriptions (
667+ allocator: kCFAllocatorDefault, dataBuffer: nil , dataReady: false ,
668+ makeDataReadyCallback: nil , refcon: nil , formatDescription: formatDescription!,
669+ sampleCount: frameCount, presentationTimeStamp: presentationTime,
670+ packetDescriptions: nil , sampleBufferOut: & sampleBuffer
671+ ) ,
672+ noErr
673+ )
674+
675+ let byteCount = frameCount * bytes
676+ let memory = UnsafeMutableRawPointer . allocate ( byteCount: byteCount, alignment: 16 )
677+ defer { memory. deallocate ( ) }
678+ let fullScale = Double ( Int64 ( 1 ) << ( bits - 1 ) )
679+ for frame in 0 ..< frameCount {
680+ let sample = Double ( frame % 2 == 0 ? amplitude : - amplitude)
681+ var value = Int64 ( ( sample * fullScale) . rounded ( ) )
682+ value = min ( max ( value, - Int64( fullScale) ) , Int64 ( fullScale) - 1 )
683+ // High-aligned puts the padding below the value; low-aligned sign-extends above it.
684+ let word = UInt32 ( truncatingIfNeeded: alignedHigh ? value << Int64 ( bytes * 8 - bits) : value)
685+ for byte in 0 ..< bytes {
686+ memory. storeBytes ( of: UInt8 ( truncatingIfNeeded: word >> UInt32 ( 8 * byte) ) ,
687+ toByteOffset: frame * bytes + byte, as: UInt8 . self)
688+ }
689+ }
690+ let bufferList = AudioBufferList . allocate ( maximumBuffers: 1 )
691+ defer { free ( bufferList. unsafeMutablePointer) }
692+ bufferList [ 0 ] = AudioBuffer ( mNumberChannels: 1 , mDataByteSize: UInt32 ( byteCount) , mData: memory)
693+ XCTAssertEqual (
694+ CMSampleBufferSetDataBufferFromAudioBufferList (
695+ sampleBuffer!, blockBufferAllocator: kCFAllocatorDefault,
696+ blockBufferMemoryAllocator: kCFAllocatorDefault, flags: 0 ,
697+ bufferList: bufferList. unsafePointer
698+ ) ,
699+ noErr
700+ )
701+ XCTAssertEqual ( CMSampleBufferSetDataReady ( sampleBuffer!) , noErr)
702+ return sampleBuffer!
703+ }
704+
591705 private func mixedFrames( _ sink: RecordingSink ) -> [ Int16 ] {
592706 var samples = [ Int16] ( )
593707 for buffer in sink. buffers {
0 commit comments