Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a404e479a | ||
|
|
ea8426eefc |
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user