@daniel156161/prism 0.2.92 → 0.2.97
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/LICENSE +21 -0
- package/README.md +386 -181
- package/dist/pi/pi-context-files.d.ts +6 -0
- package/dist/pi/pi-context-files.js +17 -0
- package/dist/pi/pi-context-files.js.map +1 -0
- package/dist/pi/pi-extensions.d.ts +0 -1
- package/dist/pi/pi-extensions.js +7 -22
- package/dist/pi/pi-extensions.js.map +1 -1
- package/dist/pi/pi-npm-extension-entry.d.ts +2 -0
- package/dist/pi/pi-npm-extension-entry.js +74 -0
- package/dist/pi/pi-npm-extension-entry.js.map +1 -0
- package/dist/pi/pi-package.js +4 -0
- package/dist/pi/pi-package.js.map +1 -1
- package/dist/pi/pi-patch-context-files.d.ts +12 -0
- package/dist/pi/pi-patch-context-files.js +46 -0
- package/dist/pi/pi-patch-context-files.js.map +1 -0
- package/dist/pi/pi-patch-interactive.js +5 -1
- package/dist/pi/pi-patch-interactive.js.map +1 -1
- package/dist/pi/plan-mode-tools.d.ts +13 -0
- package/dist/pi/plan-mode-tools.js +54 -0
- package/dist/pi/plan-mode-tools.js.map +1 -0
- package/dist/prism-extensions/core/mex-anchor.d.ts +9 -0
- package/dist/prism-extensions/core/mex-anchor.js +29 -0
- package/dist/prism-extensions/core/mex-anchor.js.map +1 -0
- package/dist/prism-extensions/core/mex-cli.d.ts +68 -0
- package/dist/prism-extensions/core/mex-cli.js +183 -0
- package/dist/prism-extensions/core/mex-cli.js.map +1 -0
- package/dist/prism-extensions/core/mex-graph-db.d.ts +26 -0
- package/dist/prism-extensions/core/mex-graph-db.js +69 -0
- package/dist/prism-extensions/core/mex-graph-db.js.map +1 -0
- package/dist/prism-extensions/integrations/ai-memory-system.js +27 -6
- package/dist/prism-extensions/integrations/ai-memory-system.js.map +1 -1
- package/dist/prism-extensions/integrations/mex-memory.d.ts +21 -0
- package/dist/prism-extensions/integrations/mex-memory.js +231 -0
- package/dist/prism-extensions/integrations/mex-memory.js.map +1 -0
- package/dist/prism-extensions/tools/builtin-tools.js +2 -0
- package/dist/prism-extensions/tools/builtin-tools.js.map +1 -1
- package/dist/prism-extensions/tools/mex-tools.d.ts +2 -0
- package/dist/prism-extensions/tools/mex-tools.js +78 -0
- package/dist/prism-extensions/tools/mex-tools.js.map +1 -0
- package/dist/prism-extensions/tools/prism-diagnostic-tools.js +10 -10
- package/dist/prism-extensions/tools/prism-diagnostic-tools.js.map +1 -1
- package/dist/prism-extensions/tools/toolbox.js +1 -1
- package/dist/prism-extensions/tools/toolbox.js.map +1 -1
- package/dist/prism-extensions/ui/toolbar.d.ts +2 -0
- package/dist/prism-extensions/ui/toolbar.js +10 -1
- package/dist/prism-extensions/ui/toolbar.js.map +1 -1
- package/node_modules/@earendil-works/pi-coding-agent/dist/core/resource-loader.js +15 -0
- package/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js +6 -6
- package/package.json +17 -4
- package/src/prism-extensions/README.md +10 -1
- package/src/prism-extensions/core/mex-anchor.ts +29 -0
- package/src/prism-extensions/core/mex-cli.ts +217 -0
- package/src/prism-extensions/core/mex-graph-db.ts +76 -0
- package/src/prism-extensions/integrations/ai-memory-system.ts +30 -6
- package/src/prism-extensions/integrations/mex-memory.ts +264 -0
- package/src/prism-extensions/tools/builtin-tools.ts +2 -0
- package/src/prism-extensions/tools/mex-tools.ts +93 -0
- package/src/prism-extensions/tools/prism-diagnostic-tools.ts +10 -10
- package/src/prism-extensions/tools/toolbox.ts +1 -1
- package/src/prism-extensions/ui/toolbar.ts +10 -1
- package/src/prism-extensions/commands/voice-command.ts +0 -49
- package/src/prism-extensions/core/voice-runtime.ts +0 -322
- package/src/prism-extensions/core/voicebox-client.ts +0 -205
- package/src/prism-extensions/core/voicebox-service.ts +0 -137
- package/src/prism-extensions/integrations/honcho-memory.ts +0 -299
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs"
|
|
2
|
-
import * as os from "node:os"
|
|
3
|
-
import * as path from "node:path"
|
|
4
|
-
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
|
|
5
|
-
import { readSettings } from "./shared-config.js"
|
|
6
|
-
import { ensureVoiceboxServiceRunning, stopVoiceboxService } from "./voicebox-service.js"
|
|
7
|
-
import { isVoiceboxBackend, resolveVoiceboxConfig, streamSpeechWithVoicebox } from "./voicebox-client.js"
|
|
8
|
-
|
|
9
|
-
export type VoiceMode = "off" | "on" | "input" | "output"
|
|
10
|
-
|
|
11
|
-
export interface VoiceConfig {
|
|
12
|
-
wakeWord: string
|
|
13
|
-
sttCommand?: string
|
|
14
|
-
whisperModel?: string
|
|
15
|
-
ttsCommand?: string
|
|
16
|
-
piperModel?: string
|
|
17
|
-
audioPlayer?: string
|
|
18
|
-
loopCooldownMs: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface VoiceRuntimeOptions {
|
|
22
|
-
env?: NodeJS.ProcessEnv
|
|
23
|
-
sendUserMessage: (message: string) => void | Promise<void>
|
|
24
|
-
notify?: (message: string, level?: string) => void
|
|
25
|
-
setStatus?: (value: string | undefined) => void
|
|
26
|
-
spawnProcess?: typeof spawn
|
|
27
|
-
spawnSyncProcess?: typeof spawnSync
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function parseVoiceMode(input: string): VoiceMode | undefined {
|
|
31
|
-
const value = String(input || "").trim().toLowerCase()
|
|
32
|
-
if (!value || value === "status") return undefined
|
|
33
|
-
if (["on", "full", "both", "all"].includes(value)) return "on"
|
|
34
|
-
if (["off", "disable", "disabled", "stop"].includes(value)) return "off"
|
|
35
|
-
if (["input", "in", "listen", "mic"].includes(value)) return "input"
|
|
36
|
-
if (["output", "out", "speak", "tts"].includes(value)) return "output"
|
|
37
|
-
return undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function voiceStatus(mode: VoiceMode): string | undefined {
|
|
41
|
-
if (mode === "off") return undefined
|
|
42
|
-
if (mode === "on") return "🎙 voice:in+out"
|
|
43
|
-
return mode === "input" ? "🎙 voice:in" : "🔊 voice:out"
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function nonEmpty(value: unknown): string | undefined {
|
|
47
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function resolveVoiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
|
|
51
|
-
const settings = readSettings(undefined, env)?.voice ?? {}
|
|
52
|
-
return {
|
|
53
|
-
wakeWord: nonEmpty(env.PRISM_VOICE_WAKE_WORD) ?? nonEmpty(settings.wakeWord) ?? "prism",
|
|
54
|
-
sttCommand: nonEmpty(env.PRISM_VOICE_STT_COMMAND) ?? nonEmpty(settings.sttCommand),
|
|
55
|
-
whisperModel: nonEmpty(env.PRISM_VOICE_WHISPER_MODEL) ?? nonEmpty(settings.whisperModel),
|
|
56
|
-
ttsCommand: nonEmpty(env.PRISM_VOICE_TTS_COMMAND) ?? nonEmpty(settings.ttsCommand),
|
|
57
|
-
piperModel: nonEmpty(env.PRISM_VOICE_PIPER_MODEL) ?? nonEmpty(settings.piperModel),
|
|
58
|
-
audioPlayer: nonEmpty(env.PRISM_VOICE_AUDIO_PLAYER) ?? nonEmpty(settings.audioPlayer),
|
|
59
|
-
loopCooldownMs: Math.max(0, Number(env.PRISM_VOICE_LOOP_COOLDOWN_MS ?? settings.loopCooldownMs ?? 1500) || 1500),
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function stripWakeWord(transcript: string, wakeWord: string): string | undefined {
|
|
64
|
-
const text = transcript.replace(/\s+/g, " ").trim()
|
|
65
|
-
if (!text) return undefined
|
|
66
|
-
const escaped = wakeWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
67
|
-
const match = text.match(new RegExp(`(?:^|\\b)(?:hey\\s+|ok\\s+|okay\\s+)?${escaped}\\b[:,;\\-]?\\s*(.*)$`, "iu"))
|
|
68
|
-
const command = match?.[1]?.trim()
|
|
69
|
-
return command || undefined
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function normalizeVoiceText(text: string): string {
|
|
73
|
-
return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").replace(/\s+/g, " ").trim()
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function shouldIgnoreTranscript(transcript: string, state: { isSpeaking: boolean; lastSpokenText?: string; lastSpokenAt?: number }, now = Date.now(), cooldownMs = 1500): boolean {
|
|
77
|
-
if (state.isSpeaking) return true
|
|
78
|
-
if (state.lastSpokenAt && now - state.lastSpokenAt < cooldownMs) return true
|
|
79
|
-
const heard = normalizeVoiceText(transcript)
|
|
80
|
-
const spoken = normalizeVoiceText(state.lastSpokenText ?? "")
|
|
81
|
-
if (!heard || !spoken) return false
|
|
82
|
-
return heard.length >= 12 && (spoken.includes(heard) || heard.includes(spoken.slice(0, Math.min(spoken.length, 160))))
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function isThinkingTextBlock(block: any): boolean {
|
|
86
|
-
if (!block || typeof block !== "object") return false
|
|
87
|
-
const marker = String(block.type ?? block.kind ?? block.name ?? block.category ?? block.role ?? "").toLowerCase()
|
|
88
|
-
if (/thinking|reasoning|scratchpad|analysis/.test(marker)) return true
|
|
89
|
-
if (block.thinking || block.reasoning || block.isThinking || block.isReasoning) return true
|
|
90
|
-
const metadata = block.metadata ?? block.providerMetadata ?? block.experimental_providerMetadata
|
|
91
|
-
if (metadata && typeof metadata === "object") {
|
|
92
|
-
const metadataMarker = JSON.stringify(metadata).toLowerCase()
|
|
93
|
-
if (/thinking|reasoning|scratchpad|analysis/.test(metadataMarker)) return true
|
|
94
|
-
}
|
|
95
|
-
return false
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function stripThinkingText(text: string): string {
|
|
99
|
-
return text
|
|
100
|
-
.replace(/<\s*(think|thinking|reasoning|analysis|scratchpad)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/giu, " ")
|
|
101
|
-
.replace(/^\s*(?:#{1,6}\s*)?(?:thinking|reasoning|analysis|scratchpad|internal notes?)\s*:?\s*$/gimu, " ")
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function messageToSpeechText(message: any, maxChars = 4000): string {
|
|
105
|
-
if (!message || message.role !== "assistant" || message.stopReason === "error") return ""
|
|
106
|
-
const content = message.content
|
|
107
|
-
if (Array.isArray(content) && content.some((block: any) => block?.type === "toolCall" || block?.type === "tool-call" || block?.toolCallId || block?.name && block?.arguments)) return ""
|
|
108
|
-
const text = Array.isArray(content)
|
|
109
|
-
? content.filter((block: any) => block?.type === "text" && typeof block.text === "string" && !isThinkingTextBlock(block)).map((block: any) => block.text).join("\n")
|
|
110
|
-
: typeof content === "string" ? content : ""
|
|
111
|
-
return stripThinkingText(text)
|
|
112
|
-
.replace(/```[\s\S]*?```/g, "Code block omitted.")
|
|
113
|
-
.replace(/`([^`]+)`/g, "$1")
|
|
114
|
-
.replace(/\s+/g, " ")
|
|
115
|
-
.trim()
|
|
116
|
-
.slice(0, maxChars)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function commandExists(command: string, spawnSyncProcess: typeof spawnSync): boolean {
|
|
120
|
-
return spawnSyncProcess("sh", ["-lc", `command -v ${JSON.stringify(command)} >/dev/null 2>&1`], { stdio: "ignore" }).status === 0
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function defaultSttCommand(config: VoiceConfig): string | undefined {
|
|
124
|
-
if (!config.whisperModel) return undefined
|
|
125
|
-
return `whisper-stream -m ${JSON.stringify(config.whisperModel)} --step 3000 --length 5000 --keep 500 -t 4`
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function chooseAudioPlayer(config: VoiceConfig, spawnSyncProcess: typeof spawnSync): string | undefined {
|
|
129
|
-
if (config.audioPlayer) return config.audioPlayer
|
|
130
|
-
for (const candidate of ["paplay", "aplay", "ffplay"]) {
|
|
131
|
-
if (commandExists(candidate, spawnSyncProcess)) return candidate === "ffplay" ? "ffplay -nodisp -autoexit" : candidate
|
|
132
|
-
}
|
|
133
|
-
return undefined
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export class LocalVoiceRuntime {
|
|
137
|
-
private mode: VoiceMode = "off"
|
|
138
|
-
private sttProcess?: ChildProcess
|
|
139
|
-
private isSpeaking = false
|
|
140
|
-
private lastSpokenText = ""
|
|
141
|
-
private lastSpokenAt = 0
|
|
142
|
-
private readonly env: NodeJS.ProcessEnv
|
|
143
|
-
private readonly spawnProcess: typeof spawn
|
|
144
|
-
private readonly spawnSyncProcess: typeof spawnSync
|
|
145
|
-
|
|
146
|
-
constructor(private readonly options: VoiceRuntimeOptions) {
|
|
147
|
-
this.env = options.env ?? process.env
|
|
148
|
-
this.spawnProcess = options.spawnProcess ?? spawn
|
|
149
|
-
this.spawnSyncProcess = options.spawnSyncProcess ?? spawnSync
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
getMode(): VoiceMode { return this.mode }
|
|
153
|
-
|
|
154
|
-
setMode(mode: VoiceMode): void {
|
|
155
|
-
this.mode = mode
|
|
156
|
-
this.options.setStatus?.(voiceStatus(mode))
|
|
157
|
-
if (mode === "on" || mode === "input") this.startInput()
|
|
158
|
-
else {
|
|
159
|
-
this.stopInput()
|
|
160
|
-
if (mode === "off") void this.stopVoicebox()
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
shutdown(): void {
|
|
165
|
-
this.stopInput()
|
|
166
|
-
void this.stopVoicebox()
|
|
167
|
-
this.options.setStatus?.(undefined)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
async speakAssistantMessage(message: any): Promise<void> {
|
|
171
|
-
if (this.mode !== "on" && this.mode !== "output") return
|
|
172
|
-
const text = messageToSpeechText(message)
|
|
173
|
-
if (!text) return
|
|
174
|
-
await this.speak(text)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
isVoiceboxEnabled(): boolean {
|
|
178
|
-
return isVoiceboxBackend(this.env)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async checkVoicebox(): Promise<boolean> {
|
|
182
|
-
if (!this.isVoiceboxEnabled()) return false
|
|
183
|
-
const { checkVoiceboxHealth } = await import("./voicebox-client.js")
|
|
184
|
-
return checkVoiceboxHealth(resolveVoiceboxConfig(this.env))
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async ensureVoicebox(): Promise<boolean> {
|
|
188
|
-
if (!this.isVoiceboxEnabled()) return false
|
|
189
|
-
return ensureVoiceboxServiceRunning(this.env, this.spawnSyncProcess, this.options.notify)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async stopVoicebox(): Promise<boolean> {
|
|
193
|
-
if (!this.isVoiceboxEnabled()) return false
|
|
194
|
-
return stopVoiceboxService(this.env, this.spawnSyncProcess)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
handleTranscript(rawTranscript: string): void {
|
|
198
|
-
if (this.mode !== "on" && this.mode !== "input") return
|
|
199
|
-
const config = resolveVoiceConfig(this.env)
|
|
200
|
-
if (shouldIgnoreTranscript(rawTranscript, { isSpeaking: this.isSpeaking, lastSpokenText: this.lastSpokenText, lastSpokenAt: this.lastSpokenAt }, Date.now(), config.loopCooldownMs)) return
|
|
201
|
-
const command = stripWakeWord(rawTranscript, config.wakeWord)
|
|
202
|
-
if (!command) return
|
|
203
|
-
void this.options.sendUserMessage(command)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private startInput(): void {
|
|
207
|
-
if (this.sttProcess) return
|
|
208
|
-
const config = resolveVoiceConfig(this.env)
|
|
209
|
-
const command = config.sttCommand ?? defaultSttCommand(config)
|
|
210
|
-
if (!command) {
|
|
211
|
-
this.options.notify?.("Voice input needs PRISM_VOICE_STT_COMMAND or PRISM_VOICE_WHISPER_MODEL (whisper.cpp whisper-stream).", "warning")
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const child = this.spawnProcess(command, { shell: true, stdio: ["ignore", "pipe", "pipe"], env: this.env })
|
|
216
|
-
this.sttProcess = child
|
|
217
|
-
child.stdout?.setEncoding("utf-8")
|
|
218
|
-
child.stdout?.on("data", (chunk: string) => {
|
|
219
|
-
for (const line of String(chunk).split(/\r?\n/)) this.handleTranscript(line)
|
|
220
|
-
})
|
|
221
|
-
child.stderr?.setEncoding("utf-8")
|
|
222
|
-
child.stderr?.on("data", (chunk: string) => {
|
|
223
|
-
const line = String(chunk).split(/\r?\n/).find((value) => value.trim())
|
|
224
|
-
if (line && /error|failed|no such|cannot/i.test(line)) this.options.notify?.(`Voice input: ${line.trim()}`, "warning")
|
|
225
|
-
})
|
|
226
|
-
child.on("exit", () => {
|
|
227
|
-
if (this.sttProcess === child) this.sttProcess = undefined
|
|
228
|
-
if (this.mode === "on" || this.mode === "input") this.options.notify?.("Voice input stopped.", "warning")
|
|
229
|
-
})
|
|
230
|
-
this.options.notify?.(`Voice input listening for wake word “${config.wakeWord}”.`, "info")
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private stopInput(): void {
|
|
234
|
-
const child = this.sttProcess
|
|
235
|
-
this.sttProcess = undefined
|
|
236
|
-
if (!child || child.killed) return
|
|
237
|
-
child.kill("SIGTERM")
|
|
238
|
-
setTimeout(() => { if (!child.killed) child.kill("SIGKILL") }, 1000).unref?.()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private async speak(text: string): Promise<void> {
|
|
242
|
-
const config = resolveVoiceConfig(this.env)
|
|
243
|
-
const command = config.ttsCommand
|
|
244
|
-
this.isSpeaking = true
|
|
245
|
-
this.lastSpokenText = text
|
|
246
|
-
try {
|
|
247
|
-
if (isVoiceboxBackend(this.env)) {
|
|
248
|
-
const ok = await this.ensureVoicebox()
|
|
249
|
-
if (!ok) {
|
|
250
|
-
this.options.notify?.("Voicebox is not reachable and could not be started.", "warning")
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
const player = chooseAudioPlayer(config, this.spawnSyncProcess)
|
|
254
|
-
if (!player) {
|
|
255
|
-
this.options.notify?.("Voicebox output needs an audio player: paplay, aplay, ffplay, or PRISM_VOICE_AUDIO_PLAYER.", "warning")
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
await this.streamVoiceboxToPlayer(text, player)
|
|
259
|
-
return
|
|
260
|
-
}
|
|
261
|
-
if (command) {
|
|
262
|
-
await this.runShell(command, text)
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
if (!config.piperModel) {
|
|
266
|
-
this.options.notify?.("Voice output needs PRISM_VOICE_TTS_COMMAND or PRISM_VOICE_PIPER_MODEL (Piper TTS).", "warning")
|
|
267
|
-
return
|
|
268
|
-
}
|
|
269
|
-
const player = chooseAudioPlayer(config, this.spawnSyncProcess)
|
|
270
|
-
if (!player) {
|
|
271
|
-
this.options.notify?.("Voice output needs an audio player: paplay, aplay, ffplay, or PRISM_VOICE_AUDIO_PLAYER.", "warning")
|
|
272
|
-
return
|
|
273
|
-
}
|
|
274
|
-
const wav = path.join(os.tmpdir(), `prism-voice-${process.pid}-${Date.now()}.wav`)
|
|
275
|
-
await this.runCommand("piper", ["--model", config.piperModel, "--output_file", wav], text)
|
|
276
|
-
await this.runShell(`${player} ${JSON.stringify(wav)}`, "")
|
|
277
|
-
fs.rmSync(wav, { force: true })
|
|
278
|
-
} finally {
|
|
279
|
-
this.lastSpokenAt = Date.now()
|
|
280
|
-
this.isSpeaking = false
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
private streamVoiceboxToPlayer(text: string, player: string): Promise<void> {
|
|
285
|
-
return new Promise((resolve) => {
|
|
286
|
-
const child = this.spawnProcess(player, { shell: true, stdio: ["pipe", "ignore", "pipe"], env: this.env })
|
|
287
|
-
let settled = false
|
|
288
|
-
const finish = () => {
|
|
289
|
-
if (settled) return
|
|
290
|
-
settled = true
|
|
291
|
-
resolve()
|
|
292
|
-
}
|
|
293
|
-
child.on("exit", finish)
|
|
294
|
-
child.on("error", finish)
|
|
295
|
-
streamSpeechWithVoicebox(text, (chunk) => {
|
|
296
|
-
if (!child.stdin?.writable) return
|
|
297
|
-
child.stdin.write(Buffer.from(chunk))
|
|
298
|
-
}, resolveVoiceboxConfig(this.env)).then(() => child.stdin?.end()).catch(() => {
|
|
299
|
-
child.stdin?.destroy()
|
|
300
|
-
finish()
|
|
301
|
-
})
|
|
302
|
-
})
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
private runShell(command: string, input: string): Promise<void> {
|
|
306
|
-
return new Promise((resolve) => {
|
|
307
|
-
const child = this.spawnProcess(command, { shell: true, stdio: ["pipe", "ignore", "pipe"], env: this.env })
|
|
308
|
-
child.stdin?.end(input)
|
|
309
|
-
child.on("exit", () => resolve())
|
|
310
|
-
child.on("error", () => resolve())
|
|
311
|
-
})
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private runCommand(command: string, args: string[], input: string): Promise<void> {
|
|
315
|
-
return new Promise((resolve) => {
|
|
316
|
-
const child = this.spawnProcess(command, args, { stdio: ["pipe", "ignore", "pipe"], env: this.env })
|
|
317
|
-
child.stdin?.end(input)
|
|
318
|
-
child.on("exit", () => resolve())
|
|
319
|
-
child.on("error", () => resolve())
|
|
320
|
-
})
|
|
321
|
-
}
|
|
322
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import { readSettings } from "./shared-config.js"
|
|
2
|
-
|
|
3
|
-
export interface VoiceboxConfig {
|
|
4
|
-
baseUrl: string
|
|
5
|
-
profile?: string
|
|
6
|
-
language: string
|
|
7
|
-
engine?: string
|
|
8
|
-
personality?: boolean
|
|
9
|
-
clientId: string
|
|
10
|
-
timeoutMs: number
|
|
11
|
-
generationTimeoutMs: number
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface VoiceboxGenerationStatus {
|
|
15
|
-
id: string
|
|
16
|
-
status: string
|
|
17
|
-
duration?: number
|
|
18
|
-
error?: string | null
|
|
19
|
-
source?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function nonEmpty(value: unknown): string | undefined {
|
|
23
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function boolSetting(value: unknown): boolean | undefined {
|
|
27
|
-
if (typeof value === "boolean") return value
|
|
28
|
-
if (typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim())) return true
|
|
29
|
-
if (typeof value === "string" && /^(0|false|no|off)$/i.test(value.trim())) return false
|
|
30
|
-
return undefined
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function numberSetting(value: unknown): number | undefined {
|
|
34
|
-
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
35
|
-
if (typeof value === "string" && value.trim()) {
|
|
36
|
-
const parsed = Number(value)
|
|
37
|
-
if (Number.isFinite(parsed)) return parsed
|
|
38
|
-
}
|
|
39
|
-
return undefined
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function getVoiceboxSettings(env: NodeJS.ProcessEnv): any {
|
|
43
|
-
const settings = readSettings(undefined, env)
|
|
44
|
-
return settings?.voicebox ?? settings?.voice?.voicebox ?? {}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function getVoiceSettings(env: NodeJS.ProcessEnv): any {
|
|
48
|
-
return readSettings(undefined, env)?.voice ?? {}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function normalizeVoiceboxBaseUrl(value: string): string {
|
|
52
|
-
return value.replace(/\/+$/, "")
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function resolveVoiceboxConfig(env: NodeJS.ProcessEnv = process.env): VoiceboxConfig {
|
|
56
|
-
const voicebox = getVoiceboxSettings(env)
|
|
57
|
-
return {
|
|
58
|
-
baseUrl: normalizeVoiceboxBaseUrl(nonEmpty(env.PRISM_VOICEBOX_URL) ?? nonEmpty(voicebox.url) ?? nonEmpty(voicebox.baseUrl) ?? "http://127.0.0.1:17493"),
|
|
59
|
-
profile: nonEmpty(env.PRISM_VOICEBOX_PROFILE) ?? nonEmpty(voicebox.profile),
|
|
60
|
-
language: nonEmpty(env.PRISM_VOICEBOX_LANGUAGE) ?? nonEmpty(voicebox.language) ?? "en",
|
|
61
|
-
engine: nonEmpty(env.PRISM_VOICEBOX_ENGINE) ?? nonEmpty(voicebox.engine),
|
|
62
|
-
personality: boolSetting(env.PRISM_VOICEBOX_PERSONALITY) ?? boolSetting(voicebox.personality) ?? false,
|
|
63
|
-
clientId: nonEmpty(env.PRISM_VOICEBOX_CLIENT_ID) ?? nonEmpty(voicebox.clientId) ?? "prism",
|
|
64
|
-
timeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_TIMEOUT_MS) ?? numberSetting(voicebox.timeoutMs) ?? 30_000),
|
|
65
|
-
generationTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_GENERATION_TIMEOUT_MS) ?? numberSetting(voicebox.generationTimeoutMs) ?? 180_000),
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function resolveVoiceBackend(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
70
|
-
return nonEmpty(env.PRISM_VOICE_BACKEND) ?? nonEmpty(getVoiceSettings(env).backend)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function isVoiceboxBackend(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
74
|
-
return /^(voicebox|auto)$/i.test(resolveVoiceBackend(env) ?? "") || !!nonEmpty(env.PRISM_VOICEBOX_URL) || !!nonEmpty(getVoiceboxSettings(env).url) || !!nonEmpty(getVoiceboxSettings(env).baseUrl)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise<any> {
|
|
78
|
-
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
|
79
|
-
const text = await response.text()
|
|
80
|
-
const parsed = parseJsonOrText(text)
|
|
81
|
-
if (!response.ok) {
|
|
82
|
-
const detail = typeof parsed?.detail === "string" ? parsed.detail : typeof parsed === "string" ? parsed : `HTTP ${response.status}`
|
|
83
|
-
throw new Error(detail)
|
|
84
|
-
}
|
|
85
|
-
return parsed
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function fetchBuffer(url: string, init: RequestInit, timeoutMs: number): Promise<Buffer> {
|
|
89
|
-
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
|
90
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
91
|
-
return Buffer.from(await response.arrayBuffer())
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function fetchStream(url: string, init: RequestInit, timeoutMs: number, onChunk: (chunk: Uint8Array) => void | Promise<void>): Promise<void> {
|
|
95
|
-
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
|
96
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
97
|
-
if (!response.body) return
|
|
98
|
-
const reader = response.body.getReader()
|
|
99
|
-
try {
|
|
100
|
-
while (true) {
|
|
101
|
-
const { done, value } = await reader.read()
|
|
102
|
-
if (done) break
|
|
103
|
-
if (value?.byteLength) await onChunk(value)
|
|
104
|
-
}
|
|
105
|
-
} finally {
|
|
106
|
-
reader.releaseLock()
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function parseJsonOrText(text: string): any {
|
|
111
|
-
try { return text ? JSON.parse(text) : undefined } catch { return text }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function parseVoiceboxStatusEvent(text: string): VoiceboxGenerationStatus | undefined {
|
|
115
|
-
for (const line of text.split(/\r?\n/)) {
|
|
116
|
-
if (!line.startsWith("data:")) continue
|
|
117
|
-
const parsed = parseJsonOrText(line.replace(/^data:\s*/, ""))
|
|
118
|
-
if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed
|
|
119
|
-
}
|
|
120
|
-
const parsed = parseJsonOrText(text)
|
|
121
|
-
if (parsed && typeof parsed === "object" && typeof parsed.status === "string") return parsed
|
|
122
|
-
return undefined
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function checkVoiceboxHealth(config = resolveVoiceboxConfig()): Promise<boolean> {
|
|
126
|
-
try {
|
|
127
|
-
await fetchJson(`${config.baseUrl}/health`, { method: "GET" }, Math.min(config.timeoutMs, 5_000))
|
|
128
|
-
return true
|
|
129
|
-
} catch {
|
|
130
|
-
return false
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export async function speakWithVoicebox(text: string, config = resolveVoiceboxConfig()): Promise<any> {
|
|
135
|
-
return fetchJson(`${config.baseUrl}/speak`, {
|
|
136
|
-
method: "POST",
|
|
137
|
-
headers: {
|
|
138
|
-
"Content-Type": "application/json",
|
|
139
|
-
"X-Voicebox-Client-Id": config.clientId,
|
|
140
|
-
},
|
|
141
|
-
body: JSON.stringify({
|
|
142
|
-
text,
|
|
143
|
-
profile: config.profile,
|
|
144
|
-
language: config.language,
|
|
145
|
-
engine: config.engine,
|
|
146
|
-
personality: config.personality,
|
|
147
|
-
}),
|
|
148
|
-
}, config.timeoutMs)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function isAbortLikeError(error: unknown): boolean {
|
|
152
|
-
return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export async function waitForVoiceboxGeneration(id: string, config = resolveVoiceboxConfig()): Promise<VoiceboxGenerationStatus> {
|
|
156
|
-
const deadline = Date.now() + config.generationTimeoutMs
|
|
157
|
-
let lastStatus: VoiceboxGenerationStatus | undefined
|
|
158
|
-
while (Date.now() < deadline) {
|
|
159
|
-
try {
|
|
160
|
-
const response = await fetch(`${config.baseUrl}/generate/${encodeURIComponent(id)}/status`, { method: "GET", signal: AbortSignal.timeout(Math.min(config.timeoutMs, 10_000)) })
|
|
161
|
-
const text = await response.text()
|
|
162
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
163
|
-
lastStatus = parseVoiceboxStatusEvent(text) ?? lastStatus
|
|
164
|
-
if (lastStatus?.status === "completed") return lastStatus
|
|
165
|
-
if (lastStatus?.status === "failed") throw new Error(lastStatus.error || "Voicebox generation failed")
|
|
166
|
-
} catch (error) {
|
|
167
|
-
if (!isAbortLikeError(error)) throw error
|
|
168
|
-
// Voicebox can block status responses while lazily loading a TTS model.
|
|
169
|
-
// Treat individual poll timeouts as transient; the overall generation
|
|
170
|
-
// timeout below remains the hard limit.
|
|
171
|
-
}
|
|
172
|
-
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
173
|
-
}
|
|
174
|
-
throw new Error(`Voicebox generation timed out${lastStatus?.status ? ` (${lastStatus.status})` : ""}`)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export async function downloadVoiceboxAudio(id: string, config = resolveVoiceboxConfig()): Promise<Buffer> {
|
|
178
|
-
return fetchBuffer(`${config.baseUrl}/audio/${encodeURIComponent(id)}`, { method: "GET" }, config.timeoutMs)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export async function synthesizeSpeechWithVoicebox(text: string, config = resolveVoiceboxConfig()): Promise<Buffer> {
|
|
182
|
-
const generation = await speakWithVoicebox(text, config)
|
|
183
|
-
const id = String(generation?.id ?? "")
|
|
184
|
-
if (!id) throw new Error("Voicebox did not return a generation id")
|
|
185
|
-
await waitForVoiceboxGeneration(id, config)
|
|
186
|
-
return downloadVoiceboxAudio(id, config)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function streamSpeechWithVoicebox(text: string, onChunk: (chunk: Uint8Array) => void | Promise<void>, config = resolveVoiceboxConfig()): Promise<void> {
|
|
190
|
-
const generation = await speakWithVoicebox(text, config)
|
|
191
|
-
const id = String(generation?.id ?? "")
|
|
192
|
-
if (!id) throw new Error("Voicebox did not return a generation id")
|
|
193
|
-
await waitForVoiceboxGeneration(id, config)
|
|
194
|
-
await fetchStream(`${config.baseUrl}/audio/${encodeURIComponent(id)}`, { method: "GET" }, config.timeoutMs, onChunk)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function transcribeWithVoicebox(audio: Blob | Buffer | Uint8Array, options: { fileName?: string; language?: string; model?: string } = {}, config = resolveVoiceboxConfig()): Promise<string> {
|
|
198
|
-
const form = new FormData()
|
|
199
|
-
const file = audio instanceof Blob ? audio : new Blob([new Uint8Array(audio)])
|
|
200
|
-
form.append("file", file, options.fileName ?? "audio.wav")
|
|
201
|
-
if (options.language) form.append("language", options.language)
|
|
202
|
-
if (options.model) form.append("model", options.model)
|
|
203
|
-
const result = await fetchJson(`${config.baseUrl}/transcribe`, { method: "POST", body: form }, config.timeoutMs)
|
|
204
|
-
return String(result?.text ?? "")
|
|
205
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import * as fs from "node:fs"
|
|
2
|
-
import * as os from "node:os"
|
|
3
|
-
import * as path from "node:path"
|
|
4
|
-
import { spawnSync, type SpawnSyncReturns } from "node:child_process"
|
|
5
|
-
import { readSettings } from "./shared-config.js"
|
|
6
|
-
import { checkVoiceboxHealth, resolveVoiceboxConfig } from "./voicebox-client.js"
|
|
7
|
-
|
|
8
|
-
const VOICEBOX_REPO_URL = "https://github.com/jamiepine/voicebox.git"
|
|
9
|
-
|
|
10
|
-
export interface VoiceboxServiceConfig {
|
|
11
|
-
autoInstall: boolean
|
|
12
|
-
autoStart: boolean
|
|
13
|
-
autoStop: boolean
|
|
14
|
-
installDir: string
|
|
15
|
-
installCommand: string
|
|
16
|
-
startCommand: string
|
|
17
|
-
stopCommand: string
|
|
18
|
-
startupTimeoutMs: number
|
|
19
|
-
installTimeoutMs: number
|
|
20
|
-
stopTimeoutMs: number
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function nonEmpty(value: unknown): string | undefined {
|
|
24
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function boolSetting(value: unknown): boolean | undefined {
|
|
28
|
-
if (typeof value === "boolean") return value
|
|
29
|
-
if (typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim())) return true
|
|
30
|
-
if (typeof value === "string" && /^(0|false|no|off)$/i.test(value.trim())) return false
|
|
31
|
-
return undefined
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function numberSetting(value: unknown): number | undefined {
|
|
35
|
-
if (typeof value === "number" && Number.isFinite(value)) return value
|
|
36
|
-
if (typeof value === "string" && value.trim()) {
|
|
37
|
-
const parsed = Number(value)
|
|
38
|
-
if (Number.isFinite(parsed)) return parsed
|
|
39
|
-
}
|
|
40
|
-
return undefined
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function quote(value: string): string {
|
|
44
|
-
return JSON.stringify(value)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function defaultInstallDir(env: NodeJS.ProcessEnv): string {
|
|
48
|
-
return nonEmpty(env.PRISM_VOICEBOX_DIR) ?? path.join(os.homedir(), ".local", "share", "prism", "voicebox")
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function defaultInstallCommand(installDir: string): string {
|
|
52
|
-
return `mkdir -p ${quote(path.dirname(installDir))} && git clone ${quote(VOICEBOX_REPO_URL)} ${quote(installDir)}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function defaultStartCommand(installDir: string): string {
|
|
56
|
-
return `if command -v prism-voicebox >/dev/null 2>&1; then prism-voicebox start; else cd ${quote(installDir)} && docker compose up -d --build; fi`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function defaultStopCommand(installDir: string): string {
|
|
60
|
-
return `if command -v prism-voicebox >/dev/null 2>&1; then prism-voicebox stop; else cd ${quote(installDir)} && docker compose down; fi`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function resolveVoiceboxServiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceboxServiceConfig {
|
|
64
|
-
const settings = readSettings(undefined, env)?.voicebox ?? {}
|
|
65
|
-
const installDir = nonEmpty(env.PRISM_VOICEBOX_DIR) ?? nonEmpty(settings.installDir) ?? defaultInstallDir(env)
|
|
66
|
-
return {
|
|
67
|
-
autoInstall: boolSetting(env.PRISM_VOICEBOX_AUTO_INSTALL) ?? boolSetting(settings.autoInstall) ?? true,
|
|
68
|
-
autoStart: boolSetting(env.PRISM_VOICEBOX_AUTO_START) ?? boolSetting(settings.autoStart) ?? true,
|
|
69
|
-
autoStop: boolSetting(env.PRISM_VOICEBOX_AUTO_STOP) ?? boolSetting(settings.autoStop) ?? true,
|
|
70
|
-
installDir,
|
|
71
|
-
installCommand: nonEmpty(env.PRISM_VOICEBOX_INSTALL_COMMAND) ?? nonEmpty(settings.installCommand) ?? defaultInstallCommand(installDir),
|
|
72
|
-
startCommand: nonEmpty(env.PRISM_VOICEBOX_START_COMMAND) ?? nonEmpty(settings.startCommand) ?? defaultStartCommand(installDir),
|
|
73
|
-
stopCommand: nonEmpty(env.PRISM_VOICEBOX_STOP_COMMAND) ?? nonEmpty(settings.stopCommand) ?? defaultStopCommand(installDir),
|
|
74
|
-
startupTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_STARTUP_TIMEOUT_MS) ?? numberSetting(settings.startupTimeoutMs) ?? 120_000),
|
|
75
|
-
installTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_INSTALL_TIMEOUT_MS) ?? numberSetting(settings.installTimeoutMs) ?? 300_000),
|
|
76
|
-
stopTimeoutMs: Math.max(1_000, numberSetting(env.PRISM_VOICEBOX_STOP_TIMEOUT_MS) ?? numberSetting(settings.stopTimeoutMs) ?? 60_000),
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function sleep(ms: number): Promise<void> {
|
|
81
|
-
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function runShell(command: string, timeoutMs: number, env: NodeJS.ProcessEnv, spawnSyncProcess: typeof spawnSync): boolean {
|
|
85
|
-
const result: SpawnSyncReturns<Buffer> = spawnSyncProcess("sh", ["-lc", command], { env, stdio: "ignore", timeout: timeoutMs })
|
|
86
|
-
return !result.error && (result.status === 0 || result.status === null)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function isVoiceboxServiceInstalled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
90
|
-
const service = resolveVoiceboxServiceConfig(env)
|
|
91
|
-
return fs.existsSync(path.join(service.installDir, "docker-compose.yml")) || fs.existsSync(path.join(service.installDir, "compose.yml"))
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export async function installVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
|
|
95
|
-
const service = resolveVoiceboxServiceConfig(env)
|
|
96
|
-
if (isVoiceboxServiceInstalled(env)) return true
|
|
97
|
-
if (!service.autoInstall) return false
|
|
98
|
-
return runShell(service.installCommand, service.installTimeoutMs, env, spawnSyncProcess) && isVoiceboxServiceInstalled(env)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function startVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
|
|
102
|
-
const service = resolveVoiceboxServiceConfig(env)
|
|
103
|
-
if (!service.autoStart) return false
|
|
104
|
-
if (!isVoiceboxServiceInstalled(env) && !(await installVoiceboxService(env, spawnSyncProcess))) return false
|
|
105
|
-
return runShell(service.startCommand, service.startupTimeoutMs, env, spawnSyncProcess)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export async function stopVoiceboxService(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync): Promise<boolean> {
|
|
109
|
-
const service = resolveVoiceboxServiceConfig(env)
|
|
110
|
-
if (!service.autoStop) return false
|
|
111
|
-
if (!isVoiceboxServiceInstalled(env)) return false
|
|
112
|
-
return runShell(service.stopCommand, service.stopTimeoutMs, env, spawnSyncProcess)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export async function ensureVoiceboxServiceRunning(env: NodeJS.ProcessEnv = process.env, spawnSyncProcess: typeof spawnSync = spawnSync, onStatus?: (message: string, level?: string) => void): Promise<boolean> {
|
|
116
|
-
const config = resolveVoiceboxConfig(env)
|
|
117
|
-
if (await checkVoiceboxHealth(config)) return true
|
|
118
|
-
const service = resolveVoiceboxServiceConfig(env)
|
|
119
|
-
|
|
120
|
-
if (!isVoiceboxServiceInstalled(env)) {
|
|
121
|
-
if (!service.autoInstall) return false
|
|
122
|
-
onStatus?.(`Voicebox is not installed. Installing to ${service.installDir}…`, "info")
|
|
123
|
-
if (!(await installVoiceboxService(env, spawnSyncProcess))) return false
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (!service.autoStart) return false
|
|
127
|
-
onStatus?.("Voicebox is not running. Starting local Voicebox container…", "info")
|
|
128
|
-
const started = await startVoiceboxService(env, spawnSyncProcess)
|
|
129
|
-
if (!started) return false
|
|
130
|
-
|
|
131
|
-
const deadline = Date.now() + service.startupTimeoutMs
|
|
132
|
-
while (Date.now() < deadline) {
|
|
133
|
-
if (await checkVoiceboxHealth(config)) return true
|
|
134
|
-
await sleep(1_000)
|
|
135
|
-
}
|
|
136
|
-
return false
|
|
137
|
-
}
|