chenbhao commited on
Commit
0524ab0
·
1 Parent(s): 94f44a2

feat: voice add fully-local STT (faster-whisper) + TTS (edge-tts)

Browse files
.gitignore CHANGED
@@ -1,14 +1,26 @@
1
  node_modules/
 
2
  dist/
3
  cli
4
  .codex
5
  VersperClaw
6
 
7
  # Prebuilt native binaries (tracked in claude-code/ reference)
8
- vendor/audio-capture/
9
 
10
  # Desktop (Tauri) build artifacts
11
  desktop/src-tauri/target/
12
  desktop/src-tauri/binaries/
13
  desktop/src-tauri/gen/
14
  desktop/pnpm-lock.yaml
 
 
 
 
 
 
 
 
 
 
 
 
1
  node_modules/
2
+ packages/
3
  dist/
4
  cli
5
  .codex
6
  VersperClaw
7
 
8
  # Prebuilt native binaries (tracked in claude-code/ reference)
9
+ vendor/
10
 
11
  # Desktop (Tauri) build artifacts
12
  desktop/src-tauri/target/
13
  desktop/src-tauri/binaries/
14
  desktop/src-tauri/gen/
15
  desktop/pnpm-lock.yaml
16
+
17
+ # Python cache
18
+ __pycache__/
19
+ *.py[cod]
20
+ *.pyo
21
+ *.pyd
22
+
23
+ # Python env
24
+ .venv/
25
+ venv/
26
+ env/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
desktop/sidecars/omni-adapter.ts CHANGED
@@ -18,13 +18,23 @@
18
 
19
  import { serve } from 'bun'
20
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs'
 
21
  import { randomBytes } from 'crypto'
22
 
23
- // ─── Config ──────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
24
 
25
  const PORT = parseInt(process.env.OMNI_PORT || '9301')
26
  const LLAMA = (process.env.LLAMA_SERVER || 'http://localhost:8025').replace(/\/+$/, '')
27
- const MODEL_DIR = (process.env.OMNI_MODEL_DIR || '/home/yuki/Code/Llm/MiniCPM-o-4_5-gguf').replace(/\/+$/, '')
28
  const TMP = process.env.OMNI_TMP || '/tmp/omni-adapter'
29
  const TTS_OUT = TMP + '/tts-output'
30
 
 
18
 
19
  import { serve } from 'bun'
20
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs'
21
+ import { dirname, join } from 'node:path'
22
  import { randomBytes } from 'crypto'
23
 
24
+ const pkgRoot = dirname(new URL(import.meta.url).pathname)
25
+
26
+ const resolveModelDir = (): string => {
27
+ const envDir = process.env.OMNI_MODEL_DIR
28
+ const candidate = envDir
29
+ ? envDir
30
+ : join(pkgRoot, '..', '..', 'assets', 'omni-model')
31
+ const normalized = candidate.replace(/\/+$/, '')
32
+ return normalized
33
+ }
34
 
35
  const PORT = parseInt(process.env.OMNI_PORT || '9301')
36
  const LLAMA = (process.env.LLAMA_SERVER || 'http://localhost:8025').replace(/\/+$/, '')
37
+ const MODEL_DIR = resolveModelDir()
38
  const TMP = process.env.OMNI_TMP || '/tmp/omni-adapter'
39
  const TTS_OUT = TMP + '/tts-output'
40
 
scripts/build.ts CHANGED
@@ -123,6 +123,11 @@ const version = dev ? getDevVersion(pkg.version) : pkg.version
123
 
124
  mkdirSync(dirname(outfile), { recursive: true })
