diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift index a0cca39..8712322 100644 --- a/OSGKeyboardShared/Services/ASRService.swift +++ b/OSGKeyboardShared/Services/ASRService.swift @@ -65,6 +65,46 @@ public enum ASRServiceFactory { } } +// MARK: - PCM format conversion (testable helpers) +// +// Extracted from the audio-thread hot path so the scaling + clipping +// math can be exercised in unit tests without instantiating the +// full ASR pipeline. See `OSGKeyboardTests/ASRConversionTests.swift`. +extension ASRServiceFactory { + + /// Convert a Float32 PCM buffer (`-1.0...1.0`) to an Int16 PCM + /// buffer (`-32768...32767`). + /// + /// - Parameters: + /// - source: Pointer to `sourceCount` `Float` samples. May be + /// `nil` when `sourceCount == 0`. + /// - sourceCount: Number of samples to convert. A `0` count + /// turns the call into a no-op regardless of the pointers. + /// - destination: Pointer to at least `sourceCount` slots of + /// `Int16`. May be `nil` when `sourceCount == 0`. + /// + /// Per-sample: `Int16(round(clamp(s * 32767, -32768, 32767)))`. + /// The explicit clip matters: without it, `s == 1.0` would map + /// to `+32767` (fine) but `s == 1.5` (which can show up at the + /// audio engine boundary under gain) would wrap to a negative + /// value after the implicit Float→Int16 conversion. The + /// `round()` (rather than truncate) preserves DC balance — `0.5` + /// quantises to `+16384`, not `+16383`, matching what most audio + /// DAW round-trips expect. + static func convertFloat32ToInt16( + source: UnsafePointer?, + sourceCount: Int, + destination: UnsafeMutablePointer? + ) { + guard sourceCount > 0, let source, let destination else { return } + for i in 0.., format: AVAudioFormat @@ -169,17 +228,27 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { AsyncStream { continuation in Task { for await snap in stream { - guard !snap.samples.isEmpty, - let pcm = AVAudioPCMBuffer( - pcmFormat: format, - frameCapacity: AVAudioFrameCount(snap.samples.count) - ) - else { continue } - pcm.frameLength = AVAudioFrameCount(snap.samples.count) - if let dst = pcm.floatChannelData?[0] { + guard !snap.samples.isEmpty else { continue } + let capacity = AVAudioFrameCount(snap.samples.count) + guard let pcm = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: capacity + ) else { continue } + pcm.frameLength = capacity + + // For a 1-channel Int16 buffer (interleaved or not — + // single channel, so the data layout is identical), + // `int16ChannelData?[0]` gives us the raw sample + // pointer. Clip on overflow to avoid wraparound + // (a Float like 1.5 would otherwise become a + // negative Int16 after the implicit truncation). + if let dst = pcm.int16ChannelData?[0] { snap.samples.withUnsafeBufferPointer { src in - guard let base = src.baseAddress else { return } - memcpy(dst, base, snap.samples.count * MemoryLayout.size) + ASRServiceFactory.convertFloat32ToInt16( + source: src.baseAddress, + sourceCount: src.count, + destination: dst + ) } } continuation.yield(AnalyzerInput(buffer: pcm)) diff --git a/OSGKeyboardTests/ASRConversionTests.swift b/OSGKeyboardTests/ASRConversionTests.swift new file mode 100644 index 0000000..ac44975 --- /dev/null +++ b/OSGKeyboardTests/ASRConversionTests.swift @@ -0,0 +1,76 @@ +// ASRConversionTests.swift +// OSGKeyboard · Tests +// +// Locks in the Float32→Int16 PCM conversion that `SpeechAnalyzerASR` +// runs on the audio thread. The dictation transcriber's precondition +// (`"Audio sample data must be 16-bit signed integers"`) trips if +// the conversion is wrong, so the scaling + clipping math here is +// the difference between "Speech works" and "Speech crashes" — worth +// a regression test even though it's only ~3 lines of arithmetic. + +import XCTest +@testable import OSGKeyboardShared + +final class ASRConversionTests: XCTestCase { + + /// Edge cases: silence, full-scale positive, full-scale negative, + /// mid-scale positive, mid-scale negative, and the "above unity" + /// gain-overflow case. Each is the one number we'd most regret + /// getting wrong. + func testFloat32ToInt16EdgeCases() { + runConversion( + input: [0.0, 1.0, -1.0, 0.5, -0.5, 1.5, -1.5], + expected: [0, 32767, -32767, 16384, -16384, 32767, -32768] + ) + } + + /// Round-trip-ish: every Int16 in a small range should be + /// reachable from a corresponding Float input. Locks the + /// quantization step (1/32767) so a future "use a different + /// scaling" change has to update this test. + func testFloat32ToInt16RoundTrip() { + var input = [Float]() + var expected = [Int16]() + for i in stride(from: -32768, through: 32767, by: 1024) { + // Map Int16 back to its canonical Float source value: + // src = i / 32767.0 (so src=1.0 → i=32767, src=-1.0 → i=-32767). + // We don't test i=-32768 because the asymmetric range + // (Int16 is -32768...32767) means there is no Float + // that decodes back to exactly -32768. + let src = Float(i) / 32767.0 + input.append(src) + expected.append(Int16(i)) + } + runConversion(input: input, expected: expected) + } + + /// Empty input must be a no-op. Guards against off-by-one in + /// the loop and against `UnsafePointer` access on a zero-length + /// array (which is undefined behaviour in C but valid in Swift). + func testFloat32ToInt16Empty() { + // `sourceCount == 0` with `nil` pointers is a no-op. The + // function guards on `sourceCount > 0` before dereferencing + // anything, so the nil pointers are safe. + ASRServiceFactory.convertFloat32ToInt16( + source: nil, sourceCount: 0, destination: nil + ) + // Reaching here without crashing is the assertion. + } + + // MARK: - Helper + + private func runConversion(input: [Float], expected: [Int16]) { + precondition(input.count == expected.count, "test setup") + var actual = [Int16](repeating: 0, count: expected.count) + input.withUnsafeBufferPointer { src in + actual.withUnsafeMutableBufferPointer { dst in + ASRServiceFactory.convertFloat32ToInt16( + source: src.baseAddress, + sourceCount: src.count, + destination: dst.baseAddress + ) + } + } + XCTAssertEqual(actual, expected) + } +}