chenbhao commited on
Commit
a260d88
Β·
1 Parent(s): a18aff5

feat: VAD but just case choose

Browse files
bun.lock CHANGED
@@ -102,6 +102,7 @@
102
  "vscode-jsonrpc": "^8.2.1",
103
  "vscode-languageserver-protocol": "^3.17.5",
104
  "vscode-languageserver-types": "^3.17.5",
 
105
  "wrap-ansi": "^10.0.0",
106
  "ws": "^8.20.0",
107
  "xss": "^1.0.15",
@@ -529,6 +530,8 @@
529
 
530
  "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
531
 
 
 
532
  "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
533
 
534
  "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
@@ -681,6 +684,8 @@
681
 
682
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
683
 
 
 
684
  "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
685
 
686
  "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
@@ -877,6 +882,8 @@
877
 
878
  "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
879
 
 
 
880
  "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
881
 
882
  "node-edge-tts": ["node-edge-tts@1.2.10", "", { "dependencies": { "https-proxy-agent": "^7.0.1", "ws": "^8.13.0", "yargs": "^17.7.2" }, "bin": { "node-edge-tts": "bin.js" } }, "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ=="],
@@ -1093,6 +1100,8 @@
1093
 
1094
  "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
1095
 
 
 
1096
  "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
1097
 
1098
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
 
102
  "vscode-jsonrpc": "^8.2.1",
103
  "vscode-languageserver-protocol": "^3.17.5",
104
  "vscode-languageserver-types": "^3.17.5",
105
+ "webrtcvad": "^1.0.1",
106
  "wrap-ansi": "^10.0.0",
107
  "ws": "^8.20.0",
108
  "xss": "^1.0.15",
 
530
 
531
  "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
532
 
533
+ "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
534
+
535
  "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
536
 
537
  "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
 
684
 
685
  "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
686
 
687
+ "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
688
+
689
  "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
690
 
691
  "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
 
882
 
883
  "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
884
 
885
+ "node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
886
+
887
  "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
888
 
889
  "node-edge-tts": ["node-edge-tts@1.2.10", "", { "dependencies": { "https-proxy-agent": "^7.0.1", "ws": "^8.13.0", "yargs": "^17.7.2" }, "bin": { "node-edge-tts": "bin.js" } }, "sha512-bV2i4XU54D45+US0Zm1HcJRkifuB3W438dWyuJEHLQdKxnuqlI1kim2MOvR6Q3XUQZvfF9PoDyR1Rt7aeXhPdQ=="],
 
1100
 
1101
  "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
1102
 
1103
+ "webrtcvad": ["webrtcvad@1.0.1", "", { "dependencies": { "bindings": "^1.3.0", "node-addon-api": "^1.7.1" } }, "sha512-oLfReCmGMpRducFWKP+o0GpKZLPj0u6qkln3P7wGaEzyjxtBiFuzvK28pmKF5SSihEkC1RGKO7Pi9C4VfY1q4Q=="],
1104
+
1105
  "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
1106
 
1107
  "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
package.json CHANGED
@@ -120,6 +120,7 @@
120
  "vscode-jsonrpc": "^8.2.1",
121
  "vscode-languageserver-protocol": "^3.17.5",
122
  "vscode-languageserver-types": "^3.17.5",
 
123
  "wrap-ansi": "^10.0.0",
124
  "ws": "^8.20.0",
125
  "xss": "^1.0.15",
 
120
  "vscode-jsonrpc": "^8.2.1",
121
  "vscode-languageserver-protocol": "^3.17.5",
122
  "vscode-languageserver-types": "^3.17.5",
123
+ "webrtcvad": "^1.0.1",
124
  "wrap-ansi": "^10.0.0",
125
  "ws": "^8.20.0",
126
  "xss": "^1.0.15",
src/components/friend/frontend/components/ChatInput.tsx CHANGED
@@ -15,8 +15,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
15
  const [menuOpen, setMenuOpen] = useState(false)
16
  const [voiceCallActive, setVoiceCallActive] = useState(false)
17
  const inputRef = useRef<HTMLInputElement>(null)
18
- const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
19
- const lastSentIndexRef = useRef(0)
20
  const voiceCallActiveRef = useRef(false)
21
  const endVoiceCallRef = useRef<() => void>(() => {})
22
  const serverStt = useServerStt()
@@ -79,13 +77,6 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
79
  if (recording) stopRecording()
80
  }, [recording, stopRecording])
81
 
82
- // --- Voice Call: constants ---
83
- const HARD_PUNCT = /[γ€‚οΌοΌŸ\.\!\?]$/
84
- const SOFT_PUNCT = /[οΌŒγ€οΌ›,;::]$/
85
- const SOFT_PUNCT_MIN_LEN = 10
86
- const MAX_UNSENT_LEN = 30
87
- const SILENCE_SEND_MS = 1200
88
-
89
  // --- Voice Call: delayed TTS interrupt ---
90
  const interruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
91
  const scheduleInterrupt = useCallback(() => {
@@ -99,90 +90,38 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
99
  if (interruptTimerRef.current) { clearTimeout(interruptTimerRef.current); interruptTimerRef.current = null }
100
  }, [])
101
 
102
- // --- Voice Call: send + mute capture for 3s after send ---
103
- const VOICE_MUTE_AFTER_SEND_MS = 2000
104
- const mutedUntilRef = useRef(0)
105
-
106
- const voiceCallSend = useCallback(async (msg: string) => {
107
- const trimmed = msg.trim()
108
- if (!trimmed) return
109
- mutedUntilRef.current = Date.now() + VOICE_MUTE_AFTER_SEND_MS
110
- cancelInterrupt()
111
- ;(window as any).__clawInterruptAudio?.()
112
- try {
113
- await fetch(`${FRIEND_API}/chat`, {
114
- method: 'POST',
115
- headers: { 'Content-Type': 'application/json' },
116
- body: JSON.stringify({ message: trimmed }),
117
- })
118
- } catch (err) {
119
- console.error('Voice call send failed:', err)
120
- }
121
- }, [cancelInterrupt])
122
-
123
- // --- Voice Call: smart sentence segmentation ---
124
- const handleVoiceCallResult = useCallback((fullTranscript: string, isFinal: boolean) => {
125
- if (Date.now() < mutedUntilRef.current) {
126
- lastSentIndexRef.current = fullTranscript.length
127
- return
128
- }
129
-
130
- const unsent = fullTranscript.slice(lastSentIndexRef.current)
131
- setText(unsent)
132
- onActiveChange?.(unsent.length > 0)
133
-
134
- if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
135
-
136
- if (isFinal) {
137
- const shouldSend =
138
- HARD_PUNCT.test(unsent) ||
139
- (SOFT_PUNCT.test(unsent) && unsent.length >= SOFT_PUNCT_MIN_LEN) ||
140
- unsent.length >= MAX_UNSENT_LEN
141
-
142
- if (shouldSend) {
143
- voiceCallSend(unsent)
144
- lastSentIndexRef.current = fullTranscript.length
145
- setText('')
146
- onActiveChange?.(false)
147
- return
148
- }
149
- }
150
-
151
- if (unsent.trim()) {
152
- silenceTimerRef.current = setTimeout(() => {
153
- if (!voiceCallActiveRef.current) return
154
- const chunk = unsent.trim()
155
- if (chunk) {
156
- voiceCallSend(chunk)
157
- lastSentIndexRef.current = fullTranscript.length
158
- setText('')
159
- onActiveChange?.(false)
160
- }
161
- }, SILENCE_SEND_MS)
162
- }
163
- }, [voiceCallSend, onActiveChange])
164
 
