chenbhao commited on
Commit
d7d3273
·
1 Parent(s): 49cd9e0

fix: audio transmission

Browse files

wait for audio synthesis to complete before playback instead of chunking

desktop/src/hooks/useCompanionAudio.ts CHANGED
@@ -1,48 +1,57 @@
1
  import { useRef, useCallback, useEffect } from 'react'
2
 
3
- function pcmToWav(pcm: ArrayBuffer): Blob {
4
- const int16 = new Int16Array(pcm)
5
- const numSamples = int16.length
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  const sampleRate = 24000
7
- const numChannels = 1
8
  const bitsPerSample = 16
9
- const byteRate = (sampleRate * numChannels * bitsPerSample) / 8
10
  const blockAlign = (numChannels * bitsPerSample) / 8
11
- const dataSize = numSamples * blockAlign
12
- const headerSize = 44
13
- const totalSize = headerSize + dataSize
14
 
15
  const buf = new ArrayBuffer(totalSize)
16
- const view = new DataView(buf)
17
-
18
- const w = (offset: number, str: string) => {
19
- for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i))
 
 
 
 
 
 
 
 
 
 
 
20
  }
21
-
22
- w(0, 'RIFF')
23
- view.setUint32(4, totalSize - 8, true)
24
- w(8, 'WAVE')
25
- w(12, 'fmt ')
26
- view.setUint32(16, 16, true)
27
- view.setUint16(20, 1, true) // PCM
28
- view.setUint16(22, numChannels, true)
29
- view.setUint32(24, sampleRate, true)
30
- view.setUint32(28, byteRate, true)
31
- view.setUint16(32, blockAlign, true)
32
- view.setUint16(34, bitsPerSample, true)
33
- w(36, 'data')
34
- view.setUint32(40, dataSize, true)
35
-
36
- new Int16Array(buf, headerSize, numSamples).set(int16)
37
  return new Blob([buf], { type: 'audio/wav' })
38
  }
39
 
40
  export function useCompanionAudio() {
41
  const audioRef = useRef<HTMLAudioElement | null>(null)
42
- const queueRef = useRef<Blob[]>([])
43
- const playingRef = useRef(false)
44
  const urlRef = useRef<string | null>(null)
45
 
 
 
 
 
46
  const revokeUrl = useCallback(() => {
47
  if (urlRef.current) {
48
  URL.revokeObjectURL(urlRef.current)
@@ -50,43 +59,26 @@ export function useCompanionAudio() {
50
  }
51
  }, [])
52
 
53
- const playNext = useCallback(() => {
54
- if (playingRef.current) return
55
- const queue = queueRef.current
56
- if (queue.length === 0) return
57
-
58
- const blob = queue.shift()!
59
- const url = URL.createObjectURL(blob)
60
- const audio = audioRef.current
61
- if (!audio) return
62
-
63
- revokeUrl()
64
- urlRef.current = url
65
- audio.src = url
66
- audio.play().then(() => {
67
- playingRef.current = true
68
- }).catch(() => {
69
- playingRef.current = false
70
  revokeUrl()
71
- playNext()
72
- })
73
- }, [revokeUrl])
 
 
 
 
74
 
 
75
  useEffect(() => {
76
  const audio = new Audio()
77
  audioRef.current = audio
78
  audio.style.display = 'none'
79
  document.body.appendChild(audio)
80
 
81
- audio.onended = () => {
82
- playingRef.current = false
83
- playNext()
84
- }
85
- audio.onerror = () => {
86
- playingRef.current = false
87
- playNext()
88
- }
89
-
90
  return () => {
91
  audio.pause()
92
  revokeUrl()
@@ -95,21 +87,25 @@ export function useCompanionAudio() {
95
  audio.parentNode.removeChild(audio)
96
  }
97
  audioRef.current = null
98
- queueRef.current = []
99
- playingRef.current = false
100
  }
101
  }, [revokeUrl])
102
 
103
- const enqueueAudio = useCallback(
104
- (pcmData: ArrayBuffer) => {
105
- const wav = pcmToWav(pcmData)
106
- queueRef.current.push(wav)
107
- if (!playingRef.current) {
108
- playNext()
109
- }
110
- },
111
- [playNext],
112
- )
 
 
 
 
113
 
114
  const stopPlayback = useCallback(() => {
115
  const audio = audioRef.current
@@ -118,27 +114,21 @@ export function useCompanionAudio() {
118
  revokeUrl()
119
  audio.src = ''
120
  }
121
- queueRef.current = []
122
- playingRef.current = false
123
  }, [revokeUrl])
124
 
125
  const resume = useCallback(() => {
126
- const audio = audioRef.current
127
- if (!audio) return
128
-
129
- // Unlock audio with a silent WAV
130
  const silent = new Int16Array(1)
131
  silent[0] = 0
132
- const wav = pcmToWav(silent.buffer)
133
- const url = URL.createObjectURL(wav)
134
- revokeUrl()
135
- urlRef.current = url
136
- audio.src = url
137
- audio.play().catch(() => {})
138
- }, [revokeUrl])
139
 
140
  return {
141
  enqueueAudio,
 
142
  stopPlayback,
143
  resume,
144
  }
 
1
  import { useRef, useCallback, useEffect } from 'react'
2
 
3
+ /** PCM Int16 → WAV Blob */
4
+ function pcmChunksToWav(chunks: Int16Array[]): Blob {
5
+ if (chunks.length === 0) {
6
+ // Return valid empty WAV
7
+ const empty = new ArrayBuffer(44)
8
+ const v = new DataView(empty)
9
+ const w = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)) }
10
+ w(0, 'RIFF'); v.setUint32(4, 36, true); w(8, 'WAVE')
11
+ w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true)
12
+ v.setUint16(22, 1, true); v.setUint32(24, 24000, true); v.setUint32(28, 48000, true)
13
+ v.setUint16(32, 2, true); v.setUint16(34, 16, true)
14
+ w(36, 'data'); v.setUint32(40, 0, true)
15
+ return new Blob([empty], { type: 'audio/wav' })
16
+ }
17
+
18
+ const totalSamples = chunks.reduce((s, c) => s + c.length, 0)
19
  const sampleRate = 24000
 
