feat: voice add fully-local STT (faster-whisper) + TTS (edge-tts)
Browse files- .gitignore +13 -1
- .python-version +1 -0
- desktop/sidecars/omni-adapter.ts +12 -2
- scripts/build.ts +5 -0
- scripts/speak.py +42 -0
- scripts/transcribe.py +86 -0
- src/commands/voice/voice.ts +9 -39
- src/components/Messages.tsx +3 -0
- src/hooks/useAutoTTS.ts +38 -0
- src/hooks/useVoice.ts +65 -41
- src/services/voice/edgeTTS.ts +140 -0
- src/services/voice/providers.ts +292 -0
- src/services/voice/whisperSTT.ts +217 -0
- src/utils/settings/types.ts +39 -2
- src/voice/voiceModeEnabled.ts +11 -39
.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/
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = (
|
| 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
|
| 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
|
| 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 —
|
| 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
|
| 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
|
| 107 |
} else if (process.platform === 'linux') {
|
| 108 |
guidance = "your system's audio settings"
|
| 109 |
} else {
|
| 110 |
-
guidance = 'System Settings
|
| 111 |
}
|
| 112 |
return {
|
| 113 |
type: 'text' as const,
|
|
@@ -115,10 +88,10 @@ export const call: LocalCommandCall = async () => {
|
|
| 115 |
}
|
| 116 |
}
|
| 117 |
|
| 118 |
-
//
|
| 119 |
const result = updateSettingsForSource('userSettings', {
|
| 120 |
voiceEnabled: true,
|
| 121 |
-
|
| 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}
|
| 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 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
})
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
|
|
|
|
|
|
|
|
|
| 797 |
void connectFn(
|
| 798 |
{
|
| 799 |
onTranscript: (text: string, isFinal: boolean) => {
|
|
@@ -1000,12 +1013,21 @@ export function useVoice({
|
|
| 1000 |
return
|
| 1001 |
}
|
| 1002 |
if (!conn) {
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 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
|
| 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 =
|
| 1046 |
-
?
|
| 1047 |
-
:
|
|
|
|
|
|
|
| 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(['
|
| 872 |
.optional()
|
| 873 |
.describe(
|
| 874 |
-
'Voice STT backend: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 10 |
-
*
|
| 11 |
-
*
|
| 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 |
-
|
| 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
|
| 27 |
-
*
|
| 28 |
-
*
|
| 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 |
-
|
| 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 |
-
*
|
| 57 |
-
* kill-switch is off — the Doubao backend does not require Anthropic auth.
|
| 58 |
*/
|
| 59 |
export function isVoiceAvailable(): boolean {
|
| 60 |
-
return
|
| 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 |
+
}
|