Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 4.7 6a404e479a fix(bus): clamp non-finite audioLevel rms to 0.0 at encode (#47)
`BusOutbound.audioLevel` carries a `Float rms` that `AudioLevelEmitter`
produces per audio frame (~100 Hz once the consumer is wired up).
Foundation's `JSONEncoder.NonConformingFloatEncodingStrategy` defaults
to `.throw`, so a single NaN — easy to land via a CoreAudio
over-/underflow or an all-zero buffer fed through certain RMS shapes —
would throw inside `WebviewBridge.sendRaw` and tear down the per-frame
bus path. The HUD-side consumer in `webview/packages/hud/src/bus/
client.ts:135` is currently a no-op (the ring shader is still on the
synthetic-sine animation from Plan 03-03), so the bug is LATENT, not
actively firing — but the clamp is cheap, defensive, and pins the
contract before the consumer activates.

Inline clamp at the encode site (`BusOutbound.encode(to:)` switch arm
for `.audioLevel`): replace non-finite floats with `0.0` via
`rms.isFinite ? rms : 0.0` immediately before `container.encode(_:)`.
No refactor of `AudioLevelEmitter` — the per-spec call here is
defensive at the serialization boundary, where every encoder-level
overflow path converges.

Two new unit tests in `CodableRoundTripTests`:
- `test_audioLevel_nanIsClampedToZero` — encodes `.audioLevel(rms: .nan)`,
  decodes, asserts `rms == 0.0` and `rms.isFinite`.
- `test_audioLevel_infinityIsClampedToZero` — companion for ±∞ so a
  future refactor cannot narrow the guard to NaN-only.

`swift test --package-path packages/Bus --filter CodableRoundTripTests`
passes (30 tests, +2 from this commit).

Closes #47

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:34:02 -05:00
james.maesandClaude Opus 4.7 ea8426eefc docs(voice): pin STT backend constant identity contract (#37)
The runtime mismatch flagged by #37 was already fixed at the call sites
in `App/AppDelegate.swift:1631-1642` and `:2562-2575` — both routes now
reference `STTBackendSelector.backendSpeechAnalyzer` /
`backendWhisperKit` (snake_case) instead of the pre-2026-05-12 camelCase
string literals. The regression test
`STTBackendSwitchTests.testB5_canonicalConstantsRoundTripThroughMake`
already locks both halves of the contract:

  1. The snake_case constants round-trip through `make(backend:)` without
     hitting the unknown-backend default branch.
  2. The pre-fix camelCase shape (`"whisperKit"`) DOES land in the
     default branch and DOES emit an unknown-backend warning.

So the #37 close action is just to make that contract self-documenting
at the declaration site rather than relying on readers finding the test.
Add a doc-comment to the two `public static let backend*` constants that
explains the wire shape, names the regression test, references Issues
#29 + #37, and warns against renaming without updating `PerTurnSnapshot`
and the test in lockstep.

No code change; `swift test --package-path packages/Voice` continues to
pass (26 tests).

Closes #37

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 09:33:48 -05:00
3 changed files with 62 additions and 1 deletions

No files matched your search

+14 -1
View File
@@ -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"