File size: 9,511 Bytes
f15911b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bcf9753
 
 
 
 
f15911b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bcf9753
 
 
 
 
 
 
 
f15911b
 
 
 
 
 
 
 
 
 
 
bcf9753
 
 
 
f15911b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
/**
 * voice_resilience β€” KI-223..228 (2026-05-15).
 *
 * Companion module to useStreamingVoice. Holds pure helpers + small classes
 * that don't need to live inside the hook's body. Keeping them out of the
 * hook keeps the giant useStreamingVoice file readable, and makes the
 * resilience logic independently unit-testable.
 *
 * Contents
 * -------------------------------------------------------------------------
 *  - retryPostTranscribe        β€” exponential-backoff wrapper around the
 *                                  Sarvam STT POST so a transient network
 *                                  blip / cold start / 502 doesn't drop the
 *                                  user's utterance (V5.4).
 *  - scaleSpeechZcrBand          β€” derives the speech-band ZCR window for a
 *                                  given AudioContext sampleRate so the
 *                                  fftSize=2048 VAD math from KI-189 keeps
 *                                  meaning when the device delivers 16/24
 *                                  kHz instead of 48 kHz (V1.3).
 *  - AdaptiveNoiseFloor          β€” rolling EMA of "silent" RMS frames. Used
 *                                  by the barge-in VAD to set a speech
 *                                  threshold that adapts to the actual room
 *                                  (quiet office vs. coffee shop). Replaces
 *                                  the static BARGE_IN_RMS_THRESHOLD on the
 *                                  noise-side; the bot-RMS adaptive piece
 *                                  from KI-190 still rides on top (V6.8).
 *  - VoiceError                  β€” string-union of new error states the hook
 *                                  can surface to page.tsx so the UI can
 *                                  prompt the user to interact (resume
 *                                  suspended AudioContext) etc. (V1.1).
 */

// ---------------------------------------------------------------------------
// V1.1 β€” AudioContext suspended / V1.2 β€” worklet failure error states.
// We don't use AudioWorklet in this hook (the Web Speech API replaced the
// custom PCM worklet path), but the type stays here so a future re-add
// has a slot.
// ---------------------------------------------------------------------------
export type VoiceError =
  | "audio_context_suspended"
  | "worklet_failed"
  | "stream_stale"
  | "transcribe_failed";

// ---------------------------------------------------------------------------
// V5.4 β€” exponential-backoff transcribe retry.
// ---------------------------------------------------------------------------
export interface RetryOptions {
  maxAttempts?: number;
  baseDelayMs?: number;
  signal?: AbortSignal;
}

/**
 * Wraps an async transcribe call with up to `maxAttempts` retries on
 * network errors. With defaults (maxAttempts=3, baseDelayMs=1000) the
 * loop performs 3 attempts separated by exponential pre-attempt delays
 * of 1s then 2s β€” max ~3s of additional wait across the run. (Delays
 * sit BETWEEN attempts; no delay follows the final attempt.) Aborts
 * propagate immediately (we don't retry a user-initiated abort).
 *
 * The caller passes a thunk that performs the actual POST. The thunk MUST
 * accept its own AbortSignal so each attempt can be individually timed
 * out β€” we wire one in via the per-attempt controller, while still
 * honouring the outer `opts.signal` so a global cancel kills everything.
 *
 * Returns null when all attempts are exhausted (so the hook can fall back
 * to the Web Speech transcript instead of crashing the utterance).
 */
export async function retryPostTranscribe<T>(
  thunk: (signal: AbortSignal) => Promise<T>,
  opts: RetryOptions = {},
): Promise<T | null> {
  const maxAttempts = opts.maxAttempts ?? 3;
  const baseDelayMs = opts.baseDelayMs ?? 1000;
  const outerSignal = opts.signal;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    if (outerSignal?.aborted) return null;
    const perAttempt = new AbortController();
    const onOuterAbort = () => perAttempt.abort();
    if (outerSignal) outerSignal.addEventListener("abort", onOuterAbort);
    try {
      const result = await thunk(perAttempt.signal);
      if (outerSignal) outerSignal.removeEventListener("abort", onOuterAbort);
      return result;
    } catch (err) {
      if (outerSignal) outerSignal.removeEventListener("abort", onOuterAbort);
      // User-initiated abort β€” don't retry.
      if (outerSignal?.aborted) return null;
      // Last attempt β€” surface null so caller can fall back.
      if (attempt === maxAttempts) {
        console.debug("[voice_resilience] retryPostTranscribe exhausted", {
          attempt,
          err: (err as Error)?.message,
        });
        return null;
      }
      const delay = baseDelayMs * Math.pow(2, attempt - 1);
      console.debug("[voice_resilience] retryPostTranscribe attempt failed, backing off", {
        attempt,
        nextDelayMs: delay,
        err: (err as Error)?.message,
      });
      await new Promise<void>((resolve, reject) => {
        const t = setTimeout(resolve, delay);
        if (outerSignal) {
          outerSignal.addEventListener("abort", () => {
            clearTimeout(t);
            reject(new Error("aborted"));
          }, { once: true });
        }
      }).catch(() => { /* outer abort β€” fall out of loop */ });
      if (outerSignal?.aborted) return null;
    }
  }
  return null;
}