20
  const bitsPerSample = 16
21
+ const numChannels = 1
22
  const blockAlign = (numChannels * bitsPerSample) / 8
23
+ const byteRate = sampleRate * blockAlign
24
+ const dataSize = totalSamples * blockAlign
25
+ const totalSize = 44 + dataSize
26
 
27
  const buf = new ArrayBuffer(totalSize)
28
+ const v = new DataView(buf)
29
+ const w = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)) }
30
+
31
+ w(0, 'RIFF'); v.setUint32(4, totalSize - 8, true); w(8, 'WAVE')
32
+ w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true)
33
+ v.setUint16(22, numChannels, true); v.setUint32(24, sampleRate, true)
34
+ v.setUint32(28, byteRate, true); v.setUint16(32, blockAlign, true)
35
+ v.setUint16(34, bitsPerSample, true)
36
+ w(36, 'data'); v.setUint32(40, dataSize, true)
37
+
38
+ const pcmView = new Int16Array(buf, 44, totalSamples)
39
+ let offset = 0
40
+ for (const chunk of chunks) {
41
+ pcmView.set(chunk, offset)
42
+ offset += chunk.length
43
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  return new Blob([buf], { type: 'audio/wav' })
45
  }
46
 
47
  export function useCompanionAudio() {
48
  const audioRef = useRef<HTMLAudioElement | null>(null)
 
 
49
  const urlRef = useRef<string | null>(null)
50
 
51
+ // Accumulation buffer — all PCM chunks are poured here until flush()
52
+ const chunksRef = useRef<Int16Array[]>([])
53
+ const chunksLenRef = useRef(0) // track total sample count for quick lookup
54
+
55
  const revokeUrl = useCallback(() => {
56
  if (urlRef.current) {
57
  URL.revokeObjectURL(urlRef.current)
 
59
  }
60
  }, [])
61
 
62
+ const playWav = useCallback(
63
+ (blob: Blob) => {
64
+ const audio = audioRef.current
65
+ if (!audio) return
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  revokeUrl()
67
+ const url = URL.createObjectURL(blob)
68
+ urlRef.current = url
69
+ audio.src = url
70
+ audio.play().catch(() => {})
71
+ },
72
+ [revokeUrl],
73
+ )
74
 
75
+ // Set up the <audio> element
76
  useEffect(() => {
77
  const audio = new Audio()
78
  audioRef.current = audio
79
  audio.style.display = 'none'
80
  document.body.appendChild(audio)
81
 
 
 
 
 
 
 
 
 
 
82
  return () => {
83
  audio.pause()
84
  revokeUrl()
 
87
  audio.parentNode.removeChild(audio)
88
  }
89
  audioRef.current = null
90
+ chunksRef.current = []
91
+ chunksLenRef.current = 0
92
  }
93
  }, [revokeUrl])
