diff --git a/packages/Bus/Sources/Bus/BusOutbound.swift b/packages/Bus/Sources/Bus/BusOutbound.swift index 3bbbd2b..883e076 100644 --- a/packages/Bus/Sources/Bus/BusOutbound.swift +++ b/packages/Bus/Sources/Bus/BusOutbound.swift @@ -159,7 +159,20 @@ extension BusOutbound: Codable { try container.encode(text, forKey: .text) case .audioLevel(let rms): try container.encode(Discriminator.audioLevel, forKey: .type) - try container.encode(rms, forKey: .rms) + // Issue #47: JSON has no canonical encoding for `Float.nan` or + // `±Float.infinity`. Foundation's `JSONEncoder` defaults to + // throwing for non-finite floats (`JSONEncoder.NonConformingFloat- + // EncodingStrategy.throw`), which would crash the per-frame bus + // path the moment `AudioLevelEmitter` produces a NaN — easy to + // hit on a CoreAudio over-/underflow or a buffer of all-zero + // samples. Clamp non-finite to 0.0 inline at the encode site so + // the rms float in the wire JSON is always a finite, ring-shader- + // safe number. The HUD-side consumer (`webview/packages/hud/src/ + // bus/client.ts`) currently no-ops on audioLevel; the clamp is + // defensive against the bug becoming active once the consumer is + // wired up. + let safe = rms.isFinite ? rms : 0.0 + try container.encode(safe, forKey: .rms) case .toolCallStart(let id, let name, let argsPreview): try container.encode(Discriminator.toolCallStart, forKey: .type) try container.encode(id.uuidString.lowercased(), forKey: .id) diff --git a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift index 66b3fdf..6a6fc3f 100644 --- a/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift +++ b/packages/Bus/Tests/BusTests/CodableRoundTripTests.swift @@ -123,6 +123,44 @@ final class CodableRoundTripTests: XCTestCase { ) } + /// Issue #47: `BusOutbound.audioLevel` encodes a `Float`, and Foundation's + /// default `JSONEncoder.NonConformingFloatEncodingStrategy` is `.throw`. + /// A non-finite `rms` (NaN from a CoreAudio over-/underflow, or ±∞ from a + /// pathological emitter buffer) would crash the per-frame bus path inside + /// `WebviewBridge.sendRaw`. The encode site in `BusOutbound.swift` clamps + /// non-finite floats to `0.0`; this test pins the contract so the clamp + /// can't silently regress. + func test_audioLevel_nanIsClampedToZero() throws { + let encoded = try BusCoder.makeEncoder().encode(BusOutbound.audioLevel(rms: .nan)) + let decoded = try BusCoder.makeDecoder().decode(BusOutbound.self, from: encoded) + guard case .audioLevel(let rms) = decoded else { + XCTFail("expected .audioLevel after round-trip, got \(decoded)") + return + } + XCTAssertEqual(rms, 0.0, "NaN rms must be clamped to 0.0 at encode time (#47)") + XCTAssertTrue(rms.isFinite, "post-clamp rms must be finite") + } + + /// Companion to `test_audioLevel_nanIsClampedToZero` — covers the ±∞ + /// edge cases. CoreAudio drivers don't normally emit infinities, but the + /// encode-time clamp is sourced from `Float.isFinite`, which covers both + /// NaN and ±∞ via one check; locking the contract here prevents a future + /// refactor from narrowing the guard to NaN-only. + func test_audioLevel_infinityIsClampedToZero() throws { + let encoder = BusCoder.makeEncoder() + let decoder = BusCoder.makeDecoder() + + for value in [Float.infinity, -Float.infinity] { + let encoded = try encoder.encode(BusOutbound.audioLevel(rms: value)) + let decoded = try decoder.decode(BusOutbound.self, from: encoded) + guard case .audioLevel(let rms) = decoded else { + XCTFail("expected .audioLevel for \(value)") + continue + } + XCTAssertEqual(rms, 0.0, "±∞ rms (\(value)) must be clamped to 0.0 (#47)") + } + } + func test_roundTrip_toolCallStart() throws { let id = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")! try assertOutboundRoundTrips( diff --git a/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift b/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift index d17bb9a..e573fb9 100644 --- a/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift +++ b/packages/Voice/Sources/Voice/STT/STTBackendSelector.swift @@ -28,6 +28,16 @@ public protocol STTWarningLogger: Sendable { public enum STTBackendSelector { // MARK: - Valid backend strings + // + // Canonical backend identifiers. Snake_case is the wire shape stored in + // `PerTurnSnapshot.stt.backend`. Call sites (`AppDelegate.sttBackend`) + // SHOULD reference these constants rather than the bare string literals; + // `STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake` + // locks the round-trip contract by asserting both that the constants + // map to the right `STTProvider` AND that camelCase string literals + // (the pre-2026-05-12 anti-pattern from Issue #29 / #37) fall through + // to the default branch with a warning. Do not rename without updating + // `PerTurnSnapshot` and the regression test in lockstep. public static let backendSpeechAnalyzer = "speech_analyzer" public static let backendWhisperKit = "whisperkit"