chenbhao commited on
Commit
0db027e
·
1 Parent(s): 2cca3b7

feat: voice stt ok english

Browse files
scripts/transcribe.py CHANGED
@@ -1,8 +1,8 @@
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:
@@ -11,21 +11,25 @@ def write_wav(path: str, raw_pcm: bytes, sample_rate: int = 16000):
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:
@@ -36,18 +40,7 @@ def transcribe(wav_path: str, model_size: str = "base", language: str | None = N
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:
@@ -55,7 +48,7 @@ def main():
55
  sys.exit(1)
56
 
57
  wav_path = sys.argv[1]
58
- model_size = "base"
59
  language = None
60
 
61
  i = 2
@@ -83,4 +76,4 @@ def main():
83
  print(json.dumps(result))
84
 
85
  if __name__ == "__main__":
86
- main()
 
1
+ import json, os, sys, tempfile, wave
2
 
3
  WAV_HEADER_SIZE = 44
4
 
5
+ WHISPER_CACHE = os.path.expanduser("~/.cache/whisper")
6
 
7
  def write_wav(path: str, raw_pcm: bytes, sample_rate: int = 16000):
8
  with wave.open(path, 'wb') as w:
 
11
  w.setframerate(sample_rate)
12
  w.writeframes(raw_pcm)
13
 
14
+ def transcribe(wav_path: str, model_size: str = "large-v3-turbo", language: str | None = None) -> dict:
15
+ try:
16
+ import whisper
17
+ model = whisper.load_model(model_size, download_root=WHISPER_CACHE)
18
+ opts = {}
19
+ if language:
20
+ opts["language"] = language
21
+ result = model.transcribe(wav_path, **opts)
22
+ return {"success": True, "text": result["text"].strip(), "language": result.get("language", "")}
23
+ except ImportError:
24
+ pass
25
 
 
 
 
 
 
26
  try:
27
  from faster_whisper import WhisperModel
28
+ model_path = model_size
29
+ if not os.path.isdir(model_path):
30
+ local_path = os.path.expanduser(f"~/.cache/huggingface/hub/models--Systran--faster-whisper-{model_size}")
31
+ if os.path.isdir(local_path):
32
+ model_path = local_path
33
  model = WhisperModel(model_path, device="cpu", compute_type="int8")
34
  opts = {"beam_size": 1}
35
  if language:
 
40
  except ImportError:
41
  pass
42
 
43
+ return {"success": False, "error": "Neither openai-whisper nor faster-whisper is installed. Run: pip install openai-whisper"}
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  def main():
46
  if len(sys.argv) < 2:
 
48
  sys.exit(1)
49
 
50
  wav_path = sys.argv[1]
51
+ model_size = "large-v3-turbo"
52
  language = None
53
 
54
  i = 2
 
76
  print(json.dumps(result))
77
 
78
  if __name__ == "__main__":
79
+ main()
src/hooks/useVoice.ts CHANGED
@@ -89,6 +89,9 @@ const LANGUAGE_NAME_TO_CODE: Record<string, string> = {
89
  svenska: 'sv',
90
  norwegian: 'no',
91
  norsk: 'no',
 
 
 
92
  }
93
 
94
  // Subset of the GrowthBook speech_to_text_voice_stream_config allowlist.
@@ -114,6 +117,7 @@ const SUPPORTED_LANGUAGE_CODES = new Set([
114
  'da',
115
  'sv',
116
  'no',
 
117
  ])
118
 
119
  // Normalize a language preference string (from settings.language) to a
 
89
  svenska: 'sv',
90
  norwegian: 'no',
91
  norsk: 'no',
92
+ chinese: 'zh',
93
+ 中文: 'zh',
94
+ 汉语: 'zh',
95
  }
96
 
97
  // Subset of the GrowthBook speech_to_text_voice_stream_config allowlist.
 
117
  'da',
118
  'sv',
119
  'no',
120
+ 'zh',
121
  ])
122
 
123
  // Normalize a language preference string (from settings.language) to a