94
 
95
+ const enqueueAudio = useCallback((pcmData: ArrayBuffer) => {
96
+ const chunk = new Int16Array(pcmData)
97
+ if (chunk.length === 0) return
98
+ chunksRef.current.push(chunk)
99
+ chunksLenRef.current += chunk.length
100
+ }, [])
101
+
102
+ const flush = useCallback(() => {
103
+ if (chunksLenRef.current === 0) return
104
+ const wav = pcmChunksToWav(chunksRef.current)
105
+ chunksRef.current = []
106
+ chunksLenRef.current = 0
107
+ playWav(wav)
108
+ }, [playWav])
109
 
110
  const stopPlayback = useCallback(() => {
111
  const audio = audioRef.current
 
114
  revokeUrl()
115
  audio.src = ''
116
  }
117
+ chunksRef.current = []
118
+ chunksLenRef.current = 0
119
  }, [revokeUrl])
120
 
121
  const resume = useCallback(() => {
122
+ // Unlock audio by playing a minimal silent WAV
 
 
 
123
  const silent = new Int16Array(1)
124
  silent[0] = 0
125
+ const wav = pcmChunksToWav([silent])
126
+ playWav(wav)
127
+ }, [playWav])
 
 
 
 
128
 
129
  return {
130
  enqueueAudio,
131
+ flush,
132
  stopPlayback,
133
  resume,
134
  }
desktop/src/hooks/useCompanionWebSocket.ts CHANGED
@@ -3,6 +3,7 @@ import { useCompanionStore } from '../stores/companionStore'
3
 
4
  interface Callbacks {
5
  onAudioReceived: (data: ArrayBuffer) => void
 
6
  }
7
 
8
  export function useCompanionWebSocket(callbacks: Callbacks) {
@@ -102,6 +103,7 @@ export function useCompanionWebSocket(callbacks: Callbacks) {
102
  )
103
  setGenerating(false)
104
  setSpeaking(false)
 
105
  break
106
  }
107
  } catch {
 
3
 
4
  interface Callbacks {
5
  onAudioReceived: (data: ArrayBuffer) => void
6
+ onDone?: () => void
7
  }
8
 
9
  export function useCompanionWebSocket(callbacks: Callbacks) {
 
103
  )
104
  setGenerating(false)
105
  setSpeaking(false)
106
+ callbacksRef.current.onDone?.()
107
  break
108
  }
