-
Notifications
You must be signed in to change notification settings - Fork 36
feat: add ElevenLabs Scribe support, fix noise-injected transcriptions, improve retry UX #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,6 +42,20 @@ The AI has access to file reading, searching, web fetching, and code execution t | |
| | `MINIMAX_API_KEY` | MiniMax API key | — | | ||
| | `NVIDIA_API_KEY` | NVIDIA NIM API key | — | | ||
|
|
||
| ### Voice Input | ||
|
|
||
| Voice capture requires `ffmpeg` and an STT provider API key. | ||
|
|
||
| | Env Var | Description | Default | | ||
| |---|---|---| | ||
| | `STT_PROVIDER` | STT provider (`elevenlabs` or `groq`) | `elevenlabs` | | ||
| | `ELEVENLABS_API_KEY` | ElevenLabs API key (required for ElevenLabs STT) | — | | ||
| | `ELEVENLABS_MODEL` | ElevenLabs model ID | `scribe_v1` | | ||
| | `GROQ_API_KEY` | Groq API key (required when `STT_PROVIDER=groq`) | — | | ||
| | `STT_LANGUAGE` | Transcription language | `en` | | ||
|
|
||
| Press **Ctrl+Shift+V** during a chat session to start voice capture. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Match the documented shortcut to the CLI binding.
🤖 Prompt for AI Agents |
||
|
|
||
| ## License | ||
|
|
||
| MIT | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -59,7 +59,7 @@ import { saveCliConfig } from "src/lib/cli-config" | |||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||
| voiceCaptureFlow, | ||||||||||||||||||||||||||||||||||||||||
| canVoiceCapture, | ||||||||||||||||||||||||||||||||||||||||
| abortCapture, | ||||||||||||||||||||||||||||||||||||||||
| stopCapture, | ||||||||||||||||||||||||||||||||||||||||
| } from "src/voice/speech.ts" | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -809,7 +809,7 @@ function stdinKeypress(_str: string, key: any) { | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Enter/Escape during voice capture stops recording, doesn't submit | ||||||||||||||||||||||||||||||||||||||||
| if (voiceCaptureActive && (key.name === "return" || key.name === "enter" || key.name === "escape")) { | ||||||||||||||||||||||||||||||||||||||||
| abortCapture() | ||||||||||||||||||||||||||||||||||||||||
| stopCapture() | ||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1008,26 +1008,26 @@ function stdinKeypress(_str: string, key: any) { | |||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // Voice capture — F2 is the primary trigger (most reliable across terminals). | ||||||||||||||||||||||||||||||||||||||||
| // Ctrl+Shift+V is also accepted but some terminals intercept it as a paste | ||||||||||||||||||||||||||||||||||||||||
| // shortcut, and Node.js readline treats Ctrl+V as a "literal next" key | ||||||||||||||||||||||||||||||||||||||||
| // which prevents the Shift+V combination from being detected reliably. | ||||||||||||||||||||||||||||||||||||||||
| // Voice capture — Ctrl+V is the primary trigger (bottom-left corner of most | ||||||||||||||||||||||||||||||||||||||||
| // keyboards, reliably detected in any terminal). F2 is also accepted as a | ||||||||||||||||||||||||||||||||||||||||
| // fallback. Note: Shift is intentionally omitted — terminals fold Shift into | ||||||||||||||||||||||||||||||||||||||||
| // Ctrl+letter combos, so Ctrl+Shift+V sends the same byte as Ctrl+V. | ||||||||||||||||||||||||||||||||||||||||
| const isVoiceKey = | ||||||||||||||||||||||||||||||||||||||||
| key.name === "F2" || | ||||||||||||||||||||||||||||||||||||||||
| (key.ctrl && key.shift && (key.name === "v" || key.name === "V")) | ||||||||||||||||||||||||||||||||||||||||
| (key.ctrl && (key.name === "v" || key.name === "V")) || | ||||||||||||||||||||||||||||||||||||||||
| key.name === "f2" | ||||||||||||||||||||||||||||||||||||||||
| if (isVoiceKey) { | ||||||||||||||||||||||||||||||||||||||||
| if (!voiceCaptureActive) { | ||||||||||||||||||||||||||||||||||||||||
| voiceCaptureActive = true | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("🎤 Recording... (Enter to stop)") | ||||||||||||||||||||||||||||||||||||||||
| startVoiceCapture().finally(() => { | ||||||||||||||||||||||||||||||||||||||||
| voiceCaptureActive = false | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("") | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| stopCapture() | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (_str && _str.length === 1 && !key.ctrl && !key.meta) { | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("") | ||||||||||||||||||||||||||||||||||||||||
| stdinInput = stdinInput.slice(0, stdinCursor) + _str + stdinInput.slice(stdinCursor) | ||||||||||||||||||||||||||||||||||||||||
| stdinCursor++ | ||||||||||||||||||||||||||||||||||||||||
| slashSelected = -1 | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1040,23 +1040,28 @@ function stdinKeypress(_str: string, key: any) { | |||||||||||||||||||||||||||||||||||||||
| async function startVoiceCapture() { | ||||||||||||||||||||||||||||||||||||||||
| const check = canVoiceCapture() | ||||||||||||||||||||||||||||||||||||||||
| if (!check.ok) { | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("⛭ Voice unavailable: " + (check.reason ?? "unknown")) | ||||||||||||||||||||||||||||||||||||||||
| const reason = check.reason ?? "unknown" | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("⛭ Voice unavailable: " + reason) | ||||||||||||||||||||||||||||||||||||||||
| setTimeout(() => activeFooter?.setStatusMessage(""), 4000) | ||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| const prevMode = voiceCaptureActive | ||||||||||||||||||||||||||||||||||||||||
| voiceCaptureActive = true | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("🎤 Recording... (voice key or Enter to stop)") | ||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||
| const text = await voiceCaptureFlow() | ||||||||||||||||||||||||||||||||||||||||
| if (text) { | ||||||||||||||||||||||||||||||||||||||||
| stdinInput = | ||||||||||||||||||||||||||||||||||||||||
| stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor) | ||||||||||||||||||||||||||||||||||||||||
| stdinCursor += text.length + 1 | ||||||||||||||||||||||||||||||||||||||||
| // Don't call renderInput here — the chat loop will call chatInput() next, | ||||||||||||||||||||||||||||||||||||||||
| // which preserves stdinInput (via voiceJustCaptured) and renders it once. | ||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("🎤 No speech detected — press voice key to retry") | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
1052
to
1059
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win "Recording..." footer message never cleared on successful transcription. When 🧹 Proposed fix const text = await voiceCaptureFlow()
if (text) {
+ activeFooter?.setStatusMessage("")
stdinInput =
stdinInput.slice(0, stdinCursor) + text + " " + stdinInput.slice(stdinCursor)
stdinCursor += text.length + 1
} else {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||
| const msg = err instanceof Error ? err.message : "Voice capture failed" | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("⛭ " + msg) | ||||||||||||||||||||||||||||||||||||||||
| activeFooter?.setStatusMessage("⛭ Voice failed: " + (err instanceof Error ? err.message : err)) | ||||||||||||||||||||||||||||||||||||||||
| setTimeout(() => activeFooter?.setStatusMessage(""), 4000) | ||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||
| voiceCaptureActive = prevMode | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1616,7 +1621,7 @@ export async function startChat( | |||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| // ── Quick-start hint ──────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||||||
| console.log( | ||||||||||||||||||||||||||||||||||||||||
| ` ${chalk.hex(theme.greenDim)("hint")} ${chalk.hex(theme.green)("·")} ${chalk.hex(theme.greenGlow)("/model")} to switch ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("F2")} voice ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("/help")} for commands ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Tab")} to cycle mode`, | ||||||||||||||||||||||||||||||||||||||||
| ` ${chalk.hex(theme.greenDim)("hint")} ${chalk.hex(theme.green)("·")} ${chalk.hex(theme.greenGlow)("/model")} to switch ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Ctrl+V")} voice ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("/help")} for commands ${chalk.hex(theme.greenDim)("·")} ${chalk.hex(theme.greenGlow)("Tab")} to cycle mode`, | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| console.log() | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from "bun:test" | ||
|
|
||
| describe("canVoiceCapture", () => { | ||
| const origEnv = { ...process.env } | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { PATH: origEnv.PATH, FFMPEG_PATH: origEnv.FFMPEG_PATH } | ||
| delete process.env.ELEVENLABS_API_KEY | ||
| delete process.env.GROQ_API_KEY | ||
| delete process.env.STT_PROVIDER | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...origEnv } | ||
| }) | ||
|
|
||
| it("returns ok=false when ELEVENLABS_API_KEY is missing and STT_PROVIDER=elevenlabs", async () => { | ||
| process.env.STT_PROVIDER = "elevenlabs" | ||
| const { canVoiceCapture } = await import("../speech.ts") | ||
| const result = canVoiceCapture() | ||
| expect(result.ok).toBe(false) | ||
| expect(result.reason).toContain("ELEVENLABS_API_KEY") | ||
| }) | ||
|
|
||
| it("returns ok=false when GROQ_API_KEY is missing and STT_PROVIDER=groq", async () => { | ||
| process.env.STT_PROVIDER = "groq" | ||
| const { canVoiceCapture } = await import("../speech.ts") | ||
| const result = canVoiceCapture() | ||
| expect(result.ok).toBe(false) | ||
| expect(result.reason).toContain("GROQ_API_KEY") | ||
| }) | ||
|
|
||
| it("uses elevenlabs by default when STT_PROVIDER is unset", async () => { | ||
| const { canVoiceCapture } = await import("../speech.ts") | ||
| const result = canVoiceCapture() | ||
| expect(result.ok).toBe(false) | ||
| expect(result.reason).toContain("ELEVENLABS_API_KEY") | ||
| }) | ||
|
|
||
| it("returns ok=false with ffmpeg reason when ffmpeg is missing", async () => { | ||
| process.env.ELEVENLABS_API_KEY = "sk-test" | ||
| process.env.FFMPEG_PATH = "/nonexistent/ffmpeg" | ||
| const { canVoiceCapture } = await import("../speech.ts") | ||
| const result = canVoiceCapture() | ||
| expect(result.ok).toBe(false) | ||
| expect(result.reason).toContain("ffmpeg") | ||
| }) | ||
| }) | ||
|
|
||
| describe("getSttProvider", () => { | ||
| const origEnv = { ...process.env } | ||
|
|
||
| beforeEach(() => { | ||
| process.env = { PATH: origEnv.PATH } | ||
| delete process.env.STT_PROVIDER | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| process.env = { ...origEnv } | ||
| }) | ||
|
|
||
| it('returns "elevenlabs" when STT_PROVIDER is unset', async () => { | ||
| const mod = await import("../speech.ts") | ||
| expect((mod as any).getSttProvider()).toBe("elevenlabs") | ||
| }) | ||
|
|
||
| it('returns "elevenlabs" when STT_PROVIDER is "elevenlabs"', async () => { | ||
| process.env.STT_PROVIDER = "elevenlabs" | ||
| const mod = await import("../speech.ts") | ||
| expect((mod as any).getSttProvider()).toBe("elevenlabs") | ||
| }) | ||
|
|
||
| it('returns "groq" when STT_PROVIDER is "groq"', async () => { | ||
| process.env.STT_PROVIDER = "groq" | ||
| const mod = await import("../speech.ts") | ||
| expect((mod as any).getSttProvider()).toBe("groq") | ||
| }) | ||
|
|
||
| it('returns "elevenlabs" for unknown STT_PROVIDER values', async () => { | ||
| process.env.STT_PROVIDER = "invalid" | ||
| const mod = await import("../speech.ts") | ||
| expect((mod as any).getSttProvider()).toBe("elevenlabs") | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add the Groq key to the Voice/STT env list.
STT_PROVIDER=groqis supported, andspeech.tsrequiresGROQ_API_KEYon that path, so leaving it out of the env guidance makes the fallback provider look unsupported.🤖 Prompt for AI Agents