Voice: SileroVAD hidden state persists across sessions; quiet onset misclassified after prior speech #34

Closed
opened 2026-05-12 19:06:57 +00:00 by KofTwentyTwo · 1 comment
KofTwentyTwo commented 2026-05-12 19:06:57 +00:00 (Migrated from github.com)

Summary

VoiceController.vadFactory returns the same SileroVAD every session and never calls reset(). LSTM hidden state from session N persists into session N+1. The first 1–3 windows of session N+1 inherit the bias from end-of-session-N, and may misclassify quiet onset as .silence — soft-spoken first syllables may be dropped from STT input.

Evidence

  • File: App/AppDelegate.swift:1271 — vadFactory: { sileroVAD } captures single instance
  • File: packages/Voice/Sources/Voice/VAD/SileroVAD.swift:292 — reset() exists; zeroes LSTM h, c + wasSpeech flag
  • VoiceController.startSTTSession creates fresh chunk stream but reuses same VAD instance, never calls reset()

Recommended fix

Call await vad.reset() at the top of startSTTSession:

private func startSTTSession() async {
    let vad = vadFactory()
    await vad.reset()
    // ... existing flow
}

The closure pattern caches the single instance, so reset() is the right idempotency primitive.

Test

  • Unit: simulate session N ending with .speech, then start session N+1 with quiet onset → assert VAD doesn't immediately fire .silence (state was reset)
  • Most tests construct fresh VAD per case, so they miss this — add explicit "VAD-reused" test

Source

  • Audit: .planning/audit-2026-05-12/voice-audit.md (2026-05-12) — finding P1-3
## Summary `VoiceController.vadFactory` returns the same `SileroVAD` every session and never calls `reset()`. LSTM hidden state from session N persists into session N+1. The first 1–3 windows of session N+1 inherit the bias from end-of-session-N, and may misclassify quiet onset as `.silence` — soft-spoken first syllables may be dropped from STT input. ## Evidence - File: `App/AppDelegate.swift:1271` — `vadFactory: { sileroVAD }` captures single instance - File: `packages/Voice/Sources/Voice/VAD/SileroVAD.swift:292` — `reset()` exists; zeroes LSTM `h, c` + `wasSpeech` flag - `VoiceController.startSTTSession` creates fresh chunk stream but reuses same VAD instance, never calls `reset()` ## Recommended fix Call `await vad.reset()` at the top of `startSTTSession`: ```swift private func startSTTSession() async { let vad = vadFactory() await vad.reset() // ... existing flow } ``` The closure pattern caches the single instance, so `reset()` is the right idempotency primitive. ## Test - Unit: simulate session N ending with `.speech`, then start session N+1 with quiet onset → assert VAD doesn't immediately fire `.silence` (state was reset) - Most tests construct fresh VAD per case, so they miss this — add explicit "VAD-reused" test ## Source - Audit: `.planning/audit-2026-05-12/voice-audit.md` (2026-05-12) — finding P1-3
KofTwentyTwo commented 2026-05-13 11:52:05 +00:00 (Migrated from github.com)

Closed by v0.1 cluster merges on 2026-05-13. Develop now at a8c7f1a0.

Closed by v0.1 cluster merges on 2026-05-13. Develop now at `a8c7f1a0`.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: KofTwentyTwo/Jarvis#34