@gotcos/glasses-server 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Token audit logger — zero-cost file I/O tracking for all claude -p calls.
|
|
2
|
+
// Both Python (COS scripts) and TypeScript (G2 glasses) write to the same JSONL.
|
|
3
|
+
// No LLM calls. Pure file append.
|
|
4
|
+
|
|
5
|
+
import { appendFileSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
|
|
9
|
+
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
10
|
+
|
|
11
|
+
// Shared JSONL file — same path as Python's llm_client.py uses
|
|
12
|
+
// Uses COS_SCRIPTS_DIR when available (server mode), falls back to user home
|
|
13
|
+
const AUDIT_FILE = COS_SCRIPTS_DIR
|
|
14
|
+
? resolve(COS_SCRIPTS_DIR, '.cos_token_audit.jsonl')
|
|
15
|
+
: resolve(homedir(), '.cos-glasses', 'token-audit.jsonl')
|
|
16
|
+
|
|
17
|
+
// Rough char-to-token ratio (1 token ~ 4 chars for English)
|
|
18
|
+
const CHARS_PER_TOKEN = 4
|
|
19
|
+
|
|
20
|
+
function estimateTokens(chars: number): number {
|
|
21
|
+
return Math.max(1, Math.floor(chars / CHARS_PER_TOKEN))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TokenAuditEntry {
|
|
25
|
+
source: string // "g2-voice", "g2-prewarm", "g2-archive", "g2-query"
|
|
26
|
+
model: string // "opus", "sonnet", "haiku"
|
|
27
|
+
inputChars: number
|
|
28
|
+
outputChars: number
|
|
29
|
+
durationMs: number
|
|
30
|
+
caller: string // "voice_query", "prewarm", "chat_summary", "day_summary"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function logTokenAudit(entry: TokenAuditEntry): void {
|
|
34
|
+
const record = {
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
source: entry.source,
|
|
37
|
+
model: entry.model,
|
|
38
|
+
input_chars: entry.inputChars,
|
|
39
|
+
output_chars: entry.outputChars,
|
|
40
|
+
est_input_tokens: estimateTokens(entry.inputChars),
|
|
41
|
+
est_output_tokens: estimateTokens(entry.outputChars),
|
|
42
|
+
duration_ms: entry.durationMs,
|
|
43
|
+
caller: entry.caller,
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n')
|
|
47
|
+
} catch {
|
|
48
|
+
// Never let logging break the actual call
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isWhisperLocalAvailable,
|
|
3
|
+
transcribeLocal,
|
|
4
|
+
transcribeHighQuality,
|
|
5
|
+
getWhisperBackend,
|
|
6
|
+
} from './whisper-local.js'
|
|
7
|
+
import { getVocabulary, getOwnerName } from './profile.js'
|
|
8
|
+
import { applyFuzzyCorrections } from './fuzzy-correct.js'
|
|
9
|
+
import { getAllSpeakerNames } from './speaker-embeddings.js'
|
|
10
|
+
import {
|
|
11
|
+
assertOpenAIWhisperBudget,
|
|
12
|
+
recordOpenAIWhisperUsage,
|
|
13
|
+
estimateAudioSeconds,
|
|
14
|
+
OpenAIWhisperBudgetExhaustedError,
|
|
15
|
+
} from './openai-whisper-budget.js'
|
|
16
|
+
import { enhanceAudio } from './audio-enhance.js'
|
|
17
|
+
import {
|
|
18
|
+
stripInlineHallucinationsOneShot,
|
|
19
|
+
isFullHallucination,
|
|
20
|
+
} from './hallucination-filter.js'
|
|
21
|
+
import { getOpenAIKey } from './openai-key.js'
|
|
22
|
+
|
|
23
|
+
export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
|
|
24
|
+
|
|
25
|
+
export type TranscribeMode = 'hq' | 'fast'
|
|
26
|
+
|
|
27
|
+
export interface TranscribeAudioResult {
|
|
28
|
+
text: string
|
|
29
|
+
backend: string
|
|
30
|
+
mode: TranscribeMode
|
|
31
|
+
elapsedMs: number
|
|
32
|
+
audioBytes: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class NoSpeechDetectedError extends Error {
|
|
36
|
+
readonly reason = 'no_speech'
|
|
37
|
+
|
|
38
|
+
constructor(readonly rawText = '') {
|
|
39
|
+
super('No speech detected')
|
|
40
|
+
this.name = 'NoSpeechDetectedError'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// HQ clips > this fall back to fast mode — large-v3 latency scales roughly linearly
|
|
45
|
+
// with audio length and beam-search × best-of amplifies that. 60s is the user-perceived
|
|
46
|
+
// ceiling (anything longer is a dictation, not a query — use meetings instead).
|
|
47
|
+
const HQ_MAX_SECONDS = 60
|
|
48
|
+
|
|
49
|
+
/** Transcribe via OpenAI Whisper API (cloud fallback).
|
|
50
|
+
* Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
|
|
51
|
+
* Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
|
|
52
|
+
* aren't double-counted. */
|
|
53
|
+
async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
|
|
54
|
+
assertOpenAIWhisperBudget()
|
|
55
|
+
|
|
56
|
+
const key = getOpenAIKey()
|
|
57
|
+
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
58
|
+
|
|
59
|
+
// Detect format from magic bytes.
|
|
60
|
+
const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
|
|
61
|
+
const filename = isWav ? 'recording.wav' : 'recording.webm'
|
|
62
|
+
const mimeType = isWav ? 'audio/wav' : 'audio/webm'
|
|
63
|
+
|
|
64
|
+
const boundary = '----COS' + Date.now()
|
|
65
|
+
const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mimeType}\r\n\r\n`
|
|
66
|
+
const modelPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1`
|
|
67
|
+
const languagePart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="language"\r\n\r\nen`
|
|
68
|
+
const vocab = getVocabulary()
|
|
69
|
+
const cloudPrompt = vocab.length > 0
|
|
70
|
+
? [getOwnerName(), ...vocab].join(', ')
|
|
71
|
+
: `${getOwnerName()}, COS Glasses, Even G2`
|
|
72
|
+
const promptPart = `\r\n--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\n${cloudPrompt}`
|
|
73
|
+
const footer = `\r\n--${boundary}--\r\n`
|
|
74
|
+
|
|
75
|
+
const body = Buffer.concat([
|
|
76
|
+
Buffer.from(header),
|
|
77
|
+
audioBuffer,
|
|
78
|
+
Buffer.from(modelPart),
|
|
79
|
+
Buffer.from(languagePart),
|
|
80
|
+
Buffer.from(promptPart),
|
|
81
|
+
Buffer.from(footer),
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
'Authorization': `Bearer ${key}`,
|
|
88
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
89
|
+
},
|
|
90
|
+
body,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const errText = await response.text()
|
|
95
|
+
throw new Error(`Whisper API ${response.status}: ${errText.slice(0, 200)}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = await response.json() as { text: string }
|
|
99
|
+
recordOpenAIWhisperUsage(audioSeconds)
|
|
100
|
+
return result.text
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resolveTranscribeMode(raw: unknown): TranscribeMode {
|
|
104
|
+
return String(raw ?? '').toLowerCase() === 'fast' ? 'fast' : 'hq'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function transcribeAudioBuffer(audioBuffer: Buffer, opts: { mode?: TranscribeMode } = {}): Promise<TranscribeAudioResult> {
|
|
108
|
+
const requestedMode = opts.mode ?? 'hq'
|
|
109
|
+
const audioSeconds = estimateAudioSeconds(audioBuffer)
|
|
110
|
+
const effectiveMode: TranscribeMode =
|
|
111
|
+
requestedMode === 'hq' && audioSeconds > HQ_MAX_SECONDS ? 'fast' : requestedMode
|
|
112
|
+
|
|
113
|
+
if (effectiveMode !== requestedMode) {
|
|
114
|
+
console.log(`[transcribe] mode downgrade: hq → fast (audio ${audioSeconds.toFixed(1)}s > cap ${HQ_MAX_SECONDS}s)`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let text: string
|
|
118
|
+
let backend: string
|
|
119
|
+
const tStart = performance.now()
|
|
120
|
+
|
|
121
|
+
if (effectiveMode === 'hq' && isWhisperLocalAvailable()) {
|
|
122
|
+
try {
|
|
123
|
+
const enhanced = await enhanceAudio(audioBuffer)
|
|
124
|
+
const result = await transcribeHighQuality(enhanced)
|
|
125
|
+
text = result.text
|
|
126
|
+
backend = 'hq-large-v3'
|
|
127
|
+
} catch (hqErr: any) {
|
|
128
|
+
console.warn(`[transcribe] HQ path failed, falling back to fast: ${hqErr.message}`)
|
|
129
|
+
try {
|
|
130
|
+
const result = await transcribeLocal(audioBuffer)
|
|
131
|
+
text = result.text
|
|
132
|
+
backend = `fast-local-${result.backend}`
|
|
133
|
+
} catch (localErr: any) {
|
|
134
|
+
console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
|
|
135
|
+
text = await transcribeCloud(audioBuffer)
|
|
136
|
+
backend = 'cloud'
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} else if (effectiveMode === 'fast' && isWhisperLocalAvailable()) {
|
|
140
|
+
try {
|
|
141
|
+
const result = await transcribeLocal(audioBuffer)
|
|
142
|
+
text = result.text
|
|
143
|
+
backend = `fast-local-${result.backend}`
|
|
144
|
+
} catch (localErr: any) {
|
|
145
|
+
console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
|
|
146
|
+
text = await transcribeCloud(audioBuffer)
|
|
147
|
+
backend = 'cloud'
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
text = await transcribeCloud(audioBuffer)
|
|
151
|
+
backend = 'cloud'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (text && text.length > 0) {
|
|
155
|
+
try {
|
|
156
|
+
text = stripInlineHallucinationsOneShot(text)
|
|
157
|
+
} catch (stripErr: any) {
|
|
158
|
+
console.warn(`[transcribe] Hallucination strip failed (non-fatal): ${stripErr.message}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (text && text.length > 0) {
|
|
163
|
+
try {
|
|
164
|
+
const fuzzyTargets = [...getAllSpeakerNames(), ...getVocabulary()]
|
|
165
|
+
const { text: corrected, replacements } = applyFuzzyCorrections(text, fuzzyTargets)
|
|
166
|
+
if (replacements > 0) {
|
|
167
|
+
console.log(`[transcribe] Fuzzy corrected ${replacements} word(s)`)
|
|
168
|
+
text = corrected
|
|
169
|
+
}
|
|
170
|
+
} catch (fuzzyErr: any) {
|
|
171
|
+
console.warn(`[transcribe] Fuzzy correction failed (non-fatal): ${fuzzyErr.message}`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const elapsedMs = performance.now() - tStart
|
|
176
|
+
if (!text || isFullHallucination(text)) {
|
|
177
|
+
throw new NoSpeechDetectedError(text || '')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
text: text.trim(),
|
|
182
|
+
backend,
|
|
183
|
+
mode: effectiveMode,
|
|
184
|
+
elapsedMs,
|
|
185
|
+
audioBytes: audioBuffer.length,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Silero VAD — trims silence from audio chunks before Whisper inference.
|
|
2
|
+
// Uses sherpa-onnx-node's built-in Silero VAD (same package as speaker embeddings).
|
|
3
|
+
// Graceful fallback: if model missing or init fails, returns audio unchanged.
|
|
4
|
+
//
|
|
5
|
+
// Architecture: client keeps RMS-based chunking (decides WHEN to flush).
|
|
6
|
+
// Server receives chunk, runs Silero VAD to extract speech segments,
|
|
7
|
+
// trims silence edges, sends only speech to Whisper.
|
|
8
|
+
|
|
9
|
+
import { resolve } from 'node:path'
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
// sherpa-onnx-node is CJS — use createRequire for ESM compat
|
|
14
|
+
import { createRequire } from 'node:module'
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
|
|
17
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
18
|
+
const MODEL_PATH = resolve(__dirname, '..', 'models', 'silero_vad.onnx')
|
|
19
|
+
|
|
20
|
+
const SAMPLE_RATE = 16000
|
|
21
|
+
const BYTES_PER_SAMPLE = 2 // 16-bit PCM
|
|
22
|
+
const WAV_HEADER_SIZE = 44
|
|
23
|
+
// Minimum samples for Silero VAD to produce meaningful results (512 = one window)
|
|
24
|
+
const MIN_SAMPLES = 512
|
|
25
|
+
|
|
26
|
+
// Module-level state — single Vad instance reused via reset() (~11ms vs 45ms per-call)
|
|
27
|
+
let sherpaOnnx: any = null
|
|
28
|
+
let available = false
|
|
29
|
+
let vadInstance: any = null
|
|
30
|
+
|
|
31
|
+
export interface TrimResult {
|
|
32
|
+
trimmedWav: Buffer
|
|
33
|
+
speechRatio: number
|
|
34
|
+
segments: Array<{ startSample: number; sampleCount: number }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Initialize Silero VAD. Returns true if model loaded, false otherwise. */
|
|
38
|
+
export function initSileroVAD(): boolean {
|
|
39
|
+
if (!existsSync(MODEL_PATH)) {
|
|
40
|
+
console.log('[silero-vad] Model not found at', MODEL_PATH, '— VAD disabled, audio passes through untrimmed')
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
sherpaOnnx = require('sherpa-onnx-node')
|
|
46
|
+
// Create the reusable Vad instance (kept alive for server lifetime)
|
|
47
|
+
// bufferSizeInSeconds: 10 — chunks are max 6s, no need for 60s buffer
|
|
48
|
+
vadInstance = new sherpaOnnx.Vad(
|
|
49
|
+
{
|
|
50
|
+
sileroVad: { model: MODEL_PATH, threshold: 0.5, minSilenceDuration: 0.3, minSpeechDuration: 0.25, windowSize: 512 },
|
|
51
|
+
sampleRate: SAMPLE_RATE,
|
|
52
|
+
numThreads: 1,
|
|
53
|
+
provider: 'cpu',
|
|
54
|
+
},
|
|
55
|
+
10, // bufferSizeInSeconds — chunks are max 6s
|
|
56
|
+
)
|
|
57
|
+
vadInstance.reset() // verify it works
|
|
58
|
+
available = true
|
|
59
|
+
console.log('[silero-vad] Initialized: Silero VAD active (reused instance, bufferSize=10s)')
|
|
60
|
+
return true
|
|
61
|
+
} catch (err: any) {
|
|
62
|
+
console.error('[silero-vad] Init failed:', err.message)
|
|
63
|
+
available = false
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Check if Silero VAD is available */
|
|
69
|
+
export function isSileroAvailable(): boolean {
|
|
70
|
+
return available
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Trim silence from a WAV buffer using Silero VAD.
|
|
75
|
+
* Returns the trimmed WAV + speech ratio for logging.
|
|
76
|
+
*
|
|
77
|
+
* Graceful: if anything fails, returns original buffer unchanged.
|
|
78
|
+
*/
|
|
79
|
+
export function trimSilence(wavBuffer: Buffer): TrimResult {
|
|
80
|
+
const fallback: TrimResult = { trimmedWav: wavBuffer, speechRatio: 0.0, segments: [] }
|
|
81
|
+
|
|
82
|
+
if (!available || !vadInstance) return fallback
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// Parse WAV: extract raw PCM from after the 44-byte header
|
|
86
|
+
if (wavBuffer.length <= WAV_HEADER_SIZE + MIN_SAMPLES * BYTES_PER_SAMPLE) {
|
|
87
|
+
// Audio too short for VAD — return unchanged
|
|
88
|
+
return fallback
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Safe Int16Array extraction — slice guarantees alignment
|
|
92
|
+
const pcmBytes = wavBuffer.buffer.slice(
|
|
93
|
+
wavBuffer.byteOffset + WAV_HEADER_SIZE,
|
|
94
|
+
wavBuffer.byteOffset + wavBuffer.length,
|
|
95
|
+
)
|
|
96
|
+
const int16 = new Int16Array(pcmBytes)
|
|
97
|
+
|
|
98
|
+
if (int16.length < MIN_SAMPLES) return fallback
|
|
99
|
+
|
|
100
|
+
// Convert Int16 → Float32 (Silero expects [-1, 1] range)
|
|
101
|
+
const float32 = new Float32Array(int16.length)
|
|
102
|
+
for (let i = 0; i < int16.length; i++) {
|
|
103
|
+
float32[i] = int16[i] / 32768
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Reuse module-level Vad instance — reset() clears state between calls
|
|
107
|
+
// Benchmark: 11ms avg vs 45ms with per-call construction
|
|
108
|
+
vadInstance.reset()
|
|
109
|
+
vadInstance.acceptWaveform(float32)
|
|
110
|
+
vadInstance.flush()
|
|
111
|
+
|
|
112
|
+
// Collect speech segments
|
|
113
|
+
const speechSegments: Array<{ startSample: number; samples: Float32Array }> = []
|
|
114
|
+
let totalSpeechSamples = 0
|
|
115
|
+
|
|
116
|
+
while (!vadInstance.isEmpty()) {
|
|
117
|
+
const segment = vadInstance.front()
|
|
118
|
+
speechSegments.push({ startSample: segment.start, samples: segment.samples })
|
|
119
|
+
totalSpeechSamples += segment.samples.length
|
|
120
|
+
vadInstance.pop()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const speechRatio = int16.length > 0 ? totalSpeechSamples / int16.length : 0.0
|
|
124
|
+
|
|
125
|
+
if (speechSegments.length === 0 || totalSpeechSamples === 0) {
|
|
126
|
+
// No speech detected — return original unchanged
|
|
127
|
+
return { trimmedWav: wavBuffer, speechRatio: 0.0, segments: [] }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Concatenate speech segments into a single Float32Array
|
|
131
|
+
const speechFloat32 = new Float32Array(totalSpeechSamples)
|
|
132
|
+
let offset = 0
|
|
133
|
+
const segmentInfo: TrimResult['segments'] = []
|
|
134
|
+
for (const seg of speechSegments) {
|
|
135
|
+
speechFloat32.set(seg.samples, offset)
|
|
136
|
+
segmentInfo.push({ startSample: seg.startSample, sampleCount: seg.samples.length })
|
|
137
|
+
offset += seg.samples.length
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Convert Float32 back to Int16 PCM
|
|
141
|
+
const speechInt16 = new Int16Array(totalSpeechSamples)
|
|
142
|
+
for (let i = 0; i < totalSpeechSamples; i++) {
|
|
143
|
+
// Clamp to [-1, 1] then scale to Int16 range
|
|
144
|
+
const clamped = Math.max(-1, Math.min(1, speechFloat32[i]))
|
|
145
|
+
speechInt16[i] = clamped < 0 ? clamped * 32768 : clamped * 32767
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pcmLength = speechInt16.length * BYTES_PER_SAMPLE
|
|
149
|
+
const trimmedWav = Buffer.alloc(WAV_HEADER_SIZE + pcmLength)
|
|
150
|
+
|
|
151
|
+
// Write WAV header (same format as audio-pipeline.ts pcmToWav)
|
|
152
|
+
trimmedWav.write('RIFF', 0)
|
|
153
|
+
trimmedWav.writeUInt32LE(WAV_HEADER_SIZE - 8 + pcmLength, 4)
|
|
154
|
+
trimmedWav.write('WAVE', 8)
|
|
155
|
+
trimmedWav.write('fmt ', 12)
|
|
156
|
+
trimmedWav.writeUInt32LE(16, 16) // sub-chunk size
|
|
157
|
+
trimmedWav.writeUInt16LE(1, 20) // PCM format
|
|
158
|
+
trimmedWav.writeUInt16LE(1, 22) // mono
|
|
159
|
+
trimmedWav.writeUInt32LE(SAMPLE_RATE, 24)
|
|
160
|
+
trimmedWav.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE, 28) // byte rate
|
|
161
|
+
trimmedWav.writeUInt16LE(BYTES_PER_SAMPLE, 32) // block align
|
|
162
|
+
trimmedWav.writeUInt16LE(16, 34) // bits per sample
|
|
163
|
+
trimmedWav.write('data', 36)
|
|
164
|
+
trimmedWav.writeUInt32LE(pcmLength, 40)
|
|
165
|
+
|
|
166
|
+
// Copy PCM data after header
|
|
167
|
+
Buffer.from(speechInt16.buffer).copy(trimmedWav, WAV_HEADER_SIZE)
|
|
168
|
+
|
|
169
|
+
// Guard: if trimmed result is too small for Whisper, fall back to original
|
|
170
|
+
if (trimmedWav.length < 100) {
|
|
171
|
+
return { trimmedWav: wavBuffer, speechRatio, segments: segmentInfo }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { trimmedWav, speechRatio, segments: segmentInfo }
|
|
175
|
+
} catch (err: any) {
|
|
176
|
+
console.error('[silero-vad] trimSilence error:', err.message)
|
|
177
|
+
return fallback
|
|
178
|
+
}
|
|
179
|
+
}
|