feat: VAD but just case choose
Browse files- bun.lock +9 -0
- package.json +1 -0
- src/components/friend/frontend/components/ChatInput.tsx +28 -90
- src/components/friend/frontend/hooks/useServerStt.ts +255 -113
- src/components/friend/frontend/package-lock.json +11 -0
- src/components/friend/frontend/package.json +1 -0
- src/components/friend/frontend/src-tauri/Cargo.lock +1 -0
- src/components/friend/frontend/src-tauri/Cargo.toml +1 -0
- src/components/friend/frontend/src-tauri/src/lib.rs +17 -2
- src/friend/FriendService.ts +93 -8
- src/friend/__tests__/e2e-http-flow.test.ts +149 -0
- src/friend/__tests__/voice-cycle.test.ts +82 -0
- src/server/api/friend.ts +36 -1
- src/server/staticFriend.ts +2 -0
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 |
-
//
|
| 103 |
-
const
|
| 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 (
|
| 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,
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
},
|
| 182 |
(err) => {
|
| 183 |
-
console.error('
|
| 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 |
-
}, [
|
| 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
|
| 206 |
-
const
|
| 207 |
|
| 208 |
-
// Send any remaining transcript
|
| 209 |
-
|
| 210 |
-
|
|
|
|
| 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,
|
| 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
|
| 3 |
* in the Friend Tauri app.
|
| 4 |
*
|
| 5 |
-
* Audio capture
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
*
|
| 9 |
* Two modes:
|
| 10 |
* - push-to-talk: POST /voice/start β capture β POST /voice/stop β get text
|
| 11 |
-
* -
|
| 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
|
| 25 |
-
const
|
| 26 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
// ββ Push-to-talk ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 29 |
|
|
@@ -53,48 +192,16 @@ export function useServerStt() {
|
|
| 53 |
return data.text || ''
|
| 54 |
}, [])
|
| 55 |
|
| 56 |
-
// ββ
|
| 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
|
| 96 |
-
*
|
| 97 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
-
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
[
|
| 153 |
)
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 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 |
-
|
| 169 |
-
|
| 170 |
-
clearTimeout(cycleTimerRef.current)
|
| 171 |
-
cycleTimerRef.current = null
|
| 172 |
}
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
setConnected(false)
|
| 177 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 359 |
*/
|
| 360 |
getCaptureStatus(): { capturing: boolean; interimText?: string } {
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ['
|
| 478 |
try {
|
| 479 |
-
const args = tool === '
|
| 480 |
-
? ['-
|
| 481 |
-
: ['-
|
| 482 |
const proc = spawn(tool, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
| 483 |
-
if (proc.pid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
// ββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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> {
|