chenbhao commited on
Commit
da5882b
·
1 Parent(s): 3807a75

feat: grop free stt

Browse files
bun.lock CHANGED
@@ -68,6 +68,7 @@
68
  "fuse.js": "^7.1.0",
69
  "get-east-asian-width": "^1.5.0",
70
  "google-auth-library": "^10.6.2",
 
71
  "highlight.js": "^11.11.1",
72
  "https-proxy-agent": "^8.0.0",
73
  "ignore": "^7.0.5",
@@ -730,6 +731,8 @@
730
 
731
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
732
 
 
 
733
  "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
734
 
735
  "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
 
68
  "fuse.js": "^7.1.0",
69
  "get-east-asian-width": "^1.5.0",
70
  "google-auth-library": "^10.6.2",
71
+ "groq-sdk": "^1.2.1",
72
  "highlight.js": "^11.11.1",
73
  "https-proxy-agent": "^8.0.0",
74
  "ignore": "^7.0.5",
 
731
 
732
  "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
733
 
734
+ "groq-sdk": ["groq-sdk@1.2.1", "", { "bin": { "groq-sdk": "bin/cli" } }, "sha512-dsDSWJRJf+n2dPiCv7zU3IsJbrh7jfSPqi6vc1q0TTK1oUF6bn+wv4P2VFdynkHpuJ0TTJ57vlpT87judPgVPA=="],
735
+
736
  "gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
737
 
738
  "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="],
package.json CHANGED
@@ -86,6 +86,7 @@
86
  "fuse.js": "^7.1.0",
87
  "get-east-asian-width": "^1.5.0",
88
  "google-auth-library": "^10.6.2",
 
89
  "highlight.js": "^11.11.1",
90
  "https-proxy-agent": "^8.0.0",
91
  "ignore": "^7.0.5",
 
86
  "fuse.js": "^7.1.0",
87
  "get-east-asian-width": "^1.5.0",
88
  "google-auth-library": "^10.6.2",
89
+ "groq-sdk": "^1.2.1",
90
  "highlight.js": "^11.11.1",
91
  "https-proxy-agent": "^8.0.0",
92
  "ignore": "^7.0.5",
src/components/friend/frontend/components/SettingsPanel.tsx CHANGED
@@ -12,7 +12,7 @@ interface DanceItem {
12
  builtin?: boolean
13
  }
14
 
15
- type SttProvider = 'browser' | 'anthropic' | 'local' | 'doubao'
16
 