src/services/voice/edgeTTS.ts CHANGED
@@ -1,14 +1,29 @@
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 = {
 
1
  import { spawn } from 'child_process'
2
  import { existsSync } from 'fs'
3
  import { tmpdir } from 'os'
4
+ import { join, dirname } from 'path'
5
 
6
+ function findProjectRoot(): string {
7
+ let dir = process.cwd()
8
+ for (let i = 0; i < 20; i++) {
9
+ if (existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'scripts'))) {
10
+ return dir
11
+ }
12
+ const parent = dirname(dir)
13
+ if (parent === dir) break
14
+ dir = parent
15
+ }
16
+ return process.cwd()
17
+ }
18
+
19
+ const PROJECT_ROOT = findProjectRoot()
20
+ const SCRIPTS_DIR = join(PROJECT_ROOT, 'scripts')
21
+ const VENV_PYTHON = join(PROJECT_ROOT, '.venv', 'bin', 'python')
22
 
23
  function resolvePythonPath(customPath?: string): string {
24
  if (customPath) return customPath
25
+ if (existsSync(VENV_PYTHON)) return VENV_PYTHON
26
+ return 'python3'
27
  }
28
 
29
  export type TTSResult = {
src/services/voice/whisperSTT.ts CHANGED
@@ -1,21 +1,36 @@
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 = {
@@ -24,16 +39,6 @@ type WhisperOptions = {
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,
@@ -44,34 +49,30 @@ export function connectLocalWhisperStream(
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-'))
@@ -83,6 +84,7 @@ export function connectLocalWhisperStream(
83
  if (language) {
84
  args.push('--language', language)
85
  }
 
86
 
87
  const proc = spawn(python, args, { stdio: ['ignore', 'pipe', 'pipe'] })
88
 
@@ -101,7 +103,8 @@ export function connectLocalWhisperStream(
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,
@@ -111,8 +114,11 @@ export function connectLocalWhisperStream(
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',
@@ -128,14 +134,10 @@ export function connectLocalWhisperStream(
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
  }
@@ -158,16 +160,15 @@ export function connectLocalWhisperStream(
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
 
@@ -184,7 +185,6 @@ export async function checkLocalWhisperAvailable(pythonPath?: string): Promise<b
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
@@ -194,22 +194,19 @@ function writeWavHeader(path: string, pcmData: Buffer, sampleRate: number): void
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
 
 
1
  import { spawn } from 'child_process'
2
+ import { existsSync, mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from 'fs'
3
  import { tmpdir } from 'os'
4
+ import { join, dirname } from 'path'
5
  import type { VoiceStreamCallbacks, VoiceStreamConnection, FinalizeSource } from '../voiceStreamSTT.js'
6
 
7
+ function findProjectRoot(): string {
8
+ let dir = process.cwd()
9
+ for (let i = 0; i < 20; i++) {
10
+ if (existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'scripts'))) {
11
+ return dir
12
+ }
13
+ const parent = dirname(dir)
14
+ if (parent === dir) break
15
+ dir = parent
16
+ }
17
+ return process.cwd()
18
+ }
19
+
20
+ const PROJECT_ROOT = findProjectRoot()
21
+ const SCRIPTS_DIR = join(PROJECT_ROOT, 'scripts')
22
+ const VENV_PYTHON = join(PROJECT_ROOT, '.venv', 'bin', 'python')
23
+
24
+ const LOG = '/tmp/vc_whisper.log'
25
+ function log(...args: unknown[]) {
26
+ const line = `[${new Date().toISOString()}] ${args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')}\n`
27
+ try { writeFileSync(LOG, line, { flag: 'a' }) } catch {}
28
  }
29
 
30
  function resolvePythonPath(customPath?: string): string {
31
  if (customPath) return customPath
32
+ if (existsSync(VENV_PYTHON)) return VENV_PYTHON
33
+ return 'python3'
34
  }
35
 
36
  type WhisperOptions = {
 
39
  pythonPath?: string
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
42
  export function connectLocalWhisperStream(
43
  callbacks: VoiceStreamCallbacks,
44
  options?: WhisperOptions,
 
49
  let tmpDir: string | null = null
50
 
51
  const python = resolvePythonPath(options?.pythonPath)
52
+ const model = options?.model ?? 'large-v3-turbo'
53
  const language = options?.language
54
+ log('projectRoot', PROJECT_ROOT, 'python', python, 'model', model, 'language', language)
 
55
 
56
  const connection: VoiceStreamConnection = {
57
  send(chunk: Buffer) {
58
  if (finalized) return
59
  chunks.push(Buffer.from(chunk))
60
+ if (chunks.length === 1) log('first chunk', chunk.length, 'bytes')
 
 
61
  },
62
 
63
  async finalize(): Promise<FinalizeSource> {
64
  if (finalized) return 'ws_already_closed'
65
  finalized = true
66
+ log('finalize called, chunks', chunks.length)
 
67
 
68
  if (chunks.length === 0) {
69
+ log('no chunks, returning no_data_timeout')
70
  callbacks.onClose()
71
  return 'no_data_timeout'
72
  }
73
 
74
  const audioBuf = Buffer.concat(chunks)
75
+ log('total audio', audioBuf.length, 'bytes')
76
 
77
  try {
78
  tmpDir = mkdtempSync(join(tmpdir(), 'vc-whisper-'))
 
84
  if (language) {
85
  args.push('--language', language)
86
  }
87
+ log('spawning', python, args.join(' '))
88
 
89
  const proc = spawn(python, args, { stdio: ['ignore', 'pipe', 'pipe'] })
90
 
 
103
  proc.on('close', resolveExit)
104
  })
105
 
106
+ log('exitCode', exitCode, 'stdout', stdout.slice(0, 500), 'stderr', stderr.slice(0, 500))
107
+
108
  if (exitCode !== 0) {
109
  callbacks.onError(`Whisper transcription failed: ${stderr || 'unknown error'}`, {
110
  fatal: true,
 
114
  }
115
 
116
  const result = JSON.parse(stdout)
117
+ log('result', result)
118
+ if (result.success && result.text) {
119
  callbacks.onTranscript(result.text, true)
120
+ } else if (result.success && !result.text) {
121
+ callbacks.onTranscript('', true)
122
  } else {
123
  callbacks.onError(
124
  result.error ?? 'Transcription failed',
 
134
  if (tmpDir) {
135
  try {
136
  unlinkSync(join(tmpDir, 'input.wav'))
137
+ } catch {}
 
 
138
  try {
139
  rmdirSync(tmpDir)
140
+ } catch {}
 
 
141
  }
142
  callbacks.onClose()
143
  }
 
160
  })
161
  }
162
 
 
163
  export async function checkLocalWhisperAvailable(pythonPath?: string): Promise<boolean> {
164
  const python = resolvePythonPath(pythonPath)
165
  return new Promise(resolve => {
166
  const proc = spawn(python, [
167
  '-c',
168
  'import json;' +
169
+ 'try: import whisper; print(json.dumps({"ok": True, "backend": "whisper"}))' +
170
  'except ImportError:' +
171
+ ' try: from faster_whisper import WhisperModel; print(json.dumps({"ok": True, "backend": "faster-whisper"}))' +
172
  ' except ImportError: print(json.dumps({"ok": False}))',
173
  ], { stdio: ['ignore', 'pipe', 'pipe'] })
174
 
 
185
  })
186
  }
187
 
 
188
  function writeWavHeader(path: string, pcmData: Buffer, sampleRate: number): void {
189
  const numChannels = 1
190
  const bitsPerSample = 16
 
194
 
195
  const header = Buffer.alloc(44)
196
 
 
197
  header.write('RIFF', 0)
198
  header.writeUInt32LE(36 + dataSize, 4)
199
  header.write('WAVE', 8)
200
 
 
201
  header.write('fmt ', 12)
202
+ header.writeUInt32LE(16, 16)
203
+ header.writeUInt16LE(1, 20)
204
  header.writeUInt16LE(numChannels, 22)
205
  header.writeUInt32LE(sampleRate, 24)
206
  header.writeUInt32LE(byteRate, 28)
207
  header.writeUInt16LE(blockAlign, 32)
208
  header.writeUInt16LE(bitsPerSample, 34)
209
 
 
210
  header.write('data', 36)
211
  header.writeUInt32LE(dataSize, 40)
212