// ---------------------------------------------------------------------------
// V1.3 β€” sample-rate-aware ZCR band.
// The original VAD assumes fftSize=2048 @ 48 kHz, where speech ZCR sits in
// ~20..250 zero crossings per 2048-sample buffer. At a fixed fftSize the
// buffer's TIME duration = fftSize / sampleRate, so a 16 kHz buffer covers
// 128 ms (vs 48 kHz's 42.7 ms β€” 3Γ— longer). Speech ZCR per SECOND is roughly
// constant for a given phoneme class (cf. Kedem 1986 "Spectral analysis and
// discrimination by zero-crossings"; Bachu et al. "Separation of Voiced and
// Unvoiced using Zero Crossing Rate"; WebRTC VAD per-rate feature tuning),
// so a longer-duration window observes MORE crossings, not fewer. Net: the
// per-buffer count scales INVERSELY with sampleRate (ratio = 48000 / actual).
//
// We expose a helper so the hook can compute the band at AudioContext init.
// ---------------------------------------------------------------------------
const REFERENCE_SAMPLE_RATE = 48000;
const REFERENCE_ZCR_MIN = 20;
const REFERENCE_ZCR_MAX = 250;

export function scaleSpeechZcrBand(actualSampleRate: number): { min: number; max: number } {
  if (!actualSampleRate || actualSampleRate <= 0) {
    return { min: REFERENCE_ZCR_MIN, max: REFERENCE_ZCR_MAX };
  }
  // Per-buffer ZCR = ZCR_per_second * (fftSize / sampleRate). With fftSize
  // fixed, per-buffer count scales as 1/sampleRate. So at 16 kHz we expect
  // ~3Γ— the crossings seen at 48 kHz for the same speech signal.
  const ratio = REFERENCE_SAMPLE_RATE / actualSampleRate;
  return {
    min: Math.max(1, Math.round(REFERENCE_ZCR_MIN * ratio)),
    max: Math.max(REFERENCE_ZCR_MIN + 1, Math.round(REFERENCE_ZCR_MAX * ratio)),
  };
}

// ---------------------------------------------------------------------------
// V6.8 β€” adaptive noise-floor estimator.
// Maintains a 5-second EMA of "silent" RMS values. The hook samples this
// every VAD frame; when RMS is below the current speech threshold we treat
// the frame as silent and feed it into the EMA. The current speech
// threshold is `noiseFloor * 4 + 0.005`, clamped to [0.02, 0.15].
//
// Recompute cadence: caller decides. We expose a `currentThreshold()` getter
// + a `feed(rms)` setter. The hook will call feed() every frame and read
// the threshold whenever it needs to compare. Both are O(1).
// ---------------------------------------------------------------------------
const NOISE_EMA_WINDOW_SECONDS = 5;
const NOISE_EMA_ASSUMED_FPS = 60; // rAF default
const NOISE_EMA_ALPHA = 1 / (NOISE_EMA_WINDOW_SECONDS * NOISE_EMA_ASSUMED_FPS);
const NOISE_THRESHOLD_MULTIPLIER = 4;
const NOISE_THRESHOLD_BASE = 0.005;
const NOISE_THRESHOLD_MIN = 0.02;
const NOISE_THRESHOLD_MAX = 0.15;

export class AdaptiveNoiseFloor {
  private ema: number;
  // Track the "current threshold" inline so currentThreshold() stays O(1)
  // without re-running the clamp each call.
  private threshold: number;

  constructor(initialEma: number = 0.005) {
    this.ema = initialEma;
    this.threshold = this.computeThreshold(this.ema);
  }

  /** Feed every VAD-frame RMS. We update the EMA only when the frame is
   *  below the CURRENT threshold (i.e. it looks like silence). This keeps
   *  speech bursts from polluting the noise floor. */
  feed(rms: number): void {
    if (rms < this.threshold) {
      this.ema = (1 - NOISE_EMA_ALPHA) * this.ema + NOISE_EMA_ALPHA * rms;
      this.threshold = this.computeThreshold(this.ema);
    }
  }

  /** Force-reseed (used on session start). */
  reset(initialEma: number = 0.005): void {
    this.ema = initialEma;
    this.threshold = this.computeThreshold(this.ema);
  }

  currentThreshold(): number {
    return this.threshold;
  }

  currentNoiseFloor(): number {
    return this.ema;
  }

  private computeThreshold(ema: number): number {
    const raw = ema * NOISE_THRESHOLD_MULTIPLIER + NOISE_THRESHOLD_BASE;
    if (raw < NOISE_THRESHOLD_MIN) return NOISE_THRESHOLD_MIN;
    if (raw > NOISE_THRESHOLD_MAX) return NOISE_THRESHOLD_MAX;
    return raw;
  }
}