165
- // --- Voice Call: start (always server-side, no getUserMedia) ---
166
  const startVoiceCall = useCallback(async () => {
167
  setVoiceCallActive(true)
168
  voiceCallActiveRef.current = true
169
- lastSentIndexRef.current = 0
170
  setText('')
171
  setOpen(true)
172
 
173
- // Always use server-side STT via HTTP polling.
174
- // Audio capture is done server-side via cpal (in-process native addon)
175
- // so no getUserMedia call is needed on the frontend β€” this avoids
176
- // WebKitGTK permission issues on Linux.
177
  serverStt.startStreaming(
178
  sttProvider,
179
- (text, isFinal) => {
180
- handleVoiceCallResult(text, isFinal)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  },
182
  (err) => {
183
- console.error('Server STT error:', err)
184
  if (voiceCallActiveRef.current) {
185
- // Show error text in the input bar, then end call after 3s
186
  setText(`语音启动倱θ΄₯: ${err}`)
187
  setTimeout(() => {
188
  setText('')
@@ -193,7 +132,7 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
193
  language === 'en' ? 'en' : 'zh',
194
  )
195
  setRecording(true)
196
- }, [handleVoiceCallResult, sttProvider, serverStt, language])
197
 
198
  // --- Voice Call: end ---
199
  const endVoiceCall = useCallback(async () => {
@@ -202,22 +141,21 @@ export function ChatInput({ visible = true, onActiveChange, uiAlign = 'right', o
202
  setVoiceCallActive(false)
203
  setRecording(false)
204
 
205
- // Stop capture and get the final transcript
206
- const text = await serverStt.stopStreaming()
207
 
208
- // Send any remaining transcript as a message (no-op if empty)
209
- if (text.trim()) {
210
- voiceCallSend(text)
 
211
  }
212
 
213
- if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null }
214
  cancelInterrupt()
215
- mutedUntilRef.current = 0
216
 
217
  setText('')
218
  setOpen(false)
219
  onActiveChange?.(false)
220
- }, [onActiveChange, cancelInterrupt, serverStt, voiceCallSend])
221
 
222
  endVoiceCallRef.current = endVoiceCall
223
 
 
15
  const [menuOpen, setMenuOpen] = useState(false)
16
  const [voiceCallActive, setVoiceCallActive] = useState(false)
17
  const inputRef = useRef<HTMLInputElement>(null)
 
 
18
  const voiceCallActiveRef = useRef(false)
19
  const endVoiceCallRef = useRef<() => void>(() => {})
20
  const serverStt = useServerStt()
 
77
  if (recording) stopRecording()
78
  }, [recording, stopRecording])
79
 
 
 
 
 
 
 
 
80
  // --- Voice Call: delayed TTS interrupt ---
81
  const interruptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
82
  const scheduleInterrupt = useCallback(() => {
 
90
  if (interruptTimerRef.current) { clearTimeout(interruptTimerRef.current); interruptTimerRef.current = null }
91
  }, [])
92
 
93
+ // Voice call display timer β€” clear text after 3s
94
+ const displayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
+ // --- Voice Call: start (browser VAD with @ricky0123/vad-web) ---
97
  const startVoiceCall = useCallback(async () => {
98
  setVoiceCallActive(true)
99
  voiceCallActiveRef.current = true
 
100
  setText('')
101
  setOpen(true)
102
 
 
 
 
 
103
  serverStt.startStreaming(
104
  sttProvider,
105
+ (text, _isFinal) => {
106
+ // Server-side transcribeAudioSegment() already calls sendText()
107
+ // to enqueue the message into the CLI conversation.
108
+ // We only need to display the transcript in the input bar briefly.
109
+ if (text.trim()) {
110
+ setText(text)
111
+ onActiveChange?.(true)
112
+ // Auto-clear after 3s
113
+ if (displayTimerRef.current) clearTimeout(displayTimerRef.current)
114
+ displayTimerRef.current = setTimeout(() => {
115
+ if (voiceCallActiveRef.current) {
116
+ setText('')
117
+ onActiveChange?.(false)
118
+ }
119
+ }, 3000)
120
+ }
121
  },
122
  (err) => {
123
+ console.error('Browser VAD error:', err)
124
  if (voiceCallActiveRef.current) {
 
125
  setText(`语音启动倱θ΄₯: ${err}`)
126
  setTimeout(() => {
127
  setText('')
 
132
  language === 'en' ? 'en' : 'zh',
133
  )
134
  setRecording(true)
135
+ }, [sttProvider, serverStt, language, onActiveChange])
136
 
137
  // --- Voice Call: end ---
138
  const endVoiceCall = useCallback(async () => {
 
141
  setVoiceCallActive(false)
142
  setRecording(false)
143
 
144
+ // Stop VAD and clean up
145
+ const finalText = await serverStt.stopStreaming()
146
 
147
+ // Send any remaining transcript (no-op if empty β€” segments were already
148
+ // sent to CLI by transcribeAudioSegment() during the call).
149
+ if (finalText.trim()) {
150
+ send(finalText)
151
  }
152
 
 
153
  cancelInterrupt()
 
154
 
155
  setText('')
156
  setOpen(false)
157
  onActiveChange?.(false)
158
+ }, [onActiveChange, cancelInterrupt, serverStt, send])
159
 
160
  endVoiceCallRef.current = endVoiceCall
161
 
src/components/friend/frontend/hooks/useServerStt.ts CHANGED
@@ -1,17 +1,14 @@
1
  /**
2
- * Hook for using server-side STT (Groq Whisper, Anthropic Voice Stream, Local Whisper, Doubao)
3
  * in the Friend Tauri app.
4
  *
5
- * Audio capture is done server-side via cpal (in-process native addon)
6
- * so no getUserMedia call is needed on the frontend β€” this avoids
7
- * WebKitGTK permission issues on Linux.
8
  *
9
  * Two modes:
10
  * - push-to-talk: POST /voice/start β†’ capture β†’ POST /voice/stop β†’ get text
11
- * - streaming: POST /voice/start β†’ poll /voice/status + auto-cycle every 3s
12
- *
13
- * The auto-cycle in streaming mode ensures batch STT providers (Groq, local Whisper)
14
- * produce regular final transcripts even though they don't support interim results.
15
  */
16
  import { useRef, useCallback, useState } from 'react'
17
 
@@ -19,11 +16,153 @@ const FRIEND_API_BASE = 'http://127.0.0.1:3456/plugins/friend'
19
 
20
  export type SttProvider = 'browser' | 'groq' | 'anthropic' | 'local' | 'doubao'
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  export function useServerStt() {
23
  const [connected, setConnected] = useState(false)
24
- const cycleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
25
- const cycleAccumRef = useRef('')
26
- const streamingGenRef = useRef(0) // generation counter to prevent stale callbacks
 
 
 
 
27
 
28
  // ── Push-to-talk ──────────────────────────────────────────────────────
29
 
@@ -53,48 +192,16 @@ export function useServerStt() {
53
  return data.text || ''
54
  }, [])
55
 
56
- // ── Streaming (voice call) with auto-cycle ────────────────────────────
57
-
58
- const doCycle = useCallback(
59
- (onTranscript: (text: string, isFinal: boolean) => void, gen: number): void => {
60
- const isStale = () => streamingGenRef.current !== gen
61
- fetch(`${FRIEND_API_BASE}/voice/stop`, { method: 'POST' })
62
- .then(async (res) => {
63
- if (isStale()) return
64
- if (!res.ok) return
65
- const data = await res.json()
66
- if (data.text?.trim()) {
67
- cycleAccumRef.current += data.text
68
- onTranscript(cycleAccumRef.current, true)
69
- }
70
- })
71
- .then(() => {
72
- if (isStale()) return
73
- // Restart capture for next cycle
74
- return fetch(`${FRIEND_API_BASE}/voice/start`, { method: 'POST' })
75
- })
76
- .then(async (res) => {
77
- if (isStale()) return
78
- if (!res?.ok) {
79
- setConnected(false)
80
- return
81
- }
82
- setConnected(true)
83
- // Schedule next cycle in 3s
84
- cycleTimerRef.current = setTimeout(
85
- () => doCycle(onTranscript, gen),
86
- 3000,
87
- )
88
- })
89
- .catch(() => {})
90
- },
91
- [],
92
- )
93
 
94
  /**
95
- * Start streaming STT for voice call mode.
96
- * Polls interim results every 500ms AND auto-cycles every 3s to get
97
- * final transcripts from batch STT providers (Groq, local Whisper).
 
 
 
 
98
  */
99
  const startStreaming = useCallback(
100
  (
@@ -103,80 +210,115 @@ export function useServerStt() {
103
  onError: (err: string) => void,
104
  _language = 'zh',
105
  ) => {
106
- // Clean up any existing state and bump generation
107
- cycleAccumRef.current = ''
108
- const myGen = ++streamingGenRef.current
109
- const existingPollId = (window as any).__friendSttPollId
110
- if (existingPollId) { clearInterval(existingPollId); delete (window as any).__friendSttPollId }
111
- if (cycleTimerRef.current) { clearTimeout(cycleTimerRef.current); cycleTimerRef.current = null }
112
-
113
- fetch(`${FRIEND_API_BASE}/voice/start`, { method: 'POST' })
114
- .then(async (res) => {
115
- if (streamingGenRef.current !== myGen) return
116
- if (!res.ok) {
117
- const body = await res.json().catch(() => ({}))
118
- throw new Error(body.error || 'STT start failed')
119
- }
120
- setConnected(true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
- // Poll for interim results every 500ms (works with streaming STT providers)
123
- const pollId = setInterval(async () => {
124
- try {
125
- if (streamingGenRef.current !== myGen) { clearInterval(pollId); return }
126
- const statusRes = await fetch(`${FRIEND_API_BASE}/voice/status`, {
127
  method: 'POST',
 
 
128
  })
129
- if (!statusRes.ok) return
130
- const status = await statusRes.json()
131
- if (status.interimText) {
132
- onTranscript(status.interimText, false)
133
- }
134
- } catch {
135
- // poll failed, ignore
 
 
 
 
 
 
 
136
  }
137
- }, 500)
138
- ;(window as any).__friendSttPollId = pollId
139
-
140
- // Schedule first auto-cycle in 3s (for batch STT providers like Groq)
141
- cycleTimerRef.current = setTimeout(
142
- () => doCycle(onTranscript, myGen),
143
- 3000,
144
- )
145
- })
146
- .catch((err) => {
147
- if (streamingGenRef.current === myGen) {
148
- onError(err.message)
149
  }
150
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  },
152
- [doCycle],
153
  )
154
 
155
- /**
156
- * Stop streaming STT.
157
- * Cleans up poll interval and auto-cycle, then returns the final transcript.
158
- */
159
- const stopStreaming = useCallback(async (): Promise<string> => {
160
- // Stop all activity by bumping generation counter
161
- streamingGenRef.current++
162
- const pollId = (window as any).__friendSttPollId
163
- if (pollId) {
164
- clearInterval(pollId)
165
- delete (window as any).__friendSttPollId
166
  }
167
-
168
- // Stop auto-cycle
169
- if (cycleTimerRef.current) {
170
- clearTimeout(cycleTimerRef.current)
171
- cycleTimerRef.current = null
172
  }
173
- cycleAccumRef.current = ''
 
 
 
 
 
 
 
 
 
174
 
175
- const res = await fetch(`${FRIEND_API_BASE}/voice/stop`, { method: 'POST' })
 
 
 
 
 
176
  setConnected(false)
177
- if (!res.ok) return ''
178
- const data = await res.json()
179
- return data.text || ''
180
  }, [])
181
 
182
  return {
 
1
  /**
2
+ * Hook for using browser-based energy VAD + server-side STT
3
  * in the Friend Tauri app.
4
  *
5
+ * Audio capture and VAD run in the browser via Web Audio API.
6
+ * Uses simple energy (RMS) detection for voice activity β€”
7
+ * no WASM or external dependencies needed.
8
  *
9
  * Two modes:
10
  * - push-to-talk: POST /voice/start β†’ capture β†’ POST /voice/stop β†’ get text
11
+ * - voice-call: Browser energy VAD detects speech segments β†’ sends to server for STT
 
 
 
12
  */
13
  import { useRef, useCallback, useState } from 'react'
14
 
 
16
 
17
  export type SttProvider = 'browser' | 'groq' | 'anthropic' | 'local' | 'doubao'
18
 
19
+ /**
20
+ * Convert Float32Array audio (between -1 and 1, 16kHz) to a WAV Blob.
21
+ */
22
+ function float32ToWav(samples: Float32Array, sampleRate = 16000): Blob {
23
+ const numChannels = 1
24
+ const bitsPerSample = 16
25
+ const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
26
+ const blockAlign = numChannels * (bitsPerSample / 8)
27
+ const dataSize = samples.length * (bitsPerSample / 8)
28
+ const headerSize = 44
29
+ const totalSize = headerSize + dataSize
30
+
31
+ const buffer = new ArrayBuffer(totalSize)
32
+ const view = new DataView(buffer)
33
+
34
+ // RIFF header
35
+ writeString(view, 0, 'RIFF')
36
+ view.setUint32(4, totalSize - 8, true)
37
+ writeString(view, 8, 'WAVE')
38
+
39
+ // fmt chunk
40
+ writeString(view, 12, 'fmt ')
41
+ view.setUint32(16, 16, true)
42
+ view.setUint16(20, 1, true) // PCM
43
+ view.setUint16(22, numChannels, true)
44
+ view.setUint32(24, sampleRate, true)
45
+ view.setUint32(28, byteRate, true)
46
+ view.setUint16(32, blockAlign, true)
47
+ view.setUint16(34, bitsPerSample, true)
48
+
49
+ // data chunk
50
+ writeString(view, 36, 'data')
51
+ view.setUint32(40, dataSize, true)
52
+
53
+ // Write PCM samples
54
+ let offset = 44
55
+ for (let i = 0; i < samples.length; i++) {
56
+ const s = Math.max(-1, Math.min(1, samples[i]))
57
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
58
+ offset += 2
59
+ }
60
+
61
+ return new Blob([buffer], { type: 'audio/wav' })
62
+ }
63
+
64
+ function writeString(view: DataView, offset: number, str: string): void {
65
+ for (let i = 0; i < str.length; i++) {
66
+ view.setUint8(offset + i, str.charCodeAt(i))
67
+ }
68
+ }
69
+
70
+ /** Compute RMS (root mean square) energy of a float32 audio frame */
71
+ function computeRms(samples: Float32Array): number {
72
+ let sum = 0
73
+ for (let i = 0; i < samples.length; i++) {
74
+ sum += samples[i] * samples[i]
75
+ }
76
+ return Math.sqrt(sum / samples.length)
77
+ }
78
+
79
+ /**
80
+ * Simple energy-based Voice Activity Detector.
81
+ * Uses RMS threshold with configurable hangover time.
82
+ */
83
+ class EnergyVad {
84
+ private threshold: number
85
+ private hangoverFrames: number
86
+ private speaking = false
87
+ private silentFrames = 0
88
+ private speechFrames: Float32Array[] = []
89
+ private speechStartRms = 0
90
+
91
+ constructor(threshold = 0.008, hangoverMs = 800, frameMs = 30) {
92
+ this.threshold = threshold
93
+ this.hangoverFrames = Math.ceil(hangoverMs / frameMs)
94
+ }
95
+
96
+ /** Process a single audio frame. Returns true if speech just ended (with accumulated audio). */
97
+ process(frame: Float32Array, rms?: number): { speechEnded: boolean; audio: Float32Array | null } {
98
+ const energy = rms ?? computeRms(frame)
99
+
100
+ if (energy > this.threshold && !this.speaking) {
101
+ // Speech just started
102
+ this.speaking = true
103
+ this.silentFrames = 0
104
+ this.speechFrames = [frame]
105
+ this.speechStartRms = energy
106
+ return { speechEnded: false, audio: null }
107
+ }
108
+
109
+ if (this.speaking) {
110
+ this.speechFrames.push(frame)
111
+
112
+ if (energy < this.threshold) {
113
+ this.silentFrames++
114
+ if (this.silentFrames >= this.hangoverFrames) {
115
+ // Speech ended after hangover
116
+ const audio = this.flush()
117
+ return { speechEnded: true, audio }
118
+ }
119
+ } else {
120
+ this.silentFrames = 0
121
+ }
122
+ }
123
+
124
+ return { speechEnded: false, audio: null }
125
+ }
126
+
127
+ /** Flush accumulated audio and reset state */
128
+ private flush(): Float32Array | null {
129
+ if (this.speechFrames.length < 3) {
130
+ this.speechFrames = []
131
+ this.speaking = false
132
+ return null
133
+ }
134
+ const totalLen = this.speechFrames.reduce((s, f) => s + f.length, 0)
135
+ const merged = new Float32Array(totalLen)
136
+ let offset = 0
137
+ for (const f of this.speechFrames) {
138
+ merged.set(f, offset)
139
+ offset += f.length
140
+ }
141
+ this.speechFrames = []
142
+ this.speaking = false
143
+ return merged
144
+ }
145
+
146
+ reset(): void {
147
+ this.speaking = false
148
+ this.silentFrames = 0
149
+ this.speechFrames = []
150
+ }
151
+
152
+ isSpeaking(): boolean {
153
+ return this.speaking
154
+ }
155
+ }
156
+
157
  export function useServerStt() {
158
  const [connected, setConnected] = useState(false)
159
+ const onTranscriptRef = useRef<((text: string, isFinal: boolean) => void) | null>(null)
160
+ const onErrorRef = useRef<((err: string) => void) | null>(null)
161
+ const vadRef = useRef<EnergyVad | null>(null)
162
+ const audioContextRef = useRef<AudioContext | null>(null)
163
+ const streamRef = useRef<MediaStream | null>(null)
164
+ const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null)
165
+ const scriptNodeRef = useRef<ScriptProcessorNode | null>(null)
166
 
167
  // ── Push-to-talk ──────────────────────────────────────────────────────
168
 
 
192
  return data.text || ''
193
  }, [])
194
 
195
+ // ── Voice call with browser energy VAD ────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  /**
198
+ * Start voice call mode with browser-based energy VAD.
199
+ *
200
+ * Uses Web Audio API (ScriptProcessorNode) to detect speech segments
201
+ * via RMS threshold. No WASM or external dependencies.
202
+ *
203
+ * When a speech segment ends, the audio is sent to the server for STT
204
+ * transcription (same /voice/stt-segment endpoint).
205
  */
206
  const startStreaming = useCallback(
207
  (
 
210
  onError: (err: string) => void,
211
  _language = 'zh',
212
  ) => {
213
+ // Clean up any existing session
214
+ stopCapture()
215
+
216
+ onTranscriptRef.current = onTranscript
217
+ onErrorRef.current = onError
218
+
219
+ const startCapture = async () => {
220
+ try {
221
+ const stream = await navigator.mediaDevices.getUserMedia({
222
+ audio: {
223
+ channelCount: 1,
224
+ echoCancellation: true,
225
+ autoGainControl: true,
226
+ noiseSuppression: true,
227
+ sampleRate: 16000,
228
+ },
229
+ })
230
+ streamRef.current = stream
231
+
232
+ const audioCtx = new AudioContext({ sampleRate: 16000 })
233
+ audioContextRef.current = audioCtx
234
+
235
+ const source = audioCtx.createMediaStreamSource(stream)
236
+ sourceNodeRef.current = source
237
+
238
+ const scriptNode = audioCtx.createScriptProcessor(1024, 1, 1)
239
+ scriptNodeRef.current = scriptNode
240
+
241
+ const vad = new EnergyVad(0.006, 800, 64)
242
+ vadRef.current = vad
243
+
244
+ scriptNode.onaudioprocess = (e) => {
245
+ if (!vadRef.current) return
246
+
247
+ const input = e.inputBuffer.getChannelData(0)
248
+ const rms = computeRms(input)
249
+ const result = vadRef.current.process(input, rms)
250
+
251
+ if (result.speechEnded && result.audio && result.audio.length > 0) {
252
+ // Send to server for STT
253
+ const wavBlob = float32ToWav(result.audio)
254
 
255
+ fetch(`${FRIEND_API_BASE}/voice/stt-segment`, {
 
 
 
 
256
  method: 'POST',
257
+ body: wavBlob,
258
+ headers: { 'Content-Type': 'application/octet-stream' },
259
  })
260
+ .then(async (res) => {
261
+ if (!res.ok) {
262
+ const errBody = await res.json().catch(() => ({}))
263
+ throw new Error(errBody.error || `STT failed (${res.status})`)
264
+ }
265
+ const data = await res.json()
266
+ if (data.text && data.text.trim()) {
267
+ onTranscriptRef.current?.(data.text, true)
268
+ }
269
+ })
270
+ .catch((err) => {
271
+ console.error('[EnergyVAD] STT segment error:', err)
272
+ onErrorRef.current?.(err.message)
273
+ })
274
  }
 
 
 
 
 
 
 
 
 
 
 
 
275
  }
276
+
277
+ // Ensure output is connected (needed for Chrome/Chromium)
278
+ scriptNode.connect(audioCtx.destination)
279
+ source.connect(scriptNode)
280
+
281
+ setConnected(true)
282
+ console.log('[EnergyVAD] Started')
283
+ } catch (err: any) {
284
+ console.error('[EnergyVAD] Init failed:', err)
285
+ onErrorRef.current?.(String(err))
286
+ }
287
+ }
288
+
289
+ startCapture()
290
  },
291
+ [],
292
  )
293
 
294
+ function stopCapture(): void {
295
+ if (scriptNodeRef.current) {
296
+ try { scriptNodeRef.current.disconnect() } catch {}
297
+ scriptNodeRef.current = null
 
 
 
 
 
 
 
298
  }
299
+ if (sourceNodeRef.current) {
300
+ try { sourceNodeRef.current.disconnect() } catch {}
301
+ sourceNodeRef.current = null
 
 
302
  }
303
+ if (audioContextRef.current) {
304
+ try { audioContextRef.current.close() } catch {}
305
+ audioContextRef.current = null
306
+ }
307
+ if (streamRef.current) {
308
+ streamRef.current.getTracks().forEach(t => t.stop())
309
+ streamRef.current = null
310
+ }
311
+ vadRef.current = null
312
+ }
313
 
314
+ /**
315
+ * Stop voice call mode.
316
+ * Cleans up all audio resources.
317
+ */
318
+ const stopStreaming = useCallback(async (): Promise<string> => {
319
+ stopCapture()
320
  setConnected(false)
321
+ return ''
 
 
322
  }, [])
323
 
324
  return {
src/components/friend/frontend/package-lock.json CHANGED
@@ -11,6 +11,7 @@
11
  "@pixiv/three-vrm": "^3.4.0",
12
  "@pixiv/three-vrm-animation": "^3.5.0",
13
  "@pixiv/three-vrm-core": "^3.5.1",
 
14
  "lucide-react": "^0.577.0",
15
  "marked": "^18.0.5",
16
  "mmd-parser": "^1.0.4",
@@ -1358,6 +1359,16 @@
1358
  "win32"
1359
  ]
1360
  },
 
 
 
 
 
 
 
 
 
 
1361
  "node_modules/@tauri-apps/cli": {
1362
  "version": "2.11.3",
1363
  "resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.3.tgz",
 
11
  "@pixiv/three-vrm": "^3.4.0",
12
  "@pixiv/three-vrm-animation": "^3.5.0",
13
  "@pixiv/three-vrm-core": "^3.5.1",
14
+ "@tauri-apps/api": "^2.11.1",
15
  "lucide-react": "^0.577.0",
16
  "marked": "^18.0.5",
17
  "mmd-parser": "^1.0.4",
 
1359
  "win32"
1360
  ]
1361
  },
1362
+ "node_modules/@tauri-apps/api": {
1363
+ "version": "2.11.1",
1364
+ "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.11.1.tgz",
1365
+ "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
1366
+ "license": "Apache-2.0 OR MIT",
1367
+ "funding": {
1368
+ "type": "opencollective",
1369
+ "url": "https://opencollective.com/tauri"
1370
+ }
1371
+ },
1372
  "node_modules/@tauri-apps/cli": {
1373
  "version": "2.11.3",
1374
  "resolved": "https://registry.npmmirror.com/@tauri-apps/cli/-/cli-2.11.3.tgz",
src/components/friend/frontend/package.json CHANGED
@@ -11,6 +11,7 @@
11
  "@pixiv/three-vrm": "^3.4.0",
12
  "@pixiv/three-vrm-animation": "^3.5.0",
13
  "@pixiv/three-vrm-core": "^3.5.1",
 
14
  "lucide-react": "^0.577.0",
15
  "marked": "^18.0.5",
16
  "mmd-parser": "^1.0.4",
 
11
  "@pixiv/three-vrm": "^3.4.0",
12
  "@pixiv/three-vrm-animation": "^3.5.0",
13
  "@pixiv/three-vrm-core": "^3.5.1",
14
+ "@tauri-apps/api": "^2.11.1",
15
  "lucide-react": "^0.577.0",
16
  "marked": "^18.0.5",
17
  "mmd-parser": "^1.0.4",
src/components/friend/frontend/src-tauri/Cargo.lock CHANGED
@@ -4030,6 +4030,7 @@ dependencies = [
4030
  "tauri",
4031
  "tauri-build",
4032
  "tauri-plugin-opener",
 
4033
  ]
4034
 
4035
  [[package]]
 
4030
  "tauri",
4031
  "tauri-build",
4032
  "tauri-plugin-opener",
4033
+ "webkit2gtk",
4034
  ]
4035
 
4036
  [[package]]
src/components/friend/frontend/src-tauri/Cargo.toml CHANGED
@@ -17,6 +17,7 @@ tauri = { version = "2", features = [] }
17
  tauri-plugin-opener = "2"
18
  serde = { version = "1", features = ["derive"] }
19
  serde_json = "1"
 
20
 
21
  [features]
22
  default = ["custom-protocol"]
 
17
  tauri-plugin-opener = "2"
18
  serde = { version = "1", features = ["derive"] }
19
  serde_json = "1"
20
+ webkit2gtk = "2.0.2"
21
 
22
  [features]
23
  default = ["custom-protocol"]
src/components/friend/frontend/src-tauri/src/lib.rs CHANGED
@@ -6,7 +6,22 @@ pub fn run() {
6
  .plugin(tauri_plugin_opener::init())
7
  .setup(|app| {
8
  let window = app.get_webview_window("main").unwrap();
9
- window.set_focus().ok();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  Ok(())
11
  })
12
  .on_window_event(|window, event| {
@@ -17,4 +32,4 @@ pub fn run() {
17
 
18
  builder.run(tauri::generate_context!())
19
  .expect("error while running tauri application");
20
- }
 
6
  .plugin(tauri_plugin_opener::init())
7
  .setup(|app| {
8
  let window = app.get_webview_window("main").unwrap();
9
+
10
+ // Allow microphone/camera access for browser VAD (getUserMedia)
11
+ #[cfg(target_os = "linux")]
12
+ {
13
+ use webkit2gtk::{PermissionRequestExt, WebViewExt};
14
+ let _ = window.with_webview(|webview| {
15
+ let platform = webview.inner();
16
+ platform.connect_permission_request(|_webview, request| {
17
+ // Allow all permission requests (media, etc.)
18
+ request.allow();
19
+ true
20
+ });
21
+ });
22
+ }
23
+
24
+ let _ = window.set_focus();
25
  Ok(())
26
  })
27
  .on_window_event(|window, event| {
 
32
 
33
  builder.run(tauri::generate_context!())
34
  .expect("error while running tauri application");
35
+ }
src/friend/FriendService.ts CHANGED
@@ -325,6 +325,14 @@ class FriendService {
325
  * Stop voice capture and return the accumulated transcript.
326
  */
327
  async stopVoiceCapture(): Promise<string> {
 
 
 
 
 
 
 
 
328
  if (!this.capturing) return '';
329
 
330
  this.capturing = false;
@@ -348,6 +356,7 @@ class FriendService {
348
  }
349
 
350
  const transcript = this.captureTranscripts.join('');
 
351
  this.captureTranscripts = [];
352
  this.captureInterimText = '';
353
  this.setState({ captureStatus: { capturing: false } });
@@ -355,10 +364,50 @@ class FriendService {
355
  }
356
 
357
  /**
358
- * Get current capture status (for voice call interim polling).
359
  */
360
  getCaptureStatus(): { capturing: boolean; interimText?: string } {
361
- return this.state.captureStatus ?? { capturing: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  }
363
 
364
  // ── Response broadcast (called by useFriendBridge) ───────────────────
@@ -474,18 +523,54 @@ class FriendService {
474
 
475
  this.audioCapture = {
476
  startRecording: async (onData, _onEnd) => {
477
- for (const tool of ['parecord', 'arecord']) {
478
  try {
479
- const args = tool === 'parecord'
480
- ? ['--raw', '--rate=16000', '--format=s16le', '--channels=1', '--latency-msec=20']
481
- : ['-D', 'default', '-r', '16000', '-f', 'S16_LE', '-c', '1', '-t', 'raw', '-q'];
482
  const proc = spawn(tool, args, { stdio: ['pipe', 'pipe', 'pipe'] });
483
- if (proc.pid !== undefined) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  captureProc = proc;
485
- proc.stdout?.on('data', onData);
486
  proc.on('exit', () => { captureProc = null; });
487
  return true;
488
  }
 
 
 
489
  } catch {
490
  continue;
491
  }
 
325
  * Stop voice capture and return the accumulated transcript.
326
  */
327
  async stopVoiceCapture(): Promise<string> {
328
+ return this._stopCapture();
329
+ }
330
+
331
+ /**
332
+ * Internal: stop audio capture + finalize STT, return transcript.
333
+ */
334
+ private async _stopCapture(): Promise<string> {
335
+ console.log(`[FriendService] _stopCapture: capturing=${this.capturing} sttConnection=${this.sttConnection ? 'exists' : 'null'}`);
336
  if (!this.capturing) return '';
337
 
338
  this.capturing = false;
 
356
  }
357
 
358
  const transcript = this.captureTranscripts.join('');
359
+ console.log(`[FriendService] _stopCapture: transcript="${transcript}" (len=${transcript.length})`);
360
  this.captureTranscripts = [];
361
  this.captureInterimText = '';
362
  this.setState({ captureStatus: { capturing: false } });
 
364
  }
365
 
366
  /**
367
+ * Get current capture status (for push-to-talk mode).
368
  */
369
  getCaptureStatus(): { capturing: boolean; interimText?: string } {
370
+ const status = this.state.captureStatus ?? { capturing: false };
371
+ return { ...status };
372
+ }
373
+
374
+ /**
375
+ * Transcribe an audio buffer (PCM/WAV) using the configured STT provider
376
+ * and send the resulting text to the CLI conversation.
377
+ *
378
+ * Called by the HTTP endpoint when the browser VAD detects a speech segment.
379
+ */
380
+ async transcribeAudioSegment(audioBuffer: Buffer): Promise<string> {
381
+ const prefs = getPrefs();
382
+ let provider = prefs.sttProvider;
383
+ if (!provider || provider === 'browser') {
384
+ provider = await this.detectAvailableSttProvider();
385
+ }
386
+
387
+ // Create a temporary STT connection just for this segment
388
+ const conn = await this.startSttConnectionWithTimeout(
389
+ provider,
390
+ prefs.sttLanguage || 'zh',
391
+ );
392
+
393
+ try {
394
+ conn.send(audioBuffer);
395
+ await conn.finalize();
396
+ conn.close();
397
+ } catch (err) {
398
+ conn.close();
399
+ throw err;
400
+ }
401
+
402
+ // Get transcript from the finalization callback
403
+ const transcript = this.captureTranscripts.join('');
404
+ this.captureTranscripts = [];
405
+
406
+ if (transcript.trim()) {
407
+ this.sendText(transcript);
408
+ }
409
+
410
+ return transcript;
411
  }
412
 
413
  // ── Response broadcast (called by useFriendBridge) ───────────────────
 
523
 
524
  this.audioCapture = {
525
  startRecording: async (onData, _onEnd) => {
526
+ for (const tool of ['arecord', 'parecord']) {
527
  try {
528
+ const args = tool === 'arecord'
529
+ ? ['-D', 'default', '-r', '16000', '-f', 'S16_LE', '-c', '1', '-t', 'raw', '-q']
530
+ : ['--raw', '--rate=16000', '--format=s16le', '--channels=1', '--latency-msec=20'];
531
  const proc = spawn(tool, args, { stdio: ['pipe', 'pipe', 'pipe'] });
532
+ if (proc.pid === undefined) continue;
533
+
534
+ // Verify the tool actually produces audio data within 500ms.
535
+ // Some tools (e.g. parecord without PulseAudio) spawn successfully
536
+ // but exit immediately without any output β€” catch that here.
537
+ let dataArrived = false;
538
+ let verifyTimer: ReturnType<typeof setTimeout> | null = null;
539
+
540
+ const verified = await new Promise<boolean>((resolve) => {
541
+ const dataHandler = (chunk: Buffer) => {
542
+ dataArrived = true;
543
+ if (verifyTimer) { clearTimeout(verifyTimer); }
544
+ // Forward first chunk, then swap to the permanent handler
545
+ onData(chunk);
546
+ proc.stdout?.removeListener('data', dataHandler);
547
+ proc.stdout?.on('data', onData);
548
+ resolve(true);
549
+ };
550
+ proc.stdout?.on('data', dataHandler);
551
+
552
+ // Process exited before producing data β€” mark as failed
553
+ proc.on('exit', () => {
554
+ if (!dataArrived) {
555
+ if (verifyTimer) { clearTimeout(verifyTimer); }
556
+ resolve(false);
557
+ }
558
+ });
559
+
560
+ // No data within 500ms β€” assume failure
561
+ verifyTimer = setTimeout(() => {
562
+ if (!dataArrived) resolve(false);
563
+ }, 500);
564
+ });
565
+
566
+ if (verified) {
567
  captureProc = proc;
 
568
  proc.on('exit', () => { captureProc = null; });
569
  return true;
570
  }
571
+
572
+ // Verification failed β€” kill and try next tool
573
+ proc.kill('SIGTERM');
574
  } catch {
575
  continue;
576
  }
src/friend/__tests__/e2e-http-flow.test.ts ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * End-to-end HTTP flow test β€” simulates what the Tauri frontend does
3
+ * with the browser VAD + server STT architecture:
4
+ * 1. POST /voice/stt-segment β†’ send audio buffer for transcription
5
+ * 2. Server transcribes and enqueues text to CLI
6
+ * 3. POST /voice/start + /voice/stop β†’ push-to-talk mode
7
+ *
8
+ * Uses mocked STT so it runs in any environment.
9
+ */
10
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
11
+ import { friendService } from '../FriendService.js';
12
+ import { setPrefs } from '../prefs.js';
13
+ import { handleFriendApi, setFriendServerInfo } from '../../server/api/friend.js';
14
+ import { getCommandQueue, resetCommandQueue } from '../../utils/messageQueueManager.js';
15
+
16
+ setFriendServerInfo('127.0.0.1', 3456);
17
+
18
+ // ════════════════════════════════════════════════════════════════
19
+ // Helpers
20
+ // ════════════════════════════════════════════════════════════════
21
+
22
+ function makeRequest(method: string, path: string, body?: unknown): Request {
23
+ const url = new URL(path, 'http://127.0.0.1:3456');
24
+ const init: RequestInit = { method };
25
+ if (body) {
26
+ init.headers = { 'Content-Type': 'application/json' };
27
+ init.body = JSON.stringify(body);
28
+ }
29
+ return new Request(url.toString(), init);
30
+ }
31
+
32
+ function apiUrl(path: string): URL {
33
+ return new URL(path, 'http://127.0.0.1:3456');
34
+ }
35
+
36
+ async function jsonResponse(res: Response): Promise<Record<string, unknown>> {
37
+ return res.json() as Promise<Record<string, unknown>>;
38
+ }
39
+
40
+ // ════════════════════════════════════════════════════════════════
41
+ // Mocks
42
+ // ════════════════════════════════════════════════════════════════
43
+
44
+ function createMockSttFactory() {
45
+ let counter = 0;
46
+ return async () => {
47
+ const myId = ++counter;
48
+ return {
49
+ send: () => {},
50
+ finalize: async () => {
51
+ await new Promise(r => setTimeout(r, 50));
52
+ (friendService as any).captureTranscripts.push(`[mock stt result #${myId}]`);
53
+ },
54
+ close: () => {},
55
+ };
56
+ };
57
+ }
58
+
59
+ // ════════════════════════════════════════════════════════════════
60
+ // Tests
61
+ // ════════════════════════════════════════════════════════════════
62
+
63
+ describe('E2E HTTP flow (browser VAD + server STT)', () => {
64
+ let origStartStt: any;
65
+
66
+ beforeEach(() => {
67
+ resetCommandQueue();
68
+ setPrefs({ sttProvider: 'groq' });
69
+ origStartStt = (friendService as any).startSttConnection;
70
+ (friendService as any).startSttConnection = createMockSttFactory();
71
+ });
72
+
73
+ afterEach(async () => {
74
+ try { await friendService.stopVoiceCapture(); } catch {}
75
+ (friendService as any).startSttConnection = origStartStt;
76
+ resetCommandQueue();
77
+ });
78
+
79
+ test(
80
+ 'POST /voice/stt-segment β†’ transcribes and enqueues via sendText()',
81
+ async () => {
82
+ // Create a WAV audio buffer (44-byte header + 16000 samples of silence)
83
+ const wavHeader = Buffer.alloc(44);
84
+ wavHeader.write('RIFF', 0);
85
+ wavHeader.writeUInt32LE(36 + 16000 * 2, 4);
86
+ wavHeader.write('WAVE', 8);
87
+ wavHeader.write('fmt ', 12);
88
+ wavHeader.writeUInt32LE(16, 16);
89
+ wavHeader.writeUInt16LE(1, 20); // PCM
90
+ wavHeader.writeUInt16LE(1, 22); // mono
91
+ wavHeader.writeUInt32LE(16000, 24); // sample rate
92
+ wavHeader.writeUInt32LE(32000, 28); // byte rate
93
+ wavHeader.writeUInt16LE(2, 32); // block align
94
+ wavHeader.writeUInt16LE(16, 34); // bits per sample
95
+ wavHeader.write('data', 36);
96
+ wavHeader.writeUInt32LE(16000 * 2, 40);
97
+
98
+ const audioData = Buffer.alloc(16000 * 2, 128); // silence
99
+ const fullAudio = Buffer.concat([wavHeader, audioData]);
100
+
101
+ // Send as application/octet-stream
102
+ const url = new URL('http://127.0.0.1:3456/plugins/friend/voice/stt-segment');
103
+ const req = new Request(url.toString(), {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/octet-stream' },
106
+ body: fullAudio,
107
+ });
108
+
109
+ const res = await handleFriendApi(req, url);
110
+ const data = await jsonResponse(res);
111
+
112
+ expect(res.status).toBe(200);
113
+ expect(data.ok).toBe(true);
114
+ expect(typeof data.text).toBe('string');
115
+ expect((data.text as string).length).toBeGreaterThan(0);
116
+
117
+ // Verify text was enqueued to CLI via sendText()
118
+ await new Promise(r => setTimeout(r, 10)); // let async sendText() enqueue
119
+ const queue = getCommandQueue();
120
+ expect(queue.length).toBeGreaterThan(0);
121
+ expect(queue[0].value).toContain('[mock stt result');
122
+ expect(queue[0].origin?.server).toBe('friend');
123
+ },
124
+ 10000,
125
+ );
126
+
127
+ test(
128
+ 'POST /voice/start β†’ /voice/stop β†’ push-to-talk cycle',
129
+ async () => {
130
+ const startRes = await handleFriendApi(
131
+ makeRequest('POST', '/plugins/friend/voice/start'),
132
+ apiUrl('/plugins/friend/voice/start'),
133
+ );
134
+ expect(startRes.status).toBe(200);
135
+
136
+ // Let it "capture" briefly
137
+ await new Promise(r => setTimeout(r, 200));
138
+
139
+ const stopRes = await handleFriendApi(
140
+ makeRequest('POST', '/plugins/friend/voice/stop'),
141
+ apiUrl('/plugins/friend/voice/stop'),
142
+ );
143
+ const stopData = await jsonResponse(stopRes);
144
+ expect(stopRes.status).toBe(200);
145
+ expect(typeof stopData.text).toBe('string');
146
+ },
147
+ 15000,
148
+ );
149
+ });
src/friend/__tests__/voice-cycle.test.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Voice capture tests for FriendService (push-to-talk mode).
3
+ *
4
+ * Tests:
5
+ * 1. startVoiceCapture β†’ stopVoiceCapture returns transcript
6
+ * 2. TranscribeAudioSegment sends text to CLI queue
7
+ */
8
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
9
+ import { friendService } from '../FriendService.js';
10
+ import { setPrefs } from '../prefs.js';
11
+ import { getCommandQueue, resetCommandQueue } from '../../utils/messageQueueManager.js';
12
+
13
+ function createMockSttConnectionFactory() {
14
+ let counter = 0;
15
+ return async () => {
16
+ const myId = ++counter;
17
+ return {
18
+ send: () => {},
19
+ finalize: async () => {
20
+ await new Promise(r => setTimeout(r, 50));
21
+ const fs = friendService as any;
22
+ fs.captureTranscripts.push(`[mock transcript #${myId}]`);
23
+ },
24
+ close: () => {},
25
+ };
26
+ };
27
+ }
28
+
29
+ describe('Voice capture (push-to-talk)', () => {
30
+ let origStartStt: any;
31
+ let origAudioCapture: any;
32
+
33
+ beforeEach(() => {
34
+ resetCommandQueue();
35
+ setPrefs({ sttProvider: 'groq' });
36
+ origStartStt = (friendService as any).startSttConnection;
37
+ origAudioCapture = (friendService as any).audioCapture;
38
+ (friendService as any).startSttConnection = createMockSttConnectionFactory();
39
+ // Mock audio capture to avoid spawning arecord/parecord
40
+ (friendService as any).audioCapture = {
41
+ startRecording: async () => true,
42
+ stopRecording: async () => {},
43
+ isRecording: () => false,
44
+ };
45
+ });
46
+
47
+ afterEach(async () => {
48
+ try { await friendService.stopVoiceCapture(); } catch {}
49
+ (friendService as any).startSttConnection = origStartStt;
50
+ (friendService as any).audioCapture = origAudioCapture;
51
+ resetCommandQueue();
52
+ });
53
+
54
+ test(
55
+ 'transcribeAudioSegment enqueues text via sendText()',
56
+ async () => {
57
+ const audioBuf = Buffer.alloc(16000); // 1s of silence @ 16kHz 16-bit
58
+ const transcript = await friendService.transcribeAudioSegment(audioBuf);
59
+
60
+ expect(transcript).toContain('[mock transcript');
61
+ await new Promise(r => setTimeout(r, 10)); // let async sendText() enqueue
62
+ const queue = getCommandQueue();
63
+ expect(queue.length).toBeGreaterThan(0);
64
+ expect(queue[0].value).toContain('[mock transcript');
65
+ expect(queue[0].origin?.server).toBe('friend');
66
+ },
67
+ 10000,
68
+ );
69
+
70
+ test(
71
+ 'startVoiceCapture + stopVoiceCapture returns transcript',
72
+ async () => {
73
+ await friendService.startVoiceCapture();
74
+
75
+ await new Promise(r => setTimeout(r, 500));
76
+
77
+ const transcript = await friendService.stopVoiceCapture();
78
+ expect(typeof transcript).toBe('string');
79
+ },
80
+ 15000,
81
+ );
82
+ });
src/server/api/friend.ts CHANGED
@@ -150,7 +150,33 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
150
  return jsonResponse({ ok: true });
151
  }
152
 
153
- // ── Voice capture (POST /plugins/friend/voice/start, /stop, /status) ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  if (pathname === '/plugins/friend/voice/start' && method === 'POST') {
155
  try {
156
  await friendService.startVoiceCapture();
@@ -160,6 +186,15 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
160
  }
161
  }
162
 
 
 
 
 
 
 
 
 
 
163
  if (pathname === '/plugins/friend/voice/stop' && method === 'POST') {
164
  try {
165
  const text = await friendService.stopVoiceCapture();
 
150
  return jsonResponse({ ok: true });
151
  }
152
 
153
+ // ── STT segment from browser VAD (POST /plugins/friend/voice/stt-segment) ──
154
+ if (pathname === '/plugins/friend/voice/stt-segment' && method === 'POST') {
155
+ try {
156
+ const contentType = req.headers.get('content-type') || '';
157
+ let audioBuffer: Buffer;
158
+
159
+ if (contentType.includes('multipart/form-data') || contentType.includes('application/octet-stream')) {
160
+ const blob = await req.blob();
161
+ audioBuffer = Buffer.from(await blob.arrayBuffer());
162
+ } else {
163
+ // Accept raw PCM or WAV as binary body
164
+ audioBuffer = Buffer.from(await req.arrayBuffer());
165
+ }
166
+
167
+ if (audioBuffer.length < 100) {
168
+ return jsonResponse({ error: 'audio too short' }, 400);
169
+ }
170
+
171
+ const transcript = await friendService.transcribeAudioSegment(audioBuffer);
172
+ return jsonResponse({ ok: true, text: transcript });
173
+ } catch (err) {
174
+ console.error('[Friend] STT segment error:', err);
175
+ return jsonResponse({ error: String(err) }, 500);
176
+ }
177
+ }
178
+
179
+ // ── Voice capture (POST /plugins/friend/voice/start, /start-vad, /stop, /status) ──
180
  if (pathname === '/plugins/friend/voice/start' && method === 'POST') {
181
  try {
182
  await friendService.startVoiceCapture();
 
186
  }
187
  }
188
 
189
+ if (pathname === '/plugins/friend/voice/start-vad' && method === 'POST') {
190
+ try {
191
+ await friendService.startVadVoiceCapture();
192
+ return jsonResponse({ ok: true });
193
+ } catch (err) {
194
+ return jsonResponse({ error: String(err) }, 500);
195
+ }
196
+ }
197
+
198
  if (pathname === '/plugins/friend/voice/stop' && method === 'POST') {
199
  try {
200
  const text = await friendService.stopVoiceCapture();
src/server/staticFriend.ts CHANGED
@@ -25,6 +25,8 @@ const MIME_TYPES: Record<string, string> = {
25
  '.ico': 'image/x-icon',
26
  '.woff': 'font/woff',
27
  '.woff2': 'font/woff2',
 
 
28
  }
29
 
30
  export async function handleFriendStaticRequest(req: Request, url: URL): Promise<Response | null> {
 
25
  '.ico': 'image/x-icon',
26
  '.woff': 'font/woff',
27
  '.woff2': 'font/woff2',
28
+ '.onnx': 'application/octet-stream',
29
+ '.wasm': 'application/wasm',
30
  }
31
 
32
  export async function handleFriendStaticRequest(req: Request, url: URL): Promise<Response | null> {