| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import Groq from 'groq-sdk' |
| import { readFileSync, existsSync } from 'node:fs' |
| import { homedir } from 'node:os' |
| import { join } from 'node:path' |
|
|
| |
|
|
| export type VoiceStreamCallbacks = { |
| onTranscript: (text: string, isFinal: boolean) => void |
| onError: (error: string, opts?: { fatal?: boolean }) => void |
| onClose: () => void |
| onReady: (connection: VoiceStreamConnection) => void |
| } |
|
|
| |
| |
| 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 = { |
| |
| apiKey?: string |
| model?: string |
| language?: string |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function resolveGroqApiKey(explicitKey?: string): string | undefined { |
| if (explicitKey) return explicitKey |
| if (process.env.GROQ_API_KEY) return process.env.GROQ_API_KEY |
|
|
| |
| 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 { } |
|
|
| return undefined |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| 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 }) |
|
|
| |
| const wavBuf = pcmToWav(audioBuf, 16000) |
| const wavFile = new File([wavBuf], 'audio.wav', { type: 'audio/wav' }) |
|
|
| |
| 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 |
| } catch (err: any) { |
| lastError = err |
| |
| if (err.status === 429 || err.status >= 500) { |
| console.warn(`[GroqSTT] model ${model} failed (${err.status}), trying next...`) |
| continue |
| } |
| |
| 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) |
| }) |
| } |
|
|
| |
| |
| |
| export function isGroqAvailable(explicitKey?: string): boolean { |
| return !!resolveGroqApiKey(explicitKey) |
| } |
|
|
| |
| |
| |
| 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]) |
| } |
|
|