17
  interface SettingsPanelProps {
18
  visible: boolean
@@ -495,7 +495,7 @@ export function SettingsPanel({
495
  <div style={{ marginTop: 8 }}>
496
  <div style={labelStyle}>{t('语音识别 (STT)', 'Speech Recognition (STT)')}</div>
497
  <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
498
- {(['browser', 'anthropic', 'local', 'doubao'] as const).map((p) => (
499
  <button
500
  key={p}
501
  onClick={() => onSttProviderChange?.(p)}
@@ -509,7 +509,7 @@ export function SettingsPanel({
509
  borderColor: p === sttProvider ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
510
  }}
511
  >
512
- {{ browser: t('浏览器', 'Browser'), anthropic: 'Anthropic', local: 'Whisper', doubao: 'Doubao' }[p]}
513
  </button>
514
  ))}
515
  </div>
 
12
  builtin?: boolean
13
  }
14
 
15
+ type SttProvider = 'browser' | 'groq' | 'anthropic' | 'local' | 'doubao'
16
 
17
  interface SettingsPanelProps {
18
  visible: boolean
 
495
  <div style={{ marginTop: 8 }}>
496
  <div style={labelStyle}>{t('语音识别 (STT)', 'Speech Recognition (STT)')}</div>
497
  <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
498
+ {(['browser', 'groq', 'anthropic', 'local', 'doubao'] as const).map((p) => (
499
  <button
500
  key={p}
501
  onClick={() => onSttProviderChange?.(p)}
 
509
  borderColor: p === sttProvider ? 'rgba(100, 160, 255, 0.6)' : 'rgba(255, 255, 255, 0.15)',
510
  }}
511
  >
512
+ {{ browser: t('浏览器', 'Browser'), groq: 'Groq', anthropic: 'Anthropic', local: 'Whisper', doubao: 'Doubao' }[p]}
513
  </button>
514
  ))}
515
  </div>
src/components/friend/frontend/hooks/useServerStt.ts CHANGED
@@ -14,7 +14,7 @@ import { useRef, useCallback, useState } from 'react'
14
 
15
  const FRIEND_API_BASE = 'http://127.0.0.1:3456/plugins/friend'
16
 
17
- export type SttProvider = 'browser' | 'anthropic' | 'local' | 'doubao'
18
 
19
  export function useServerStt() {
20
  const [connected, setConnected] = useState(false)
 
14
 
15
  const FRIEND_API_BASE = 'http://127.0.0.1:3456/plugins/friend'
16
 
17
+ export type SttProvider = 'browser' | 'groq' | 'anthropic' | 'local' | 'doubao'
18
 
19
  export function useServerStt() {
20
  const [connected, setConnected] = useState(false)
src/friend/FriendService.ts CHANGED
@@ -235,12 +235,22 @@ class FriendService {
235
 
236
  /**
237
  * Auto-detect the first available STT provider.
238
- * Tries: local Whisper → Anthropic Voice Stream → Doubao ASR
239
  */
240
  private async detectAvailableSttProvider(): Promise<string> {
241
  console.log('[FriendService] detectAvailableSttProvider: checking available providers...');
242
 
243
- // Check local Whisper first (no external API keys needed)
 
 
 
 
 
 
 
 
 
 
244
  try {
245
  const { checkLocalWhisperAvailable } = await import(
246
  '../services/voice/whisperSTT.js'
@@ -438,6 +448,13 @@ class FriendService {
438
  return await connectDoubaoStream(callbacks, { language: language || 'zh' });
439
  }
440
 
 
 
 
 
 
 
 
441
  default:
442
  throw new Error(`Unknown STT provider: ${provider}`);
443
  }
 
235
 
236
  /**
237
  * Auto-detect the first available STT provider.
238
+ * Tries: Groq Whisper (cloud, API key) → local Whisper → Anthropic Voice Stream → Doubao ASR
239
  */
240
  private async detectAvailableSttProvider(): Promise<string> {
241
  console.log('[FriendService] detectAvailableSttProvider: checking available providers...');
242
 
243
+ // Check Groq API key first (fastest — no Python, just a REST call)
244
+ // Keys are resolved from: prefs → process.env → ~/.claude/settings.json
245
+ try {
246
+ const { isGroqAvailable } = await import('../services/voice/groqSTT.js');
247
+ if (isGroqAvailable()) {
248
+ console.log('[FriendService] detectAvailableSttProvider: Groq API key found');
249
+ return 'groq';
250
+ }
251
+ } catch { /* ignore */ }
252
+
253
+ // Check local Whisper (no external API keys needed)
254
  try {
255
  const { checkLocalWhisperAvailable } = await import(
256
  '../services/voice/whisperSTT.js'
 
448
  return await connectDoubaoStream(callbacks, { language: language || 'zh' });
449
  }
450
 
451
+ case 'groq': {
452
+ const { connectGroqStream } = await import(
453
+ '../services/voice/groqSTT.js'
454
+ );
455
+ return await connectGroqStream(callbacks, { language });
456
+ }
457
+
458
  default:
459
  throw new Error(`Unknown STT provider: ${provider}`);
460
  }
src/friend/prefs.ts CHANGED
@@ -22,10 +22,12 @@ export interface FriendPrefs {
22
  language?: 'zh' | 'en';
23
  currentDance?: string;
24
  hideMood?: boolean;
25
- /** STT provider: browser | anthropic | local | doubao */
26
- sttProvider?: 'browser' | 'anthropic' | 'local' | 'doubao';
27
  /** STT language override (e.g. 'en', 'zh', 'ja') */
28
  sttLanguage?: string;
 
 
29
  }
30
 
31
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
 
22
  language?: 'zh' | 'en';
23
  currentDance?: string;
24
  hideMood?: boolean;
25
+ /** STT provider: browser | anthropic | local | doubao | groq */
26
+ sttProvider?: 'browser' | 'anthropic' | 'local' | 'doubao' | 'groq';
27
  /** STT language override (e.g. 'en', 'zh', 'ja') */
28
  sttLanguage?: string;
29
+ /** GROQ_API_KEY for Groq Whisper STT (cloud, fast, no Python needed) */
30
+ groqApiKey?: string;
31
  }
32
 
33
  const homeDir = process.env.HOME || process.env.USERPROFILE || '';
src/server/api/friend.ts CHANGED
@@ -309,6 +309,7 @@ export async function handleFriendApi(req: Request, url: URL): Promise<Response>
309
  if (body.hideMood !== undefined) patch.hideMood = body.hideMood;
310
  if (body.sttProvider !== undefined) patch.sttProvider = body.sttProvider;
311
  if (body.sttLanguage !== undefined) patch.sttLanguage = body.sttLanguage;
 
312
  setPrefs(updatePrefs(patch));
313
  return jsonResponse({ ok: true });
314
  }
 
309
  if (body.hideMood !== undefined) patch.hideMood = body.hideMood;
310
  if (body.sttProvider !== undefined) patch.sttProvider = body.sttProvider;
311
  if (body.sttLanguage !== undefined) patch.sttLanguage = body.sttLanguage;
312
+ if (body.groqApiKey !== undefined) patch.groqApiKey = body.groqApiKey;
313
  setPrefs(updatePrefs(patch));
314
  return jsonResponse({ ok: true });
315
  }
src/services/voice/groqSTT.ts ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Groq Whisper STT — cloud-based speech-to-text via Groq LPU API.
3
+ *
4
+ * Uses the official groq-sdk npm package. Tries whisper-large-v3 first,
5
+ * falls back to whisper-large-v3-turbo on rate-limit (429) errors.
6
+ *
7
+ * No Python subprocess needed. Pure TypeScript.
8
+ */
9
+
10
+ import Groq from 'groq-sdk'
11
+ import { readFileSync, existsSync } from 'node:fs'
12
+ import { homedir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import type { VoiceStreamCallbacks, VoiceStreamConnection, FinalizeSource } from '../voiceStreamSTT.js'
15
+
16
+ const MODELS = ['whisper-large-v3', 'whisper-large-v3-turbo'] as const
17
+
18
+ export type GroqSttOptions = {
19
+ /** Explicit API key (highest priority) */
20
+ apiKey?: string
21
+ model?: string
22
+ language?: string
23
+ }
24
+
25
+ /**
26
+ * Resolve the Groq API key from multiple sources (priority order):
27
+ * 1. Explicitly passed `apiKey` option
28
+ * 2. `getPrefs().groqApiKey` (from friend.json)
29
+ * 3. `process.env.GROQ_API_KEY`
30
+ * 4. `~/.claude/settings.json` → env.groqApiKey
31
+ */
32
+ export function resolveGroqApiKey(explicitKey?: string): string | undefined {
33
+ if (explicitKey) return explicitKey
34
+ if (process.env.GROQ_API_KEY) return process.env.GROQ_API_KEY
35
+
36
+ // Fallback: read from ~/.claude/settings.json
37
+ try {
38
+ const settingsPath = join(homedir(), '.claude', 'settings.json')
39
+ if (existsSync(settingsPath)) {
40
+ const raw = readFileSync(settingsPath, 'utf-8')
41
+ const settings = JSON.parse(raw)
42
+ if (settings.env?.groqApiKey) {
43
+ return settings.env.groqApiKey
44
+ }
45
+ if (settings.env?.GROQ_API_KEY) {
46
+ return settings.env.GROQ_API_KEY
47
+ }
48
+ }
49
+ } catch { /* ignore */ }
50
+
51
+ return undefined
52
+ }
53
+
54
+ /**
55
+ * Connect to Groq Whisper STT as a stream-like connection.
56
+ *
57
+ * `send(chunk)` buffers audio PCM data. `finalize()` sends the full buffer
58
+ * to the Groq API, trying whisper-large-v3 first, falling back to
59
+ * whisper-large-v3-turbo on rate-limit.
60
+ */
61
+ export function connectGroqStream(
62
+ callbacks: VoiceStreamCallbacks,
63
+ options: GroqSttOptions,
64
+ ): Promise<VoiceStreamConnection | null> {
65
+ return new Promise(resolve => {
66
+ const chunks: Buffer[] = []
67
+ let finalized = false
68
+
69
+ const connection: VoiceStreamConnection = {
70
+ send(chunk: Buffer) {
71
+ if (finalized) return
72
+ chunks.push(Buffer.from(chunk))
73
+ },
74
+
75
+ async finalize(): Promise<FinalizeSource> {
76
+ if (finalized) return 'ws_already_closed'
77
+ finalized = true
78
+
79
+ if (chunks.length === 0) {
80
+ callbacks.onClose()
81
+ return 'no_data_timeout'
82
+ }
83
+
84
+ const audioBuf = Buffer.concat(chunks)
85
+
86
+ try {
87
+ // Resolve API key
88
+ const apiKey = resolveGroqApiKey(options.apiKey)
89
+ if (!apiKey) {
90
+ throw new Error(
91
+ 'Groq API key not found. Set GROQ_API_KEY env var or add "groqApiKey" to ~/.claude/settings.json env block.',
92
+ )
93
+ }
94
+
95
+ const client = new Groq({ apiKey })
96
+
97
+ // Convert raw PCM to WAV buffer
98
+ const wavBuf = pcmToWav(audioBuf, 16000)
99
+ const wavFile = new File([wavBuf], 'audio.wav', { type: 'audio/wav' })
100
+
101
+ // Try whisper-large-v3 first, fallback to -turbo on 429
102
+ const preferredModel = options.model || MODELS[0]
103
+ const modelsToTry = preferredModel === MODELS[1]
104
+ ? [MODELS[1]]
105
+ : [preferredModel, MODELS[1]]
106
+
107
+ let lastError: Error | null = null
108
+
109
+ for (const model of modelsToTry) {
110
+ try {
111
+ const transcription = await client.audio.transcriptions.create({
112
+ file: wavFile,
113
+ model,
114
+ temperature: 0,
115
+ response_format: 'verbose_json',
116
+ ...(options.language ? { language: options.language } : {}),
117
+ })
118
+
119
+ if (transcription.text) {
120
+ callbacks.onTranscript(transcription.text, true)
121
+ } else {
122
+ callbacks.onTranscript('', true)
123
+ }
124
+
125
+ lastError = null
126
+ break // success
127
+ } catch (err: any) {
128
+ lastError = err
129
+ // Only retry on rate-limit (429) or server errors
130
+ if (err.status === 429 || err.status >= 500) {
131
+ console.warn(`[GroqSTT] model ${model} failed (${err.status}), trying next...`)
132
+ continue
133
+ }
134
+ // Other errors are fatal — don't retry
135
+ throw err
136
+ }
137
+ }
138
+
139
+ if (lastError) throw lastError
140
+ } catch (err) {
141
+ callbacks.onError(
142
+ `Groq STT error: ${err instanceof Error ? err.message : String(err)}`,
143
+ { fatal: true },
144
+ )
145
+ } finally {
146
+ callbacks.onClose()
147
+ }
148
+
149
+ return 'post_closestream_endpoint'
150
+ },
151
+
152
+ close() {
153
+ finalized = true
154
+ callbacks.onClose()
155
+ },
156
+
157
+ isConnected() {
158
+ return true
159
+ },
160
+ }
161
+
162
+ callbacks.onReady(connection)
163
+ resolve(connection)
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Check if a Groq API key is available somewhere.
169
+ */
170
+ export function isGroqAvailable(explicitKey?: string): boolean {
171
+ return !!resolveGroqApiKey(explicitKey)
172
+ }
173
+
174
+ /**
175
+ * Convert 16-bit mono PCM data (16000 Hz) to a WAV buffer.
176
+ */
177
+ function pcmToWav(pcmData: Buffer, sampleRate: number): Buffer {
178
+ const numChannels = 1
179
+ const bitsPerSample = 16
180
+ const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
181
+ const blockAlign = numChannels * (bitsPerSample / 8)
182
+ const dataSize = pcmData.length
183
+
184
+ const header = Buffer.alloc(44)
185
+ header.write('RIFF', 0)
186
+ header.writeUInt32LE(36 + dataSize, 4)
187
+ header.write('WAVE', 8)
188
+ header.write('fmt ', 12)
189
+ header.writeUInt32LE(16, 16)
190
+ header.writeUInt16LE(1, 20)
191
+ header.writeUInt16LE(numChannels, 22)
192
+ header.writeUInt32LE(sampleRate, 24)
193
+ header.writeUInt32LE(byteRate, 28)
194
+ header.writeUInt16LE(blockAlign, 32)
195
+ header.writeUInt16LE(bitsPerSample, 34)
196
+ header.write('data', 36)
197
+ header.writeUInt32LE(dataSize, 40)
198
+
199
+ return Buffer.concat([header, pcmData])
200
+ }