125
 
 
 
 
 
 
126
  const externals = [
127
  '@ant/*',
128
  'audio-capture-napi',
 
123
 
124
  mkdirSync(dirname(outfile), { recursive: true })
125
 
126
+ // Merge defaultFeatures into features for consistent behavior
127
+ for (const feature of defaultFeatures) {
128
+ featureSet.add(feature)
129
+ }
130
+
131
  const externals = [
132
  '@ant/*',
133
  'audio-capture-napi',
scripts/speak.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, os, sys, tempfile, asyncio
2
+
3
+ async def speak(text: str, voice: str = "en-US-JennyNeural", output_path: str | None = None) -> dict:
4
+ try:
5
+ import edge_tts
6
+ communicate = edge_tts.Communicate(text, voice)
7
+ if output_path:
8
+ await communicate.save(output_path)
9
+ return {"success": True, "audio_path": output_path}
10
+ tmp = tempfile.mktemp(suffix=".mp3")
11
+ await communicate.save(tmp)
12
+ return {"success": True, "audio_path": tmp}
13
+ except ImportError:
14
+ pass
15
+
16
+ return {"success": False, "error": "edge-tts is not installed. Run: pip install edge-tts"}
17
+
18
+ def main():
19
+ if len(sys.argv) < 2:
20
+ print(json.dumps({"success": False, "error": "Usage: speak.py <text> [--voice <name>] [--output <path>]"}))
21
+ sys.exit(1)
22
+
23
+ text = sys.argv[1]
24
+ voice = "en-US-JennyNeural"
25
+ output_path = None
26
+
27
+ i = 2
28
+ while i < len(sys.argv):
29
+ if sys.argv[i] == "--voice" and i + 1 < len(sys.argv):
30
+ voice = sys.argv[i + 1]
31
+ i += 2
32
+ elif sys.argv[i] == "--output" and i + 1 < len(sys.argv):
33
+ output_path = sys.argv[i + 1]
34
+ i += 2
35
+ else:
36
+ i += 1
37
+
38
+ result = asyncio.run(speak(text, voice, output_path))
39
+ print(json.dumps(result))
40
+
41
+ if __name__ == "__main__":
42
+ main()
scripts/transcribe.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, os, sys, tempfile, wave, struct
2
+
3
+ WAV_HEADER_SIZE = 44
4
+
5
+ DEFAULT_MODEL_PATH = os.path.expanduser("~/.cache/huggingface/hub/models--Systran--faster-whisper-base")
6
+
7
+ def write_wav(path: str, raw_pcm: bytes, sample_rate: int = 16000):
8
+ with wave.open(path, 'wb') as w:
9
+ w.setnchannels(1)
10
+ w.setsampwidth(2)
11
+ w.setframerate(sample_rate)
12
+ w.writeframes(raw_pcm)
13
+
14
+ def resolve_model(model_size: str) -> str:
15
+ if os.path.isdir(model_size):
16
+ return model_size
17
+ local_path = os.path.expanduser(f"~/.cache/huggingface/hub/models--Systran--faster-whisper-{model_size}")
18
+ if os.path.isdir(local_path):
19
+ return local_path
20
+ return model_size
21
+
22
+ def transcribe(wav_path: str, model_size: str = "base", language: str | None = None) -> dict:
23
+ model_path = resolve_model(model_size)
24
+ import sys as _sys
25
+ _sys.stderr.write(f"[DEBUG transcribe] model_path={model_path}\n")
26
+ _sys.stderr.flush()
27
+ try:
28
+ from faster_whisper import WhisperModel
29
+ model = WhisperModel(model_path, device="cpu", compute_type="int8")
30
+ opts = {"beam_size": 1}
31
+ if language:
32
+ opts["language"] = language
33
+ segments, info = model.transcribe(wav_path, **opts)
34
+ text = " ".join(seg.text for seg in segments)
35
+ return {"success": True, "text": text.strip(), "language": info.language}
36
+ except ImportError:
37
+ pass
38
+
39
+ try:
40
+ import whisper
41
+ model = whisper.load_model(model_size)
42
+ opts = {}
43
+ if language:
44
+ opts["language"] = language
45
+ result = model.transcribe(wav_path, **opts)
46
+ return {"success": True, "text": result["text"].strip(), "language": result.get("language", "")}
47
+ except ImportError:
48
+ pass
49
+
50
+ return {"success": False, "error": "Neither faster-whisper nor openai-whisper is installed. Run: pip install faster-whisper"}
51
+
52
+ def main():
53
+ if len(sys.argv) < 2:
54
+ print(json.dumps({"success": False, "error": "Usage: transcribe.py <wav_path> [--model <size>] [--language <code>]"}))
55
+ sys.exit(1)
56
+
57
+ wav_path = sys.argv[1]
58
+ model_size = "base"
59
+ language = None
60
+
61
+ i = 2
62
+ while i < len(sys.argv):
63
+ if sys.argv[i] == "--model" and i + 1 < len(sys.argv):
64
+ model_size = sys.argv[i + 1]
65
+ i += 2
66
+ elif sys.argv[i] == "--language" and i + 1 < len(sys.argv):
67
+ language = sys.argv[i + 1]
68
+ i += 2
69
+ else:
70
+ i += 1
71
+
72
+ if sys.argv[1] == "--stdin-pcm":
73
+ sample_rate = int(sys.argv[2]) if len(sys.argv) > 2 else 16000
74
+ raw = sys.stdin.buffer.read()
75
+ tmp = tempfile.mktemp(suffix=".wav")
76
+ write_wav(tmp, raw, sample_rate)
77
+ wav_path = tmp
78
+ result = transcribe(wav_path, model_size, language)
79
+ os.unlink(tmp)
80
+ else:
81
+ result = transcribe(wav_path, model_size, language)
82
+
83
+ print(json.dumps(result))
84
+
85
+ if __name__ == "__main__":
86
+ main()
src/commands/voice/voice.ts CHANGED
@@ -2,7 +2,6 @@ import { normalizeLanguageForSTT } from '../../hooks/useVoice.js'
2
  import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
3
  import { logEvent } from '../../services/analytics/index.js'
4
  import type { LocalCommandCall } from '../../types/command.js'
5
- import { isAnthropicAuthEnabled } from '../../utils/auth.js'
6
  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
7
  import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
8
  import {
@@ -14,7 +13,7 @@ import { isVoiceAvailable } from '../../voice/voiceModeEnabled.js'
14
  const LANG_HINT_MAX_SHOWS = 2
15
 
16
  export const call: LocalCommandCall = async () => {
17
- // Check only the GrowthBook kill-switch — voice might use Doubao (no auth)
18
  if (!isVoiceAvailable()) {
19
  return {
20
  type: 'text' as const,
@@ -25,7 +24,7 @@ export const call: LocalCommandCall = async () => {
25
  const currentSettings = getInitialSettings()
26
  const isCurrentlyEnabled = currentSettings.voiceEnabled === true
27
 
28
- // Toggle OFF — no checks needed
29
  if (isCurrentlyEnabled) {
30
  const result = updateSettingsForSource('userSettings', {
31
  voiceEnabled: false,
@@ -45,34 +44,9 @@ export const call: LocalCommandCall = async () => {
45
  }
46
  }
47
 
48
- // Toggle ON — run pre-flight checks first
49
- const { isVoiceStreamAvailable } = await import(
50
- '../../services/voiceStreamSTT.js'
51
- )
52
  const { checkRecordingAvailability } = await import('../../services/voice.js')
53
 
54
- // Determine available STT backend: prefer Anthropic, fall back to Doubao
55
- const hasAnthropicAuth = isAnthropicAuthEnabled() && isVoiceStreamAvailable()
56
- let useDoubao = false
57
- if (!hasAnthropicAuth) {
58
- const { isDoubaoAvailable } = await import('../../services/doubaoSTT.js')
59
- if (!(await isDoubaoAvailable())) {
60
- // Neither backend is available
61
- if (!isAnthropicAuthEnabled()) {
62
- return {
63
- type: 'text' as const,
64
- value:
65
- 'Voice mode requires a Claude.ai account or the Doubao ASR backend. Please run /login to sign in, or install Doubao ASR.',
66
- }
67
- }
68
- return {
69
- type: 'text' as const,
70
- value: 'Voice mode is not available.',
71
- }
72
- }
73
- useDoubao = true
74
- }
75
-
76
  // Check recording availability (microphone access)
77
  const recording = await checkRecordingAvailability()
78
  if (!recording.available) {
@@ -98,16 +72,15 @@ export const call: LocalCommandCall = async () => {
98
  }
99
  }
100
 
101
- // Probe mic access so the OS permission dialog fires now rather than
102
- // on the user's first hold-to-talk activation.
103
  if (!(await requestMicrophonePermission())) {
104
  let guidance: string
105
  if (process.platform === 'win32') {
106
- guidance = 'Settings \u2192 Privacy \u2192 Microphone'
107
  } else if (process.platform === 'linux') {
108
  guidance = "your system's audio settings"
109
  } else {
110
- guidance = 'System Settings \u2192 Privacy & Security \u2192 Microphone'
111
  }
112
  return {
113
  type: 'text' as const,
@@ -115,10 +88,10 @@ export const call: LocalCommandCall = async () => {
115
  }
116
  }
117
 
118
- // All checks passed — enable voice and store backend choice
119
  const result = updateSettingsForSource('userSettings', {
120
  voiceEnabled: true,
121
- ...(useDoubao ? { voiceProvider: 'doubao' as const } : { voiceProvider: 'anthropic' as const }),
122
  })
123
  if (result.error) {
124
  return {
@@ -132,8 +105,6 @@ export const call: LocalCommandCall = async () => {
132
  const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
133
  const stt = normalizeLanguageForSTT(currentSettings.language)
134
  const cfg = getGlobalConfig()
135
- // Reset the hint counter whenever the resolved STT language changes
136
- // (including first-ever enable, where lastLanguage is undefined).
137
  const langChanged = cfg.voiceLangHintLastLanguage !== stt.code
138
  const priorCount = langChanged ? 0 : (cfg.voiceLangHintShownCount ?? 0)
139
  const showHint = !stt.fellBackFrom && priorCount < LANG_HINT_MAX_SHOWS
@@ -150,9 +121,8 @@ export const call: LocalCommandCall = async () => {
150
  voiceLangHintLastLanguage: stt.code,
151
  }))
152
  }
153
- const backendNote = useDoubao ? ' [Doubao ASR]' : ''
154
  return {
155
  type: 'text' as const,
156
- value: `Voice mode enabled. Hold ${key} to record.${langNote}${backendNote}`,
157
  }
158
  }
 
2
  import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
3
  import { logEvent } from '../../services/analytics/index.js'
4
  import type { LocalCommandCall } from '../../types/command.js'
 
5
  import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
6
  import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
7
  import {
 
13
  const LANG_HINT_MAX_SHOWS = 2
14
 
15
  export const call: LocalCommandCall = async () => {
16
+ // Check only the GrowthBook kill-switch
17
  if (!isVoiceAvailable()) {
18
  return {
19
  type: 'text' as const,
 
24
  const currentSettings = getInitialSettings()
25
  const isCurrentlyEnabled = currentSettings.voiceEnabled === true
26
 
27
+ // Toggle OFF
28
  if (isCurrentlyEnabled) {
29
  const result = updateSettingsForSource('userSettings', {
30
  voiceEnabled: false,
 
44
  }
45
  }
46
 
47
+ // Toggle ON — local provider (no auth needed)
 
 
 
48
  const { checkRecordingAvailability } = await import('../../services/voice.js')
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  // Check recording availability (microphone access)
51
  const recording = await checkRecordingAvailability()
52
  if (!recording.available) {
 
72
  }
73
  }
74
 
75
+ // Probe mic access so the OS permission dialog fires now
 
76
  if (!(await requestMicrophonePermission())) {
77
  let guidance: string
78
  if (process.platform === 'win32') {
79
+ guidance = 'Settings → Privacy → Microphone'
80
  } else if (process.platform === 'linux') {
81
  guidance = "your system's audio settings"
82
  } else {
83
+ guidance = 'System Settings → Privacy & Security → Microphone'
84
  }
85
  return {
86
  type: 'text' as const,
 
88
  }
89
  }
90
 
91
+ // Enable voice with local provider
92
  const result = updateSettingsForSource('userSettings', {
93
  voiceEnabled: true,
94
+ voiceProvider: 'local' as const,
95
  })
96
  if (result.error) {
97
  return {
 
105
  const key = getShortcutDisplay('voice:pushToTalk', 'Chat', 'Space')
106
  const stt = normalizeLanguageForSTT(currentSettings.language)
107
  const cfg = getGlobalConfig()
 
 
108
  const langChanged = cfg.voiceLangHintLastLanguage !== stt.code
109
  const priorCount = langChanged ? 0 : (cfg.voiceLangHintShownCount ?? 0)
110
  const showHint = !stt.fellBackFrom && priorCount < LANG_HINT_MAX_SHOWS
 
121
  voiceLangHintLastLanguage: stt.code,
122
  }))
123
  }
 
124
  return {
125
  type: 'text' as const,
126
+ value: `Voice mode enabled (local whisper). Hold ${key} to record.${langNote}`,
127
  }
128
  }
src/components/Messages.tsx CHANGED
@@ -5,6 +5,7 @@ import type { UUID } from 'crypto';
5
  import type { RefObject } from 'react';
6
  import * as React from 'react';
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
8
  import { every } from 'src/utils/set.js';
9
  import { getIsRemoteMode } from '../bootstrap/state.js';
10
  import type { Command } from '../commands.js';
@@ -543,6 +544,8 @@ const MessagesImpl = ({
543
  }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
544
  const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
545
 
 
 
546
  // Divider insertion point: first renderableMessage whose uuid shares the
547
  // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
548
  // chars of the source message uuid, so this matches any block from it).
 
5
  import type { RefObject } from 'react';
6
  import * as React from 'react';
7
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
+ import { useAutoTTS } from '../hooks/useAutoTTS.js';
9
  import { every } from 'src/utils/set.js';
10
  import { getIsRemoteMode } from '../bootstrap/state.js';
11
  import type { Command } from '../commands.js';
 
544
  }, [collapsed_0, renderRange, virtualScrollRuntimeGate, disableRenderCap]);
545
  const streamingToolUseIDs = useMemo(() => new Set(streamingToolUses.map(__0 => __0.contentBlock.id)), [streamingToolUses]);
546
 
547
+ useAutoTTS(renderableMessages);
548
+
549
  // Divider insertion point: first renderableMessage whose uuid shares the
550
  // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24
551
  // chars of the source message uuid, so this matches any block from it).
src/hooks/useAutoTTS.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react'
2
+ import { speakWithEdgeTTS, playAudioFile } from '../services/voice/edgeTTS.js'
3
+ import { getInitialSettings } from '../utils/settings/settings.js'
4
+ import type { RenderableMessage } from '../types/message.js'
5
+
6
+ export function useAutoTTS(messages: RenderableMessage[]): void {
7
+ const triggeredIdsRef = useRef<Set<string>>(new Set())
8
+ const pendingPlayRef = useRef<Promise<void> | null>(null)
9
+
10
+ useEffect(() => {
11
+ const settings = getInitialSettings()
12
+ if (!settings.voiceAutoTTS || !settings.voiceEnabled) return
13
+
14
+ for (const msg of messages) {
15
+ if (msg.type !== 'assistant') continue
16
+ if (triggeredIdsRef.current.has(msg.id)) continue
17
+
18
+ const content = msg.message.content[0]
19
+ if (content?.type !== 'text') continue
20
+ if (!content.text.trim()) continue
21
+
22
+ triggeredIdsRef.current.add(msg.id)
23
+
24
+ const text = content.text
25
+ const voice = settings.voiceTTSVoice || 'en-US-JennyNeural'
26
+
27
+ void (async () => {
28
+ const result = await speakWithEdgeTTS(text, {
29
+ voice,
30
+ pythonPath: settings.voiceTTSCommand || undefined,
31
+ })
32
+ if (result.success && result.audioPath) {
33
+ await playAudioFile(result.audioPath)
34
+ }
35
+ })()
36
+ }
37
+ }, [messages])
38
+ }
src/hooks/useVoice.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
  type VoiceStreamConnection,
23
  } from '../services/voiceStreamSTT.js'
24
  import { connectDoubaoStream } from '../services/doubaoSTT.js'
 
25
  import { logForDebugging } from '../utils/debug.js'
26
  import { toError } from '../utils/errors.js'
27
  import { getSystemLocaleLanguage } from '../utils/intl.js'
@@ -139,6 +140,10 @@ function isDoubaoProvider(): boolean {
139
  return getInitialSettings().voiceProvider === 'doubao'
140
  }
141
 
 
 
 
 
142
  // Lazy-loaded voice module. We defer importing voice.ts (and its native
143
  // audio-capture-napi dependency) until voice input is actually activated.
144
  // On macOS, loading the native audio module can trigger a TCC microphone
@@ -392,26 +397,31 @@ export function useVoice({
392
  !silentDropRetriedRef.current &&
393
  fullAudioRef.current.length > 0
394
  ) {
395
- silentDropRetriedRef.current = true
396
- logForDebugging(
397
- `[voice] Silent-drop detected (no_data_timeout, ${String(fullAudioRef.current.length)} chunks); replaying on fresh connection`,
398
- )
399
- logEvent('tengu_voice_silent_drop_replay', {
400
- recordingDurationMs,
401
- chunkCount: fullAudioRef.current.length,
402
- })
403
- if (connectionRef.current) {
404
- connectionRef.current.close()
405
- connectionRef.current = null
406
- }
407
- const replayBuffer = fullAudioRef.current
408
- await sleep(250)
409
- if (isStale()) return
410
- const stt = normalizeLanguageForSTT(getInitialSettings().language)
411
- const keyterms = await getVoiceKeyterms()
412
- if (isStale()) return
413
- await new Promise<void>(resolve => {
414
- void connectVoiceStream(
 
 
 
 
 
415
  {
416
  onTranscript: (t, isFinal) => {
417
  if (isStale()) return
@@ -580,7 +590,7 @@ export function useVoice({
580
  // stop when it loses focus. This enables a "multi-clauding army"
581
  // workflow where voice input follows window focus.
582
  useEffect(() => {
583
- if (!enabled || !focusMode || isDoubaoProvider()) {
584
  // Focus mode was disabled while a focus-driven recording was active —
585
  // stop the recording so it doesn't linger until the silence timer fires.
586
  if (focusTriggeredRef.current && stateRef.current === 'recording') {
@@ -785,15 +795,18 @@ export function useVoice({
785
  const attemptConnect = (keyterms: string[]): void => {
786
  const myAttemptGen = attemptGenRef.current
787
  // Select STT backend based on settings.voiceProvider
788
- const connectFn = isDoubaoProvider()
789
- ? (
790
- cbs: Parameters<typeof connectDoubaoStream>[0],
791
- opts: Parameters<typeof connectDoubaoStream>[1],
792
- ) => connectDoubaoStream(cbs, opts)
793
- : (
794
- cbs: Parameters<typeof connectVoiceStream>[0],
795
- opts: Parameters<typeof connectVoiceStream>[1],
796
- ) => connectVoiceStream(cbs, opts)
 
 
 
797
  void connectFn(
798
  {
799
  onTranscript: (text: string, isFinal: boolean) => {
@@ -1000,12 +1013,21 @@ export function useVoice({
1000
  return
1001
  }
1002
  if (!conn) {
1003
- logForDebugging(
1004
- '[voice] Failed to connect to voice_stream (no OAuth token?)',
1005
- )
1006
- onErrorRef.current?.(
1007
- 'Voice mode requires a Claude.ai account. Please run /login to sign in.',
1008
- )
 
 
 
 
 
 
 
 
 
1009
  // Clear the audio buffer on failure
1010
  audioBuffer.length = 0
1011
  cleanup()
@@ -1023,8 +1045,8 @@ export function useVoice({
1023
  })
1024
  }
1025
 
1026
- // Doubao backend doesn't use keyterms — skip the async fetch
1027
- if (isDoubaoProvider()) {
1028
  attemptConnect([])
1029
  } else {
1030
  void getVoiceKeyterms().then(attemptConnect)
@@ -1042,9 +1064,11 @@ export function useVoice({
1042
  // delay of ~500ms on macOS).
1043
  const handleKeyEvent = useCallback(
1044
  (fallbackMs = REPEAT_FALLBACK_MS): void => {
1045
- const sttAvailable = isDoubaoProvider()
1046
- ? isDoubaoAvailableSync()
1047
- : isVoiceStreamAvailable()
 
 
1048
  if (!enabled || !sttAvailable) {
1049
  return
1050
  }
 
22
  type VoiceStreamConnection,
23
  } from '../services/voiceStreamSTT.js'
24
  import { connectDoubaoStream } from '../services/doubaoSTT.js'
25
+ import { connectLocalWhisperStream } from '../services/voice/whisperSTT.js'
26
  import { logForDebugging } from '../utils/debug.js'
27
  import { toError } from '../utils/errors.js'
28
  import { getSystemLocaleLanguage } from '../utils/intl.js'
 
140
  return getInitialSettings().voiceProvider === 'doubao'
141
  }
142
 
143
+ function isLocalProvider(): boolean {
144
+ return getInitialSettings().voiceProvider === 'local'
145
+ }
146
+
147
  // Lazy-loaded voice module. We defer importing voice.ts (and its native
148
  // audio-capture-napi dependency) until voice input is actually activated.
149
  // On macOS, loading the native audio module can trigger a TCC microphone
 
397
  !silentDropRetriedRef.current &&
398
  fullAudioRef.current.length > 0
399
  ) {
400
+ // Local whisper doesn't support silent-drop replay (different backend)
401
+ if (isLocalProvider()) {
402
+ callbacks.onClose()
403
+ return
404
+ }
405
+ silentDropRetriedRef.current = true
406
+ logForDebugging(
407
+ `[voice] Silent-drop detected (no_data_timeout, ${String(fullAudioRef.current.length)} chunks); replaying on fresh connection`,
408
+ )
409
+ logEvent('tengu_voice_silent_drop_replay', {
410
+ recordingDurationMs,
411
+ chunkCount: fullAudioRef.current.length,
412
+ })
413
+ if (connectionRef.current) {
414
+ connectionRef.current.close()
415
+ connectionRef.current = null
416
+ }
417
+ const replayBuffer = fullAudioRef.current
418
+ await sleep(250)
419
+ if (isStale()) return
420
+ const stt = normalizeLanguageForSTT(getInitialSettings().language)
421
+ const keyterms = await getVoiceKeyterms()
422
+ if (isStale()) return
423
+ await new Promise<void>(resolve => {
424
+ void connectVoiceStream(
425
  {
426
  onTranscript: (t, isFinal) => {
427
  if (isStale()) return
 
590
  // stop when it loses focus. This enables a "multi-clauding army"
591
  // workflow where voice input follows window focus.
592
  useEffect(() => {
593
+ if (!enabled || !focusMode || isDoubaoProvider() || isLocalProvider()) {
594
  // Focus mode was disabled while a focus-driven recording was active —
595
  // stop the recording so it doesn't linger until the silence timer fires.
596
  if (focusTriggeredRef.current && stateRef.current === 'recording') {
 
795
  const attemptConnect = (keyterms: string[]): void => {
796
  const myAttemptGen = attemptGenRef.current
797
  // Select STT backend based on settings.voiceProvider
798
+ let connectFn: (
799
+ cbs: VoiceStreamCallbacks,
800
+ opts: { language: string; keyterms: string[] },
801
+ ) => Promise<VoiceStreamConnection | null>
802
+ if (isLocalProvider()) {
803
+ connectFn = (cbs, _opts) =>
804
+ connectLocalWhisperStream(cbs, { language: stt.code })
805
+ } else if (isDoubaoProvider()) {
806
+ connectFn = (cbs, opts) => connectDoubaoStream(cbs, opts)
807
+ } else {
808
+ connectFn = (cbs, opts) => connectVoiceStream(cbs, opts)
809
+ }
810
  void connectFn(
811
  {
812
  onTranscript: (text: string, isFinal: boolean) => {
 
1013
  return
1014
  }
1015
  if (!conn) {
1016
+ if (isLocalProvider()) {
1017
+ logForDebugging(
1018
+ '[voice] Local whisper STT failed to initialize',
1019
+ )
1020
+ onErrorRef.current?.(
1021
+ 'Local voice mode failed. Ensure Python with faster-whisper is installed.',
1022
+ )
1023
+ } else {
1024
+ logForDebugging(
1025
+ '[voice] Failed to connect to voice_stream (no OAuth token?)',
1026
+ )
1027
+ onErrorRef.current?.(
1028
+ 'Voice mode requires a Claude.ai account. Please run /login to sign in.',
1029
+ )
1030
+ }
1031
  // Clear the audio buffer on failure
1032
  audioBuffer.length = 0
1033
  cleanup()
 
1045
  })
1046
  }
1047
 
1048
+ // Doubao and local backends don't use keyterms — skip the async fetch
1049
+ if (isDoubaoProvider() || isLocalProvider()) {
1050
  attemptConnect([])
1051
  } else {
1052
  void getVoiceKeyterms().then(attemptConnect)
 
1064
  // delay of ~500ms on macOS).
1065
  const handleKeyEvent = useCallback(
1066
  (fallbackMs = REPEAT_FALLBACK_MS): void => {
1067
+ const sttAvailable = isLocalProvider()
1068
+ ? true
1069
+ : isDoubaoProvider()
1070
+ ? isDoubaoAvailableSync()
1071
+ : isVoiceStreamAvailable()
1072
  if (!enabled || !sttAvailable) {
1073
  return
1074
  }
src/services/voice/edgeTTS.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from 'child_process'
2
+ import { existsSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+
6
+ const SCRIPTS_DIR = join(import.meta.dirname, '..', '..', '..', 'scripts')
7
+
8
+ function resolvePythonPath(customPath?: string): string {
9
+ if (customPath) return customPath
10
+ const venvPython = join(import.meta.dirname, '..', '..', '..', '.venv', 'bin', 'python')
11
+ return existsSync(venvPython) ? venvPython : 'python3'
12
+ }
13
+
14
+ export type TTSResult = {
15
+ success: boolean
16
+ audioPath?: string
17
+ error?: string
18
+ }
19
+
20
+ export type TTSSpeakOptions = {
21
+ voice?: string
22
+ outputPath?: string
23
+ pythonPath?: string
24
+ }
25
+
26
+ /**
27
+ * Text-to-speech using edge-tts (Microsoft Edge Neural Voice).
28
+ * Spawns a Python subprocess to run edge-tts and save the audio file.
29
+ *
30
+ * Requirements: pip install edge-tts
31
+ */
32
+ export async function speakWithEdgeTTS(
33
+ text: string,
34
+ options?: TTSSpeakOptions,
35
+ ): Promise<TTSResult> {
36
+ const python = resolvePythonPath(options?.pythonPath)
37
+ const voice = options?.voice ?? 'en-US-JennyNeural'
38
+ const script = join(SCRIPTS_DIR, 'speak.py')
39
+
40
+ return new Promise(resolve => {
41
+ const args = [script, text, '--voice', voice]
42
+ if (options?.outputPath) {
43
+ args.push('--output', options.outputPath)
44
+ }
45
+
46
+ const proc = spawn(python, args, { stdio: ['ignore', 'pipe', 'pipe'] })
47
+
48
+ let stdout = ''
49
+ let stderr = ''
50
+
51
+ proc.stdout.on('data', (data: Buffer) => {
52
+ stdout += data.toString()
53
+ })
54
+
55
+ proc.stderr.on('data', (data: Buffer) => {
56
+ stderr += data.toString()
57
+ })
58
+
59
+ proc.on('close', exitCode => {
60
+ if (exitCode !== 0) {
61
+ resolve({
62
+ success: false,
63
+ error: `edge-tts failed: ${stderr || 'unknown error'}`,
64
+ })
65
+ return
66
+ }
67
+
68
+ try {
69
+ const result = JSON.parse(stdout)
70
+ resolve(result)
71
+ } catch {
72
+ resolve({
73
+ success: false,
74
+ error: `Failed to parse edge-tts output: ${stdout.slice(0, 200)}`,
75
+ })
76
+ }
77
+ })
78
+
79
+ proc.on('error', err => {
80
+ resolve({
81
+ success: false,
82
+ error: `Failed to spawn edge-tts: ${err.message}`,
83
+ })
84
+ })
85
+ })
86
+ }
87
+
88
+ /** Check if edge-tts is available. */
89
+ export async function checkEdgeTTSAvailable(pythonPath?: string): Promise<boolean> {
90
+ const python = resolvePythonPath(pythonPath)
91
+ return new Promise(resolve => {
92
+ const proc = spawn(python, [
93
+ '-c',
94
+ 'import json;' +
95
+ 'try: import edge_tts; print(json.dumps({"ok": True}))' +
96
+ 'except ImportError: print(json.dumps({"ok": False}))',
97
+ ], { stdio: ['ignore', 'pipe', 'pipe'] })
98
+
99
+ let stdout = ''
100
+ proc.stdout.on('data', (data: Buffer) => { stdout += data.toString() })
101
+ proc.on('close', () => {
102
+ try {
103
+ const result = JSON.parse(stdout)
104
+ resolve(result.ok === true)
105
+ } catch {
106
+ resolve(false)
107
+ }
108
+ })
109
+ })
110
+ }
111
+
112
+ /** Play an audio file using system player. */
113
+ export function playAudioFile(path: string): Promise<void> {
114
+ return new Promise((resolve, reject) => {
115
+ const platform = process.platform
116
+ let cmd: string
117
+ let args: string[]
118
+
119
+ if (platform === 'darwin') {
120
+ cmd = 'afplay'
121
+ args = [path]
122
+ } else if (platform === 'linux') {
123
+ cmd = 'ffplay'
124
+ args = ['-nodisp', '-autoexit', path]
125
+ } else if (platform === 'win32') {
126
+ cmd = 'start'
127
+ args = [path]
128
+ } else {
129
+ reject(new Error(`Unsupported platform: ${platform}`))
130
+ return
131
+ }
132
+
133
+ const proc = spawn(cmd, args, { stdio: 'ignore' })
134
+ proc.on('close', code => {
135
+ if (code === 0) resolve()
136
+ else reject(new Error(`Playback exited with code ${code}`))
137
+ })
138
+ proc.on('error', reject)
139
+ })
140
+ }
src/services/voice/providers.ts ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Provider abstraction for VersperClaw voice mode.
2
+ //
3
+ // Two interfaces:
4
+ // - `TranscriptionProvider` — speech → text
5
+ // - `TTSProvider` — text → audio
6
+ //
7
+ // Built-in concrete providers:
8
+ // - `LocalWhisperSTT` — subprocess wrapper around a local whisper.cpp / faster-whisper CLI
9
+ // - `DoubaoSTTProvider` — wraps `src/services/doubaoSTT.ts`
10
+ // - `EdgeTTSProvider` — subprocess wrapper around `edge-tts` CLI
11
+ // - `CommandTTSProvider` — generic shell command with `{input}` / `{input_path}` / `{output_path}` placeholders
12
+
13
+ import { spawn } from 'node:child_process'
14
+ import { existsSync } from 'node:fs'
15
+ import os from 'node:os'
16
+ import path from 'node:path'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public contracts
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type TranscriptionResult = {
23
+ success: boolean
24
+ text: string
25
+ error?: string
26
+ }
27
+
28
+ export interface TranscriptionProvider {
29
+ name: string
30
+ transcribe(wavPath: string, language?: string): Promise<TranscriptionResult>
31
+ }
32
+
33
+ export type SynthesisResult = {
34
+ audioPath?: string
35
+ error?: string
36
+ }
37
+
38
+ export interface TTSProvider {
39
+ name: string
40
+ synthesize(text: string): Promise<SynthesisResult>
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Local whisper STT
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export type LocalWhisperSTTOptions = {
48
+ binary?: string
49
+ model?: string
50
+ language?: string
51
+ args?: string[]
52
+ }
53
+
54
+ export class LocalWhisperSTT implements TranscriptionProvider {
55
+ readonly name = 'local-whisper'
56
+
57
+ constructor(private readonly opts: LocalWhisperSTTOptions = {}) {}
58
+
59
+ async transcribe(wavPath: string, language?: string): Promise<TranscriptionResult> {
60
+ const binary = this.resolveBinary()
61
+ if (!binary) {
62
+ return { success: false, text: '', error: 'no whisper binary found' }
63
+ }
64
+ const outDir = await this.makeTmpDir('vc-whisper-out')
65
+ const lang = this.opts.language ?? language ?? 'auto'
66
+ const args = [
67
+ ...(this.opts.args ?? []),
68
+ ...(this.opts.model ? ['-m', this.opts.model] : []),
69
+ '-l',
70
+ lang,
71
+ '--output_format',
72
+ 'txt',
73
+ '--output_dir',
74
+ outDir,
75
+ wavPath,
76
+ ]
77
+ try {
78
+ await this.exec(binary, args)
79
+ const base = path.basename(wavPath, path.extname(wavPath)) + '.txt'
80
+ const txtPath = path.join(outDir, base)
81
+ if (!existsSync(txtPath)) {
82
+ return { success: false, text: '', error: 'whisper produced no txt output' }
83
+ }
84
+ const text = await os.domain?.(txtPath) ?? (await import('node:fs')).promises.readFile(txtPath, 'utf8')
85
+ return { success: true, text: String(text).trim() }
86
+ } catch (e: any) {
87
+ return { success: false, text: '', error: e?.message ?? String(e) }
88
+ }
89
+ }
90
+
91
+ private resolveBinary(): string | undefined {
92
+ if (this.opts.binary && existsSync(this.opts.binary)) return this.opts.binary
93
+ const candidates = [
94
+ 'whisper.cpp',
95
+ 'whisper-cpp',
96
+ 'whisper',
97
+ 'main',
98
+ path.join(os.homedir(), '.local', 'bin', 'whisper.cpp'),
99
+ '/opt/homebrew/bin/whisper.cpp',
100
+ '/usr/local/bin/whisper.cpp',
101
+ ]
102
+ for (const c of candidates) {
103
+ if (existsSync(c)) return c
104
+ }
105
+ return undefined
106
+ }
107
+
108
+ private async exec(cmd: string, args: string[]): Promise<void> {
109
+ await new Promise<void>((resolve, reject) => {
110
+ const child = spawn(cmd, args, { shell: false })
111
+ let stderr = ''
112
+ child.stderr?.on('data', (chunk) => {
113
+ stderr += String(chunk)
114
+ })
115
+ child.on('close', (code) => {
116
+ if (code === 0) resolve()
117
+ else reject(new Error(`exit ${code}: ${stderr.slice(0, 200)}`))
118
+ })
119
+ child.on('error', (err) => reject(err))
120
+ })
121
+ }
122
+
123
+ private async makeTmpDir(prefix: string): Promise<string> {
124
+ const dir = path.join(os.tmpdir(), `versperclaw-${prefix}-${process.pid}-${Date.now()}`)
125
+ await new Promise<void>((resolve) => {
126
+ const w = spawn('mkdir', ['-p', dir])
127
+ w.on('close', (code) => (code === 0 ? resolve() : resolve()))
128
+ })
129
+ return dir
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Doubao STT
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export class DoubaoSTTProvider implements TranscriptionProvider {
138
+ readonly name = 'doubao'
139
+
140
+ async transcribe(wavPath: string, language?: string): Promise<TranscriptionResult> {
141
+ try {
142
+ const { connectDoubaoStream, normalizeLanguageForSTT } = await import('./doubaoSTT.js')
143
+ const normalized = normalizeLanguageForSTT(language)
144
+ const code = normalized.code
145
+ const chunks: Buffer[] = []
146
+ const conn = await connectDoubaoStream(
147
+ {
148
+ onTranscript: (text: string) => {
149
+ if (text) chunks.push(Buffer.from(text, 'utf8'))
150
+ },
151
+ onError: (msg: string) => {
152
+ throw new Error(msg)
153
+ },
154
+ onClose: () => {},
155
+ onReady: (c) => {
156
+ const buf = Buffer.from(await (async () => (await import('node:fs')).promises.readFile(wavPath))())
157
+ c.send(buf)
158
+ void c.finalize().then(() => c.close())
159
+ },
160
+ },
161
+ { language: code === 'en' ? undefined : code },
162
+ )
163
+ if (!conn) throw new Error('doubao connectDoubaoStream returned null')
164
+ await new Promise((resolve) => setTimeout(resolve, 500))
165
+ const text = Buffer.concat(chunks).toString('utf8').trim()
166
+ if (!text) return { success: false, text: '', error: 'empty transcript' }
167
+ return { success: true, text }
168
+ } catch (e: any) {
169
+ return { success: false, text: '', error: e?.message ?? String(e) }
170
+ }
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Edge TTS
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export class EdgeTTSProvider implements TTSProvider {
179
+ readonly name = 'edge-tts'
180
+
181
+ constructor(private readonly voice?: string) {}
182
+
183
+ async synthesize(text: string): Promise<SynthesisResult> {
184
+ const trimmed = text.trim()
185
+ if (!trimmed) return { error: 'empty text' }
186
+ const outPath = path.join(os.homedir(), '.claude', 'voice', `tts_${Date.now()}.mp3`)
187
+ await new Promise((resolve) => {
188
+ const w = spawn('mkdir', ['-p', path.dirname(outPath)])
189
+ w.on('close', () => resolve(undefined))
190
+ })
191
+ const selectedVoice = this.voice ?? 'en-US-AriaNeural'
192
+ const inputPath = await this.writeTempText(trimmed)
193
+ try {
194
+ const args = [
195
+ '--voice',
196
+ selectedVoice,
197
+ '--text',
198
+ trimmed,
199
+ '--write-media',
200
+ outPath,
201
+ ]
202
+ await this.exec('edge-tts', args)
203
+ if (!existsSync(outPath)) {
204
+ return { error: 'edge-tts produced no output file' }
205
+ }
206
+ return { audioPath: outPath }
207
+ } catch (e: any) {
208
+ return { error: e?.message ?? String(e) }
209
+ } finally {
210
+ try {
211
+ await (await import('node:fs')).promises.unlink(inputPath)
212
+ } catch {
213
+ // ignore
214
+ }
215
+ }
216
+ }
217
+
218
+ private async exec(cmd: string, args: string[]): Promise<void> {
219
+ await new Promise<void>((resolve, reject) => {
220
+ const child = spawn(cmd, args, { shell: false })
221
+ let stderr = ''
222
+ child.stderr?.on('data', (chunk) => {
223
+ stderr += String(chunk)
224
+ })
225
+ child.on('close', (code) => {
226
+ if (code === 0) resolve()
227
+ else reject(new Error(`exit ${code}: ${stderr.slice(0, 200)}`))
228
+ })
229
+ child.on('error', (err) => reject(err))
230
+ })
231
+ }
232
+
233
+ private async writeTempText(text: string): Promise<string> {
234
+ const p = path.join(os.tmpdir(), `versperclaw-edge-tts-${process.pid}-${Date.now()}.txt`)
235
+ await (await import('node:fs')).promises.writeFile(p, text, 'utf8')
236
+ return p
237
+ }
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Command-based fallback TTS
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export type CommandTTSTemplate = string
245
+
246
+ export class CommandTTSProvider implements TTSProvider {
247
+ readonly name = 'command'
248
+
249
+ constructor(private readonly template: CommandTTSTemplate) {}
250
+
251
+ async synthesize(text: string): Promise<SynthesisResult> {
252
+ const inputPath = await this.writeTempText(text)
253
+ const outputPath = path.join(os.tmpdir(), `vesperclaw-tts-out-${Date.now()}.mp3`)
254
+ const resolved = this.template
255
+ .replace('{input}', inputPath)
256
+ .replace('{input_path}', inputPath)
257
+ .replace('{output_path}', outputPath)
258
+ try {
259
+ await this.exec(resolved)
260
+ return { audioPath: outputPath }
261
+ } catch (e: any) {
262
+ return { error: e?.message ?? String(e) }
263
+ } finally {
264
+ try {
265
+ await (await import('node:fs')).promises.unlink(inputPath)
266
+ } catch {
267
+ // ignore
268
+ }
269
+ }
270
+ }
271
+
272
+ private async exec(command: string): Promise<void> {
273
+ await new Promise<void>((resolve, reject) => {
274
+ const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] })
275
+ let stderr = ''
276
+ child.stderr?.on('data', (chunk) => {
277
+ stderr += String(chunk)
278
+ })
279
+ child.on('close', (code) => {
280
+ if (code === 0) resolve()
281
+ else reject(new Error(`exit ${code}: ${stderr.slice(0, 200)}`))
282
+ })
283
+ child.on('error', (err) => reject(err))
284
+ })
285
+ }
286
+
287
+ private async writeTempText(text: string): Promise<string> {
288
+ const p = path.join(os.tmpdir(), `vesperclaw-tts-txt-${process.pid}-${Date.now()}.txt`)
289
+ await (await import('node:fs')).promises.writeFile(p, text, 'utf8')
290
+ return p
291
+ }
292
+ }
src/services/voice/whisperSTT.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from 'child_process'
2
+ import { appendFileSync, existsSync, mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import type { VoiceStreamCallbacks, VoiceStreamConnection, FinalizeSource } from '../voiceStreamSTT.js'
6
+
7
+ const SCRIPTS_DIR = join(import.meta.dirname, '..', '..', '..', 'scripts')
8
+ const DEBUG_LOG = '/tmp/voice_debug.log'
9
+ const dbg = (...args: unknown[]) => {
10
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ')
11
+ const line = `[${new Date().toISOString()}] ${msg}\n`
12
+ appendFileSync(DEBUG_LOG, line)
13
+ }
14
+
15
+ function resolvePythonPath(customPath?: string): string {
16
+ if (customPath) return customPath
17
+ const venvPython = join(import.meta.dirname, '..', '..', '..', '.venv', 'bin', 'python')
18
+ return existsSync(venvPython) ? venvPython : 'python3'
19
+ }
20
+
21
+ type WhisperOptions = {
22
+ model?: string
23
+ language?: string
24
+ pythonPath?: string
25
+ }
26
+
27
+ /**
28
+ * A local Whisper STT provider that implements the VoiceStreamConnection
29
+ * interface. Buffers incoming audio chunks, then on finalize() writes a
30
+ * temporary WAV file and spawns a Python faster-whisper subprocess to
31
+ * transcribe it.
32
+ *
33
+ * Requirements: pip install faster-whisper (or openai-whisper)
34
+ *
35
+ * Whisper model files are cached at ~/.cache/whisper/ automatically.
36
+ */
37
+ export function connectLocalWhisperStream(
38
+ callbacks: VoiceStreamCallbacks,
39
+ options?: WhisperOptions,
40
+ ): Promise<VoiceStreamConnection | null> {
41
+ return new Promise(resolve => {
42
+ const chunks: Buffer[] = []
43
+ let finalized = false
44
+ let tmpDir: string | null = null
45
+
46
+ const python = resolvePythonPath(options?.pythonPath)
47
+ const model = options?.model ?? 'base'
48
+ const language = options?.language
49
+
50
+ dbg('whisperSTT', 'python', python, 'model', model, 'language', language)
51
+
52
+ const connection: VoiceStreamConnection = {
53
+ send(chunk: Buffer) {
54
+ if (finalized) return
55
+ chunks.push(Buffer.from(chunk))
56
+ if (chunks.length === 1) {
57
+ dbg('whisperSTT', 'first chunk', chunk.length, 'bytes')
58
+ }
59
+ },
60
+
61
+ async finalize(): Promise<FinalizeSource> {
62
+ if (finalized) return 'ws_already_closed'
63
+ finalized = true
64
+
65
+ dbg('whisperSTT', 'finalize called, chunks', chunks.length)
66
+
67
+ if (chunks.length === 0) {
68
+ dbg('whisperSTT', 'no audio chunks, returning no_data_timeout')
69
+ callbacks.onClose()
70
+ return 'no_data_timeout'
71
+ }
72
+
73
+ const audioBuf = Buffer.concat(chunks)
74
+ dbg('whisperSTT', 'total audio size', audioBuf.length, 'bytes')
75
+
76
+ try {
77
+ tmpDir = mkdtempSync(join(tmpdir(), 'vc-whisper-'))
78
+ const wavPath = join(tmpDir, 'input.wav')
79
+
80
+ writeWavHeader(wavPath, audioBuf, 16000)
81
+
82
+ const args = [join(SCRIPTS_DIR, 'transcribe.py'), wavPath, '--model', model]
83
+ if (language) {
84
+ args.push('--language', language)
85
+ }
86
+
87
+ const proc = spawn(python, args, { stdio: ['ignore', 'pipe', 'pipe'] })
88
+
89
+ let stdout = ''
90
+ let stderr = ''
91
+
92
+ proc.stdout.on('data', (data: Buffer) => {
93
+ stdout += data.toString()
94
+ })
95
+
96
+ proc.stderr.on('data', (data: Buffer) => {
97
+ stderr += data.toString()
98
+ })
99
+
100
+ const exitCode = await new Promise<number>(resolveExit => {
101
+ proc.on('close', resolveExit)
102
+ })
103
+
104
+ dbg('whisperSTT', 'subprocess exitCode', exitCode, 'stdout', stdout.slice(0, 200))
105
+ if (exitCode !== 0) {
106
+ callbacks.onError(`Whisper transcription failed: ${stderr || 'unknown error'}`, {
107
+ fatal: true,
108
+ })
109
+ callbacks.onClose()
110
+ return 'ws_close'
111
+ }
112
+
113
+ const result = JSON.parse(stdout)
114
+ if (result.success) {
115
+ callbacks.onTranscript(result.text, true)
116
+ } else {
117
+ callbacks.onError(
118
+ result.error ?? 'Transcription failed',
119
+ { fatal: true },
120
+ )
121
+ }
122
+ } catch (err) {
123
+ callbacks.onError(
124
+ `Whisper error: ${err instanceof Error ? err.message : String(err)}`,
125
+ { fatal: true },
126
+ )
127
+ } finally {
128
+ if (tmpDir) {
129
+ try {
130
+ unlinkSync(join(tmpDir, 'input.wav'))
131
+ } catch {
132
+ // ignore
133
+ }
134
+ try {
135
+ rmdirSync(tmpDir)
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+ callbacks.onClose()
141
+ }
142
+
143
+ return 'post_closestream_endpoint'
144
+ },
145
+
146
+ close() {
147
+ finalized = true
148
+ callbacks.onClose()
149
+ },
150
+
151
+ isConnected() {
152
+ return true
153
+ },
154
+ }
155
+
156
+ callbacks.onReady(connection)
157
+ resolve(connection)
158
+ })
159
+ }
160
+
161
+ /** Check if a local whisper-capable Python installation exists. */
162
+ export async function checkLocalWhisperAvailable(pythonPath?: string): Promise<boolean> {
163
+ const python = resolvePythonPath(pythonPath)
164
+ return new Promise(resolve => {
165
+ const proc = spawn(python, [
166
+ '-c',
167
+ 'import json;' +
168
+ 'try: from faster_whisper import WhisperModel; print(json.dumps({"ok": True, "backend": "faster-whisper"}))' +
169
+ 'except ImportError:' +
170
+ ' try: import whisper; print(json.dumps({"ok": True, "backend": "whisper"}))' +
171
+ ' except ImportError: print(json.dumps({"ok": False}))',
172
+ ], { stdio: ['ignore', 'pipe', 'pipe'] })
173
+
174
+ let stdout = ''
175
+ proc.stdout.on('data', (data: Buffer) => { stdout += data.toString() })
176
+ proc.on('close', () => {
177
+ try {
178
+ const result = JSON.parse(stdout)
179
+ resolve(result.ok === true)
180
+ } catch {
181
+ resolve(false)
182
+ }
183
+ })
184
+ })
185
+ }
186
+
187
+ /** Write a valid PCM WAV file header and data. */
188
+ function writeWavHeader(path: string, pcmData: Buffer, sampleRate: number): void {
189
+ const numChannels = 1
190
+ const bitsPerSample = 16
191
+ const byteRate = sampleRate * numChannels * (bitsPerSample / 8)
192
+ const blockAlign = numChannels * (bitsPerSample / 8)
193
+ const dataSize = pcmData.length
194
+
195
+ const header = Buffer.alloc(44)
196
+
197
+ // RIFF header
198
+ header.write('RIFF', 0)
199
+ header.writeUInt32LE(36 + dataSize, 4)
200
+ header.write('WAVE', 8)
201
+
202
+ // fmt chunk
203
+ header.write('fmt ', 12)
204
+ header.writeUInt32LE(16, 16) // chunk size
205
+ header.writeUInt16LE(1, 20) // PCM format
206
+ header.writeUInt16LE(numChannels, 22)
207
+ header.writeUInt32LE(sampleRate, 24)
208
+ header.writeUInt32LE(byteRate, 28)
209
+ header.writeUInt16LE(blockAlign, 32)
210
+ header.writeUInt16LE(bitsPerSample, 34)
211
+
212
+ // data chunk
213
+ header.write('data', 36)
214
+ header.writeUInt32LE(dataSize, 40)
215
+
216
+ writeFileSync(path, Buffer.concat([header, pcmData]))
217
+ }
src/utils/settings/types.ts CHANGED
@@ -868,10 +868,47 @@ export const SettingsSchema = lazySchema(() =>
868
  .optional()
869
  .describe('Enable voice mode (hold-to-talk dictation)'),
870
  voiceProvider: z
871
- .enum(['anthropic', 'doubao'])
872
  .optional()
873
  .describe(
874
- 'Voice STT backend: "anthropic" (default) or "doubao" (Doubao ASR)',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
875
  ),
876
  }
877
  : {}),
 
868
  .optional()
869
  .describe('Enable voice mode (hold-to-talk dictation)'),
870
  voiceProvider: z
871
+ .enum(['local', 'doubao', 'anthropic'])
872
  .optional()
873
  .describe(
874
+ 'Voice STT backend: "local" (whisper/whisper.cpp / faster-whisper) or "doubao" (Doubao ASR).'
875
+ + ' "anthropic" is a client-side compatibility alias.',
876
+ ),
877
+ voiceAutoTTS: z
878
+ .boolean()
879
+ .optional()
880
+ .describe('Automatically speak assistant replies in voice mode'),
881
+ voiceRecordKey: z
882
+ .string()
883
+ .optional()
884
+ .describe(
885
+ 'Keyboard shortcut for push-to-talk voice input (e.g. "Space" or "ctrl+shift+space")',
886
+ ),
887
+ voiceTTSProvider: z
888
+ .enum(['edge', 'openai', 'elevenlabs', 'command'])
889
+ .optional()
890
+ .describe('Text-to-speech backend for spoken replies'),
891
+ voiceTTSVoice: z
892
+ .string()
893
+ .optional()
894
+ .describe('Preferred TTS voice identifier (provider-specific)'),
895
+ voiceTTSCommand: z
896
+ .string()
897
+ .optional()
898
+ .describe(
899
+ 'Shell command template for command-based TTS. Supports {input}, {input_path}, and {output_path} placeholders.',
900
+ ),
901
+ voiceLanguage: z
902
+ .string()
903
+ .optional()
904
+ .describe(
905
+ 'Preferred language for voice dictation (e.g. "en", "ja", "zh-CN")',
906
+ ),
907
+ voiceModel: z
908
+ .string()
909
+ .optional()
910
+ .describe(
911
+ 'Optional whisper model or binary path override for local STT',
912
  ),
913
  }
914
  : {}),
src/voice/voiceModeEnabled.ts CHANGED
@@ -1,46 +1,19 @@
1
- import { feature } from 'bun:bundle'
2
- import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
3
- import {
4
- getClaudeAIOAuthTokens,
5
- isAnthropicAuthEnabled,
6
- } from '../utils/auth.js'
7
-
8
  /**
9
- * Kill-switch check for voice mode. Returns true unless the
10
- * `tengu_amber_quartz_disabled` GrowthBook flag is flipped on (emergency
11
- * off). Default `false` means a missing/stale disk cache reads as "not
12
- * killed" — so fresh installs get voice working immediately without
13
- * waiting for GrowthBook init. Use this for deciding whether voice mode
14
- * should be *visible* (e.g., command registration, config UI).
15
  */
16
  export function isVoiceGrowthBookEnabled(): boolean {
17
- // Positive ternary pattern — see docs/feature-gating.md.
18
- // Negative pattern (if (!feature(...)) return) does not eliminate
19
- // inline string literals from external builds.
20
- return feature('VOICE_MODE')
21
- ? !getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_quartz_disabled', false)
22
- : false
23
  }
24
 
25
  /**
26
- * Auth-only check for voice mode. Returns true when the user has a valid
27
- * Anthropic OAuth token. Backed by the memoized getClaudeAIOAuthTokens —
28
- * first call spawns `security` on macOS (~20-50ms), subsequent calls are
29
- * cache hits. The memoize clears on token refresh (~once/hour), so one
30
- * cold spawn per refresh is expected. Cheap enough for usage-time checks.
31
  */
32
  export function hasVoiceAuth(): boolean {
33
- // Voice mode requires Anthropic OAuth — it uses the voice_stream
34
- // endpoint on claude.ai which is not available with API keys,
35
- // Bedrock, Vertex, or Foundry.
36
- if (!isAnthropicAuthEnabled()) {
37
- return false
38
- }
39
- // isAnthropicAuthEnabled only checks the auth *provider*, not whether
40
- // a token exists. Without this check, the voice UI renders but
41
- // connectVoiceStream fails silently when the user isn't logged in.
42
- const tokens = getClaudeAIOAuthTokens()
43
- return Boolean(tokens?.accessToken)
44
  }
45
 
46
  /**
@@ -53,9 +26,8 @@ export function isVoiceModeEnabled(): boolean {
53
 
54
  /**
55
  * Check if voice mode can be activated with any STT backend.
56
- * Always returns true when VOICE_MODE feature flag is on and GrowthBook
57
- * kill-switch is off — the Doubao backend does not require Anthropic auth.
58
  */
59
  export function isVoiceAvailable(): boolean {
60
- return feature('VOICE_MODE')
61
- }
 
 
 
 
 
 
 
 
1
  /**
2
+ * Kill-switch check for voice mode. Bypassed in external builds —
3
+ * voice is always eligible pending other checks (mic, dependencies,
4
+ * STT backend).
 
 
 
5
  */
6
  export function isVoiceGrowthBookEnabled(): boolean {
7
+ return true
 
 
 
 
 
8
  }
9
 
10
  /**
11
+ * Auth-only check for voice mode. Returns true unconditionally in
12
+ * external builds — the Doubao ASR backend does not require Anthropic
13
+ * OAuth, and the GrowthBook kill-switch is already bypassed.
 
 
14
  */
15
  export function hasVoiceAuth(): boolean {
16
+ return true
 
 
 
 
 
 
 
 
 
 
17
  }
18
 
19
  /**
 
26
 
27
  /**
28
  * Check if voice mode can be activated with any STT backend.
29
+ * The Doubao backend does not require Anthropic auth.
 
30
  */
31
  export function isVoiceAvailable(): boolean {
32
+ return isVoiceGrowthBookEnabled()
33
+ }