109
  } catch {
desktop/src/lib/audioEngine.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type EngineState =
2
+ | 'uninitialized'
3
+ | 'initializing'
4
+ | 'running'
5
+ | 'suspended'
6
+ | 'failed'
7
+ | 'closed'
8
+ | 'fallback'
9
+
10
+ // PCM Int16 -> Float32 conversion
11
+ function pcmToFloat32(int16: Int16Array): Float32Array {
12
+ const float32 = new Float32Array(int16.length)
13
+ for (let i = 0; i < int16.length; i++) {
14
+ const sample = int16[i]!
15
+ float32[i] = sample < 0 ? sample / 0x8000 : sample / 0x7fff
16
+ }
17
+ return float32
18
+ }
19
+
20
+ // PCM Int16 -> WAV Blob (for fallback mode)
21
+ function pcmToWav(pcmBuffer: ArrayBuffer): Blob {
22
+ const int16 = new Int16Array(pcmBuffer)
23
+ const numSamples = int16.length
24
+ if (numSamples === 0) {
25
+ const empty = new ArrayBuffer(44)
26
+ const v = new DataView(empty)
27
+ const w = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)) }
28
+ w(0, 'RIFF'); v.setUint32(4, 36, true); w(8, 'WAVE')
29
+ w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true)
30
+ v.setUint16(22, 1, true); v.setUint32(24, 24000, true); v.setUint32(28, 48000, true)
31
+ v.setUint16(32, 2, true); v.setUint16(34, 16, true)
32
+ w(36, 'data'); v.setUint32(40, 0, true)
33
+ return new Blob([empty], { type: 'audio/wav' })
34
+ }
35
+
36
+ const sampleRate = 24000
37
+ const numChannels = 1
38
+ const bitsPerSample = 16
39
+ const blockAlign = (numChannels * bitsPerSample) / 8
40
+ const byteRate = sampleRate * blockAlign
41
+ const dataSize = numSamples * blockAlign
42
+ const totalSize = 44 + dataSize
43
+
44
+ const buf = new ArrayBuffer(totalSize)
45
+ const v = new DataView(buf)
46
+
47
+ const w = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)) }
48
+
49
+ w(0, 'RIFF')
50
+ v.setUint32(4, totalSize - 8, true)
51
+ w(8, 'WAVE')
52
+ w(12, 'fmt ')
53
+ v.setUint32(16, 16, true) // PCM
54
+ v.setUint16(20, 1, true)
55
+ v.setUint16(22, numChannels, true)
56
+ v.setUint32(24, sampleRate, true)
57
+ v.setUint32(28, byteRate, true)
58
+ v.setUint16(32, blockAlign, true)
59
+ v.setUint16(34, bitsPerSample, true)
60
+ w(36, 'data')
61
+ v.setUint32(40, dataSize, true)
62
+
63
+ new Int16Array(buf, 44, numSamples).set(int16)
64
+ return new Blob([buf], { type: 'audio/wav' })
65
+ }
66
+
67
+ // Safety limit: 60 seconds = 1,440,000 samples @ 24kHz
68
+ const MAX_FALLBACK_SAMPLES = 24000 * 60
69
+
70
+ export class AudioEngine {
71
+ private ctx: AudioContext | null = null
72
+ private state: EngineState = 'uninitialized'
73
+ private nextTime: number = 0
74
+ private keepaliveOsc: OscillatorNode | null = null
75
+ private keepaliveGain: GainNode | null = null
76
+
77
+ // MediaStream keepalive (secondary — most reliable for WebKit)
78
+ private silentAudioEl: HTMLAudioElement | null = null
79
+ private mediaStreamDest: MediaStreamAudioDestinationNode | null = null
80
+
81
+ // Fallback accumulators
82
+ private chunks: Int16Array[] = []
83
+ private totalSamples: number = 0
84
+
85
+ // State change callback
86
+ private onStateChange: ((s: EngineState) => void) | null = null
87
+
88
+ constructor(onStateChange?: (s: EngineState) => void) {
89
+ this.onStateChange = onStateChange || null
90
+ }
91
+
92
+ getState(): EngineState {
93
+ return this.state
94
+ }
95
+
96
+ private setState(s: EngineState) {
97
+ this.state = s
98
+ this.onStateChange?.(s)
99
+ }
100
+
101
+ async initialize(): Promise<boolean> {
102
+ if (this.state === 'running') return true
103
+ if (this.state === 'fallback') return false
104
+
105
+ this.setState('initializing')
106
+
107
+ try {
108
+ // Create AudioContext (MUST be called during user gesture)
109
+ const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
110
+ this.ctx = ctx
111
+
112
+ // Primary keepalive: low-freq oscillator (inaudible, keeps context running)
113
+ this.keepaliveGain = ctx.createGain()
114
+ this.keepaliveGain.gain.value = 0.001
115
+ this.keepaliveOsc = ctx.createOscillator()
116
+ this.keepaliveOsc.frequency.value = 1
117
+ this.keepaliveOsc.connect(this.keepaliveGain)
118
+ this.keepaliveGain.connect(ctx.destination)
119
+ this.keepaliveOsc.start()
120
+
121
+ // Secondary keepalive: MediaStream destination trick
122
+ // WebKit treats a live MediaStream as non-idle and won't suspend the context
123
+ try {
124
+ this.mediaStreamDest = ctx.createMediaStreamDestination()
125
+ const keepaliveGain2 = ctx.createGain()
126
+ keepaliveGain2.gain.value = 0
127
+ keepaliveGain2.connect(this.mediaStreamDest)
128
+ this.silentAudioEl = new Audio()
129
+ this.silentAudioEl.srcObject = this.mediaStreamDest.stream
130
+ this.silentAudioEl.play().catch(() => {})
131
+ } catch {
132
+ // MediaStream destination not supported; oscillator keepalive is sufficient
133
+ }
134
+
135
+ this.nextTime = ctx.currentTime
136
+
137
+ // Handle state changes
138
+ ctx.onstatechange = () => this.handleStateChange()
139
+
140
+ this.setState('running')
141
+
142
+ console.log('[AudioEngine] AudioContext created, keepalive running')
143
+ return true
144
+ } catch (e) {
145
+ console.warn('[AudioEngine] Failed to create AudioContext, switching to fallback mode', e)
146
+ this.enterFallbackMode()
147
+ return false
148
+ }
149
+ }
150
+
151
+ private handleStateChange() {
152
+ if (!this.ctx) return
153
+ const ctx = this.ctx
154
+
155
+ if (ctx.state === 'suspended') {
156
+ console.log('[AudioEngine] context suspended, attempting resume')
157
+ this.setState('suspended')
158
+ ctx.resume()
159
+ .then(() => {
160
+ if (this.ctx?.state === 'running') {
161
+ this.setState('running')
162
+ // Reset nextTime after suspension
163
+ this.nextTime = Math.max(this.nextTime, this.ctx.currentTime)
164
+ }
165
+ })
166
+ .catch(() => {
167
+ console.warn('[AudioEngine] resume() failed, switching to fallback')
168
+ this.enterFallbackMode()
169
+ })
170
+ } else if (ctx.state === 'closed') {
171
+ console.warn('[AudioEngine] context closed, switching to fallback')
172
+ this.enterFallbackMode()
173
+ } else if (ctx.state === 'running') {
174
+ this.setState('running')
175
+ }
176
+ }
177
+
178
+ private enterFallbackMode() {
179
+ if (this.state === 'fallback') return
180
+ console.log('[AudioEngine] entering fallback mode')
181
+ this.setState('fallback')
182
+ if (this.ctx) {
183
+ this.ctx.close().catch(() => {})
184
+ this.ctx = null
185
+ }
186
+ if (this.keepaliveOsc) {
187
+ try { this.keepaliveOsc.stop() } catch {}
188
+ this.keepaliveOsc = null
189
+ }
190
+ if (this.keepaliveGain) {
191
+ this.keepaliveGain = null
192
+ }
193
+ this.cleanupMediaStream()
194
+ }
195
+
196
+ private cleanupMediaStream() {
197
+ if (this.silentAudioEl) {
198
+ this.silentAudioEl.pause()
199
+ this.silentAudioEl.srcObject = null
200
+ this.silentAudioEl = null
201
+ }
202
+ if (this.mediaStreamDest) {
203
+ this.mediaStreamDest.disconnect()
204
+ this.mediaStreamDest = null
205
+ }
206
+ }
207
+
208
+ enqueuePCM(pcmData: ArrayBuffer): void {
209
+ if (this.state === 'fallback') {
210
+ this.enqueueFallback(pcmData)
211
+ return
212
+ }
213
+ if (this.state !== 'running' || !this.ctx) {
214
+ // Drop audio until initialized; user needs to click Connect
215
+ return
216
+ }
217
+
218
+ const ctx = this.ctx
219
+ const int16 = new Int16Array(pcmData)
220
+ const float32 = pcmToFloat32(int16)
221
+
222
+ // Apply micro-fades at chunk boundaries to prevent clicking artifacts.
223
+ // 128 samples @ 24kHz = ~5.3ms fade — inaudible as a fade but eliminates
224
+ // the DC discontinuity between consecutive scheduled chunks.
225
+ const FADE_LEN = Math.min(128, float32.length >> 1)
226
+ if (FADE_LEN > 0) {
227
+ for (let i = 0; i < FADE_LEN; i++) {
228
+ const t = i / FADE_LEN
229
+ float32[i] *= t // fade in
230
+ float32[float32.length - 1 - i] *= t // fade out
231
+ }
232
+ }
233
+
234
+ // Create audio buffer
235
+ const audioBuffer = ctx.createBuffer(1, float32.length, 24000)
236
+ audioBuffer.getChannelData(0).set(float32)
237
+
238
+ // Create source node
239
+ const source = ctx.createBufferSource()
240
+ source.buffer = audioBuffer
241
+
242
+ // Connect through a gain node for consistency
243
+ // We'll use the keepalive gain's destination, but create separate gain per buffer
244
+ // Actually just connect to destination directly; we have keepalive running already
245
+ source.connect(ctx.destination)
246
+
247
+ // Schedule at the correct time for gapless playback
248
+ const startTime = Math.max(ctx.currentTime, this.nextTime)
249
+ source.start(startTime)
250
+
251
+ // Advance the scheduled time
252
+ this.nextTime = startTime + audioBuffer.duration
253
+
254
+ // Cleanup when playback ends
255
+ source.onended = () => {
256
+ try { source.disconnect() } catch {}
257
+ }
258
+ }
259
+
260
+ private enqueueFallback(pcmData: ArrayBuffer) {
261
+ const chunk = new Int16Array(pcmData)
262
+ this.chunks.push(chunk)
263
+ this.totalSamples += chunk.length
264
+
265
+ // Safety: if we exceed 60s, force-flush so user hears SOMETHING
266
+ if (this.totalSamples >= MAX_FALLBACK_SAMPLES) {
267
+ console.warn('[AudioEngine] Fallback buffer exceeded 60s, force-flushing')
268
+ // We don't flush here; flush() is called externally on `done`
269
+ // The caller needs to decide how to play the WAV
270
+ }
271
+ }
272
+
273
+ /**
274
+ * In fallback mode: concatenate all accumulated PCM and return a WAV Blob.
275
+ * In Web Audio mode: returns null (audio already scheduled).
276
+ */
277
+ flush(): Blob | null {
278
+ if (this.state === 'fallback' && this.totalSamples > 0) {
279
+ const combined = new Int16Array(this.totalSamples)
280
+ let offset = 0
281
+ for (const chunk of this.chunks) {
282
+ combined.set(chunk, offset)
283
+ offset += chunk.length
284
+ }
285
+ this.chunks = []
286
+ this.totalSamples = 0
287
+ return pcmToWav(combined.buffer)
288
+ }
289
+ // In Web Audio mode, nothing to do
290
+ return null
291
+ }
292
+
293
+ stop(): void {
294
+ // Stop keepalive
295
+ if (this.keepaliveOsc) {
296
+ try { this.keepaliveOsc.stop() } catch {}
297
+ try { this.keepaliveOsc.disconnect() } catch {}
298
+ this.keepaliveOsc = null
299
+ }
300
+ if (this.keepaliveGain) {
301
+ try { this.keepaliveGain.disconnect() } catch {}
302
+ this.keepaliveGain = null
303
+ }
304
+ this.cleanupMediaStream()
305
+
306
+ // Close AudioContext
307
+ if (this.ctx) {
308
+ this.ctx.close().catch(() => {})
309
+ this.ctx = null
310
+ }
311
+
312
+ // Clear fallback accumulators
313
+ this.chunks = []
314
+ this.totalSamples = 0
315
+
316
+ this.nextTime = 0
317
+ this.setState('uninitialized')
318
+ }
319
+ }
desktop/src/pages/Companion.tsx CHANGED
@@ -29,8 +29,11 @@ export function Companion() {
29
  },
30
  [companionAudio],
31
  )
 
 
 
32
 
33
- const ws = useCompanionWebSocket({ onAudioReceived })
34
  const webcam = useWebcam(ws.connected && cameraEnabled)
35
  useMicrophone(ws.connected && micEnabled, ws.sendAudio)
36
 
 
29
  },
30
  [companionAudio],
31
  )
32
+ const onDone = useCallback(() => {
33
+ companionAudio.flush()
34
+ }, [companionAudio])
35
 
36
+ const ws = useCompanionWebSocket({ onAudioReceived, onDone })
37
  const webcam = useWebcam(ws.connected && cameraEnabled)
38
  useMicrophone(ws.connected && micEnabled, ws.sendAudio)
39