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])
}