File size: 6,771 Bytes
da5882b 346a09e da5882b 346a09e da5882b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | /**
* Groq Whisper STT β cloud-based speech-to-text via Groq LPU API.
*
* Uses the official groq-sdk npm package. Tries whisper-large-v3 first,
* falls back to whisper-large-v3-turbo on rate-limit (429) errors.
*
* No Python subprocess needed. Pure TypeScript.
*/
import Groq from 'groq-sdk'
import { readFileSync, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
// βββ Shared STT connection contracts βββββββββββββββββββββββββββββββββββββββ
export type VoiceStreamCallbacks = {
onTranscript: (text: string, isFinal: boolean) => void
onError: (error: string, opts?: { fatal?: boolean }) => void
onClose: () => void
onReady: (connection: VoiceStreamConnection) => void
}
// How finalize() resolved. `no_data_timeout` means zero server messages
// after CloseStream β the silent-drop signature.
export type FinalizeSource =
| 'post_closestream_endpoint'
| 'no_data_timeout'
| 'safety_timeout'
| 'ws_close'
| 'ws_already_closed'
export type VoiceStreamConnection = {
send: (audioChunk: Buffer) => void
finalize: () => Promise<FinalizeSource>
close: () => void
isConnected: () => boolean
}
const MODELS = ['whisper-large-v3', 'whisper-large-v3-turbo'] as const
export type GroqSttOptions = {
/** Explicit API key (highest priority) */
apiKey?: string
model?: string
language?: string
}
/**
* Resolve the Groq API key from multiple sources (priority order):
* 1. Explicitly passed `apiKey` option
* 2. `getPrefs().groqApiKey` (from companion prefs)
* 3. `process.env.GROQ_API_KEY`
* 4. `~/.claude/settings.json` β env.groqApiKey
*/
export function resolveGroqApiKey(explicitKey?: string): string | undefined {
if (explicitKey) return explicitKey
if (process.env.GROQ_API_KEY) return process.env.GROQ_API_KEY
// Fallback: read from ~/.claude/settings.json
try {
const settingsPath = join(homedir(), '.claude', 'settings.json')
if (existsSync(settingsPath)) {
const raw = readFileSync(settingsPath, 'utf-8')
const settings = JSON.parse(raw)
if (settings.env?.groqApiKey) {
return settings.env.groqApiKey
}
if (settings.env?.GROQ_API_KEY) {
return settings.env.GROQ_API_KEY
}
}
} catch { /* ignore */ }
return undefined
}
/**
* Connect to Groq Whisper STT as a stream-like connection.
*
* `send(chunk)` buffers audio PCM data. `finalize()` sends the full buffer
* to the Groq API, trying whisper-large-v3 first, falling back to
* whisper-large-v3-turbo on rate-limit.
*/
export function connectGroqStream(
callbacks: VoiceStreamCallbacks,
options: GroqSttOptions,
): Promise<VoiceStreamConnection | null> {
return new Promise(resolve => {
const chunks: Buffer[] = []
let finalized = false
const connection: VoiceStreamConnection = {
send(chunk: Buffer) {
if (finalized) return
chunks.push(Buffer.from(chunk))
},
async finalize(): Promise<FinalizeSource> {
if (finalized) return 'ws_already_closed'
finalized = true
if (chunks.length === 0) {
callbacks.onClose()
return 'no_data_timeout'
}
const audioBuf = Buffer.concat(chunks)
try {
// Resolve API key
const apiKey = resolveGroqApiKey(options.apiKey)
if (!apiKey) {
throw new Error(
'Groq API key not found. Set GROQ_API_KEY env var or add "groqApiKey" to ~/.claude/settings.json env block.',
)
}
const client = new Groq({ apiKey })
// Convert raw PCM to WAV buffer
const wavBuf = pcmToWav(audioBuf, 16000)
const wavFile = new File([wavBuf], 'audio.wav', { type: 'audio/wav' })
// Try whisper-large-v3 first, fallback to -turbo on 429
const preferredModel = options.model || MODELS[0]
const modelsToTry = preferredModel === MODELS[1]
? [MODELS[1]]
: [preferredModel, MODELS[1]]
let lastError: Error | null = null
for (const model of modelsToTry) {
try {
const transcription = await client.audio.transcriptions.create({
file: wavFile,
model,
temperature: 0,
response_format: 'verbose_json',
...(options.language ? { language: options.language } : {}),
})
if (transcription.text) {
callbacks.onTranscript(transcription.text, true)
} else {
callbacks.onTranscript('', true)
}
lastError = null
break // success
} catch (err: any) {
lastError = err
// Only retry on rate-limit (429) or server errors
if (err.status === 429 || err.status >= 500) {
console.warn(`[GroqSTT] model ${model} failed (${err.status}), trying next...`)
continue
}
// Other errors are fatal β don't retry
throw err
}
}
if (lastError) throw lastError
} catch (err) {
callbacks.onError(
`Groq STT error: ${err instanceof Error ? err.message : String(err)}`,
{ fatal: true },
)
} finally {
callbacks.onClose()
}
return 'post_closestream_endpoint'
},
close() {
finalized = true
callbacks.onClose()
},
isConnected() {
return true
},
}
callbacks.onReady(connection)
resolve(connection)
})
}
/**
* Check if a Groq API key is available somewhere.
*/
export function isGroqAvailable(explicitKey?: string): boolean {
return !!resolveGroqApiKey(explicitKey)
}
/**
* Convert 16-bit mono PCM data (16000 Hz) to a WAV buffer.
*/
function pcmToWav(pcmData: Buffer, sampleRate: number): Buffer {
const numChannels = 1
const bitsPerSample = 16
const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
const blockAlign = numChannels * (bitsPerSample / 8)
const dataSize = pcmData.length
const header = Buffer.alloc(44)
header.write('RIFF', 0)
header.writeUInt32LE(36 + dataSize, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16)
header.writeUInt16LE(1, 20)
header.writeUInt16LE(numChannels, 22)
header.writeUInt32LE(sampleRate, 24)
header.writeUInt32LE(byteRate, 28)
header.writeUInt16LE(blockAlign, 32)
header.writeUInt16LE(bitsPerSample, 34)
header.write('data', 36)
header.writeUInt32LE(dataSize, 40)
return Buffer.concat([header, pcmData])
}
|