openspeechapi 0.1.0__py3-none-any.whl
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.
- openspeech/__init__.py +75 -0
- openspeech/__main__.py +5 -0
- openspeech/cli.py +413 -0
- openspeech/client/__init__.py +4 -0
- openspeech/client/client.py +145 -0
- openspeech/config.py +212 -0
- openspeech/core/__init__.py +0 -0
- openspeech/core/base.py +75 -0
- openspeech/core/enums.py +39 -0
- openspeech/core/models.py +61 -0
- openspeech/core/registry.py +37 -0
- openspeech/core/settings.py +8 -0
- openspeech/demo.py +675 -0
- openspeech/dispatch/__init__.py +0 -0
- openspeech/dispatch/context.py +34 -0
- openspeech/dispatch/dispatcher.py +661 -0
- openspeech/dispatch/executors/__init__.py +0 -0
- openspeech/dispatch/executors/base.py +34 -0
- openspeech/dispatch/executors/in_process.py +66 -0
- openspeech/dispatch/executors/remote.py +64 -0
- openspeech/dispatch/executors/subprocess_exec.py +446 -0
- openspeech/dispatch/fanout.py +95 -0
- openspeech/dispatch/filters.py +73 -0
- openspeech/dispatch/lifecycle.py +178 -0
- openspeech/dispatch/watcher.py +82 -0
- openspeech/engine_catalog.py +236 -0
- openspeech/engine_registry.yaml +347 -0
- openspeech/exceptions.py +51 -0
- openspeech/factory.py +325 -0
- openspeech/local_engines/__init__.py +12 -0
- openspeech/local_engines/aim_resolver.py +91 -0
- openspeech/local_engines/backends/__init__.py +1 -0
- openspeech/local_engines/backends/docker_backend.py +490 -0
- openspeech/local_engines/backends/native_backend.py +902 -0
- openspeech/local_engines/base.py +30 -0
- openspeech/local_engines/engines/__init__.py +1 -0
- openspeech/local_engines/engines/faster_whisper.py +36 -0
- openspeech/local_engines/engines/fish_speech.py +33 -0
- openspeech/local_engines/engines/sherpa_onnx.py +56 -0
- openspeech/local_engines/engines/whisper.py +41 -0
- openspeech/local_engines/engines/whisperlivekit.py +60 -0
- openspeech/local_engines/manager.py +208 -0
- openspeech/local_engines/models.py +50 -0
- openspeech/local_engines/progress.py +69 -0
- openspeech/local_engines/registry.py +19 -0
- openspeech/local_engines/task_store.py +52 -0
- openspeech/local_engines/tasks.py +71 -0
- openspeech/logging_config.py +607 -0
- openspeech/observe/__init__.py +0 -0
- openspeech/observe/base.py +79 -0
- openspeech/observe/debug.py +44 -0
- openspeech/observe/latency.py +19 -0
- openspeech/observe/metrics.py +47 -0
- openspeech/observe/tracing.py +44 -0
- openspeech/observe/usage.py +27 -0
- openspeech/providers/__init__.py +0 -0
- openspeech/providers/_template.py +101 -0
- openspeech/providers/stt/__init__.py +0 -0
- openspeech/providers/stt/alibaba.py +86 -0
- openspeech/providers/stt/assemblyai.py +135 -0
- openspeech/providers/stt/azure_speech.py +99 -0
- openspeech/providers/stt/baidu.py +135 -0
- openspeech/providers/stt/deepgram.py +311 -0
- openspeech/providers/stt/elevenlabs.py +385 -0
- openspeech/providers/stt/faster_whisper.py +211 -0
- openspeech/providers/stt/google_cloud.py +106 -0
- openspeech/providers/stt/iflytek.py +427 -0
- openspeech/providers/stt/macos_speech.py +226 -0
- openspeech/providers/stt/openai.py +84 -0
- openspeech/providers/stt/sherpa_onnx.py +353 -0
- openspeech/providers/stt/tencent.py +212 -0
- openspeech/providers/stt/volcengine.py +107 -0
- openspeech/providers/stt/whisper.py +153 -0
- openspeech/providers/stt/whisperlivekit.py +530 -0
- openspeech/providers/stt/windows_speech.py +249 -0
- openspeech/providers/tts/__init__.py +0 -0
- openspeech/providers/tts/alibaba.py +95 -0
- openspeech/providers/tts/azure_speech.py +123 -0
- openspeech/providers/tts/baidu.py +143 -0
- openspeech/providers/tts/coqui.py +64 -0
- openspeech/providers/tts/cosyvoice.py +90 -0
- openspeech/providers/tts/deepgram.py +174 -0
- openspeech/providers/tts/elevenlabs.py +311 -0
- openspeech/providers/tts/fish_speech.py +158 -0
- openspeech/providers/tts/google_cloud.py +107 -0
- openspeech/providers/tts/iflytek.py +209 -0
- openspeech/providers/tts/macos_say.py +251 -0
- openspeech/providers/tts/minimax.py +122 -0
- openspeech/providers/tts/openai.py +104 -0
- openspeech/providers/tts/piper.py +104 -0
- openspeech/providers/tts/tencent.py +189 -0
- openspeech/providers/tts/volcengine.py +117 -0
- openspeech/providers/tts/windows_sapi.py +234 -0
- openspeech/server/__init__.py +1 -0
- openspeech/server/app.py +72 -0
- openspeech/server/auth.py +42 -0
- openspeech/server/middleware.py +75 -0
- openspeech/server/routes/__init__.py +1 -0
- openspeech/server/routes/management.py +848 -0
- openspeech/server/routes/stt.py +121 -0
- openspeech/server/routes/tts.py +159 -0
- openspeech/server/routes/webui.py +29 -0
- openspeech/server/webui/app.js +2649 -0
- openspeech/server/webui/index.html +216 -0
- openspeech/server/webui/styles.css +617 -0
- openspeech/server/ws/__init__.py +1 -0
- openspeech/server/ws/stt_stream.py +263 -0
- openspeech/server/ws/tts_stream.py +207 -0
- openspeech/telemetry/__init__.py +21 -0
- openspeech/telemetry/perf.py +307 -0
- openspeech/utils/__init__.py +5 -0
- openspeech/utils/audio_converter.py +406 -0
- openspeech/utils/audio_playback.py +156 -0
- openspeech/vendor_registry.yaml +74 -0
- openspeechapi-0.1.0.dist-info/METADATA +101 -0
- openspeechapi-0.1.0.dist-info/RECORD +118 -0
- openspeechapi-0.1.0.dist-info/WHEEL +4 -0
- openspeechapi-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,2649 @@
|
|
|
1
|
+
const $ = (id) => document.getElementById(id);
|
|
2
|
+
|
|
3
|
+
// ---- Lab Interaction Log ----
|
|
4
|
+
const _labLogMax = 200; // max lines kept
|
|
5
|
+
function labLog(level, msg) {
|
|
6
|
+
const el = $("lab-log");
|
|
7
|
+
if (!el) return;
|
|
8
|
+
const ts = new Date().toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 });
|
|
9
|
+
const cls = level === "error" ? "log-err" : level === "warn" ? "log-warn" : level === "data" ? "log-data" : "log-info";
|
|
10
|
+
const line = document.createElement("span");
|
|
11
|
+
line.innerHTML = `<span class="log-ts">[${ts}]</span> <span class="${cls}">${_escHtml(msg)}</span>\n`;
|
|
12
|
+
el.appendChild(line);
|
|
13
|
+
// Trim old lines
|
|
14
|
+
while (el.childElementCount > _labLogMax) el.removeChild(el.firstElementChild);
|
|
15
|
+
el.scrollTop = el.scrollHeight;
|
|
16
|
+
}
|
|
17
|
+
function labLogV(level, msg) {
|
|
18
|
+
const cb = $("lab-log-verbose");
|
|
19
|
+
if (cb && cb.checked) labLog(level, msg);
|
|
20
|
+
}
|
|
21
|
+
function labLogClear() {
|
|
22
|
+
const el = $("lab-log");
|
|
23
|
+
if (el) el.innerHTML = "";
|
|
24
|
+
}
|
|
25
|
+
function _escHtml(s) {
|
|
26
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Shared group ordering for engines/config/dashboard
|
|
30
|
+
const ENGINE_GROUPS = [
|
|
31
|
+
{ label: 'STT — Cloud', filter: e => e.type === 'stt' && e.category === 'cloud' },
|
|
32
|
+
{ label: 'STT — Local / Native', filter: e => e.type === 'stt' && (e.category === 'local' || e.category === 'native') },
|
|
33
|
+
{ label: 'TTS — Cloud', filter: e => e.type === 'tts' && e.category === 'cloud' },
|
|
34
|
+
{ label: 'TTS — Local / Native', filter: e => e.type === 'tts' && (e.category === 'local' || e.category === 'native') },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const state = {
|
|
38
|
+
engines: [],
|
|
39
|
+
eventSource: null,
|
|
40
|
+
ttsTimer: null,
|
|
41
|
+
sttLive: null,
|
|
42
|
+
sttLiveText: "",
|
|
43
|
+
sttLiveMetricsTimer: null,
|
|
44
|
+
// Last TTS result for download
|
|
45
|
+
ttsAudioData: null, // Uint8Array of raw audio bytes
|
|
46
|
+
ttsAudioFormat: null, // "mp3", "wav", etc.
|
|
47
|
+
ttsSampleRate: 16000,
|
|
48
|
+
ttsChannels: 1,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function execModeDesc(mode) {
|
|
52
|
+
const m = String(mode || "").toLowerCase();
|
|
53
|
+
if (m === "subprocess") return "Worker subprocess + IPC";
|
|
54
|
+
if (m === "local") return "Local engine service + HTTP(S)";
|
|
55
|
+
if (m === "remote") return "Cloud/remote API over network";
|
|
56
|
+
if (m === "in_process") return "In-process model execution";
|
|
57
|
+
return "Unknown";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nowLabel() {
|
|
61
|
+
const d = new Date();
|
|
62
|
+
return d.toLocaleTimeString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function fetchJson(url, init = {}) {
|
|
66
|
+
const res = await fetch(url, init);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
let detail = `HTTP ${res.status}`;
|
|
69
|
+
const raw = await res.text();
|
|
70
|
+
if (raw) {
|
|
71
|
+
try {
|
|
72
|
+
const data = JSON.parse(raw);
|
|
73
|
+
detail = data.detail || JSON.stringify(data);
|
|
74
|
+
} catch (_) {
|
|
75
|
+
detail = raw;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error(detail || `HTTP ${res.status}`);
|
|
79
|
+
}
|
|
80
|
+
return res.json();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setText(id, text) {
|
|
84
|
+
$(id).textContent = String(text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setOutput(text) {
|
|
88
|
+
const out = $("engine-output-detail") || $("engine-output");
|
|
89
|
+
if (out) out.textContent = `[${nowLabel()}] ${text}\n` + out.textContent;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stopTTSTimer() {
|
|
93
|
+
if (state.ttsTimer) {
|
|
94
|
+
clearInterval(state.ttsTimer);
|
|
95
|
+
state.ttsTimer = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sttMode() {
|
|
100
|
+
return $("stt-mode").value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function updateSTTModeUI() {
|
|
104
|
+
const mode = sttMode();
|
|
105
|
+
const filePanel = $("stt-file-panel");
|
|
106
|
+
const livePanel = $("stt-live-panel");
|
|
107
|
+
const fileInput = $("stt-file");
|
|
108
|
+
const metricsEl = $("stt-live-metrics");
|
|
109
|
+
if (mode === "live") {
|
|
110
|
+
filePanel.classList.add("hidden");
|
|
111
|
+
livePanel.classList.remove("hidden");
|
|
112
|
+
if (metricsEl) metricsEl.style.display = "";
|
|
113
|
+
fileInput.required = false;
|
|
114
|
+
} else {
|
|
115
|
+
if (state.sttLive) {
|
|
116
|
+
stopLiveSTT().catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
filePanel.classList.remove("hidden");
|
|
119
|
+
livePanel.classList.add("hidden");
|
|
120
|
+
if (metricsEl) metricsEl.style.display = "none";
|
|
121
|
+
fileInput.required = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isLocalEngine(p) {
|
|
126
|
+
return ["subprocess", "local", "in_process"].includes(p.exec_mode) && p.exec_mode !== "remote";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function providerDisplayName(p) {
|
|
130
|
+
const info = p.display_info || {};
|
|
131
|
+
const parts = [];
|
|
132
|
+
if (info.model || info.model_size || info.model_name) parts.push(info.model || info.model_size || info.model_name);
|
|
133
|
+
if (info.voice || info.voice_id || info.voice_name || info.voice_type) parts.push(info.voice || info.voice_id || info.voice_name || info.voice_type);
|
|
134
|
+
if (info.language || info.engine_type) parts.push(info.language || info.engine_type);
|
|
135
|
+
if (info.region) parts.push(info.region);
|
|
136
|
+
return parts.length ? parts.join(' / ') : '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderProviderRow(p) {
|
|
140
|
+
const detail = providerDisplayName(p);
|
|
141
|
+
const healthDot = p.healthy ? '<span class="dot dot-green"></span>' : '<span class="dot dot-red"></span>';
|
|
142
|
+
const local = isLocalEngine(p);
|
|
143
|
+
let actions = '';
|
|
144
|
+
if (local) {
|
|
145
|
+
actions = `
|
|
146
|
+
<button class="btn btn-xs" onclick="engineAction('${p.name}','start')" title="Start">Start</button>
|
|
147
|
+
<button class="btn btn-xs" onclick="engineAction('${p.name}','stop')" title="Stop">Stop</button>
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
const label = p.display_name || p.name;
|
|
151
|
+
return `<div class="provider-row">
|
|
152
|
+
<div class="provider-info">
|
|
153
|
+
${healthDot}
|
|
154
|
+
<strong>${label}</strong>
|
|
155
|
+
<span class="provider-engine">${p.name}</span>
|
|
156
|
+
${detail ? `<span class="provider-detail">${detail}</span>` : ''}
|
|
157
|
+
</div>
|
|
158
|
+
<div class="provider-actions">${actions}</div>
|
|
159
|
+
</div>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderEngines(engines) {
|
|
163
|
+
const container = $("provider-groups");
|
|
164
|
+
container.innerHTML = "";
|
|
165
|
+
|
|
166
|
+
const groups = [
|
|
167
|
+
{ label: "STT — Cloud", filter: p => p.type === "stt" && !isLocalEngine(p) },
|
|
168
|
+
{ label: "STT — Local / Native", filter: p => p.type === "stt" && isLocalEngine(p) },
|
|
169
|
+
{ label: "TTS — Cloud", filter: p => p.type === "tts" && !isLocalEngine(p) },
|
|
170
|
+
{ label: "TTS — Local / Native", filter: p => p.type === "tts" && isLocalEngine(p) },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
for (const g of groups) {
|
|
174
|
+
const items = engines.filter(g.filter);
|
|
175
|
+
if (items.length === 0) continue;
|
|
176
|
+
const section = document.createElement("div");
|
|
177
|
+
section.className = "provider-group";
|
|
178
|
+
section.innerHTML = `<h3 class="provider-group-title">${g.label} <span class="provider-count">${items.filter(p=>p.healthy).length}/${items.length}</span></h3>`;
|
|
179
|
+
for (const p of items) {
|
|
180
|
+
section.innerHTML += renderProviderRow(p);
|
|
181
|
+
}
|
|
182
|
+
container.appendChild(section);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sttSelect = $("stt-provider");
|
|
186
|
+
const ttsSelect = $("tts-provider");
|
|
187
|
+
const prevStt = sttSelect.value;
|
|
188
|
+
const prevTts = ttsSelect.value;
|
|
189
|
+
sttSelect.innerHTML = "";
|
|
190
|
+
ttsSelect.innerHTML = "";
|
|
191
|
+
|
|
192
|
+
const sttEngines = engines.filter((p) => p.type === "stt");
|
|
193
|
+
const ttsEngines = engines.filter((p) => p.type === "tts");
|
|
194
|
+
|
|
195
|
+
for (const p of sttEngines) {
|
|
196
|
+
const option = document.createElement("option");
|
|
197
|
+
option.value = p.name;
|
|
198
|
+
const label = p.display_name || p.name;
|
|
199
|
+
option.textContent = p.healthy ? label : `${label} (unhealthy)`;
|
|
200
|
+
option.disabled = !p.healthy;
|
|
201
|
+
sttSelect.appendChild(option);
|
|
202
|
+
}
|
|
203
|
+
for (const p of ttsEngines) {
|
|
204
|
+
const option = document.createElement("option");
|
|
205
|
+
option.value = p.name;
|
|
206
|
+
const label = p.display_name || p.name;
|
|
207
|
+
option.textContent = p.healthy ? label : `${label} (unhealthy)`;
|
|
208
|
+
option.disabled = !p.healthy;
|
|
209
|
+
ttsSelect.appendChild(option);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pickDefault = (select, list, prev) => {
|
|
213
|
+
if (prev && list.some((p) => p.name === prev && p.healthy)) {
|
|
214
|
+
select.value = prev;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const firstHealthy = list.find((p) => p.healthy);
|
|
218
|
+
if (firstHealthy) {
|
|
219
|
+
select.value = firstHealthy.name;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (list.length > 0) {
|
|
223
|
+
select.value = list[0].name;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
pickDefault(sttSelect, sttEngines, prevStt);
|
|
227
|
+
pickDefault(ttsSelect, ttsEngines, prevTts);
|
|
228
|
+
loadTTSVoices(ttsSelect.value);
|
|
229
|
+
loadTTSFieldOptions(ttsSelect.value);
|
|
230
|
+
loadSTTFieldOptions(sttSelect.value);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Backward compatibility alias
|
|
234
|
+
function renderProviders(providers) { return renderEngines(providers); }
|
|
235
|
+
|
|
236
|
+
async function loadTTSVoices(provider) {
|
|
237
|
+
const voiceLabel = $("tts-voice-label");
|
|
238
|
+
const voiceSelect = $("tts-voice");
|
|
239
|
+
voiceSelect.innerHTML = "";
|
|
240
|
+
const pushVoiceOption = (value, label, title = "") => {
|
|
241
|
+
if (!value) return;
|
|
242
|
+
const existing = Array.from(voiceSelect.options).some((o) => o.value === value);
|
|
243
|
+
if (existing) return;
|
|
244
|
+
const opt = document.createElement("option");
|
|
245
|
+
opt.value = value;
|
|
246
|
+
opt.textContent = label || value;
|
|
247
|
+
if (title) opt.title = title;
|
|
248
|
+
voiceSelect.appendChild(opt);
|
|
249
|
+
};
|
|
250
|
+
if (!provider) {
|
|
251
|
+
voiceLabel.classList.add("hidden");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const engineInfo = (state.engines || []).find((e) => e.name === provider) || {};
|
|
255
|
+
const configuredVoice =
|
|
256
|
+
(engineInfo.display_info && (
|
|
257
|
+
engineInfo.display_info.voice_id ||
|
|
258
|
+
engineInfo.display_info.voice ||
|
|
259
|
+
engineInfo.display_info.voice_name ||
|
|
260
|
+
engineInfo.display_info.voice_type
|
|
261
|
+
)) || "";
|
|
262
|
+
const providerKey = engineInfo.provider || provider;
|
|
263
|
+
const ensureTemplateCache = async () => {
|
|
264
|
+
if (providerTemplates.length > 0) return;
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch('/v1/admin/provider-templates');
|
|
267
|
+
providerTemplates = (await res.json()).templates || [];
|
|
268
|
+
} catch (_) {}
|
|
269
|
+
};
|
|
270
|
+
const addFallbackVoices = async () => {
|
|
271
|
+
if (configuredVoice) {
|
|
272
|
+
pushVoiceOption(String(configuredVoice), `Configured (${configuredVoice})`);
|
|
273
|
+
}
|
|
274
|
+
await ensureTemplateCache();
|
|
275
|
+
const tpl = providerTemplates.find((t) => t.provider === providerKey);
|
|
276
|
+
if (tpl && tpl.defaults) {
|
|
277
|
+
const defaultVoice = tpl.defaults.voice_id || tpl.defaults.voice || "";
|
|
278
|
+
if (defaultVoice) {
|
|
279
|
+
pushVoiceOption(String(defaultVoice), `Default (${defaultVoice})`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (voiceSelect.options.length > 0) {
|
|
283
|
+
voiceLabel.classList.remove("hidden");
|
|
284
|
+
} else {
|
|
285
|
+
voiceLabel.classList.add("hidden");
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
const data = await fetchJson(`/v1/tts/${provider}/voices`);
|
|
290
|
+
const voices = data.voices || [];
|
|
291
|
+
if (voices.length === 0) {
|
|
292
|
+
await addFallbackVoices();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
voiceLabel.classList.remove("hidden");
|
|
296
|
+
const groups = {};
|
|
297
|
+
for (const v of voices) {
|
|
298
|
+
const g = v.group || "";
|
|
299
|
+
if (!groups[g]) groups[g] = [];
|
|
300
|
+
groups[g].push(v);
|
|
301
|
+
}
|
|
302
|
+
const sortedKeys = Object.keys(groups).sort((a, b) => {
|
|
303
|
+
if (a === "中文") return -1;
|
|
304
|
+
if (b === "中文") return 1;
|
|
305
|
+
if (a === "English") return -1;
|
|
306
|
+
if (b === "English") return 1;
|
|
307
|
+
return a.localeCompare(b);
|
|
308
|
+
});
|
|
309
|
+
for (const key of sortedKeys) {
|
|
310
|
+
const optgroup = document.createElement("optgroup");
|
|
311
|
+
optgroup.label = key || "Default";
|
|
312
|
+
for (const v of groups[key]) {
|
|
313
|
+
const opt = document.createElement("option");
|
|
314
|
+
opt.value = v.id || v.name;
|
|
315
|
+
opt.textContent = v.name || v.id;
|
|
316
|
+
if (v.description) opt.title = v.description;
|
|
317
|
+
optgroup.appendChild(opt);
|
|
318
|
+
}
|
|
319
|
+
voiceSelect.appendChild(optgroup);
|
|
320
|
+
}
|
|
321
|
+
} catch (_) {
|
|
322
|
+
await addFallbackVoices();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function maybeAutoAdjustTTSTransportByModel() {
|
|
327
|
+
const providerAlias = $("tts-provider").value;
|
|
328
|
+
const modelSel = $("tts-model");
|
|
329
|
+
const transportSel = $("tts-transport");
|
|
330
|
+
if (!providerAlias || !modelSel || !transportSel) return;
|
|
331
|
+
const engineInfo = (state.engines || []).find((e) => e.name === providerAlias) || {};
|
|
332
|
+
const providerKey = (engineInfo.provider || providerAlias || "").toLowerCase();
|
|
333
|
+
if (providerKey !== "elevenlabs") return;
|
|
334
|
+
const model = String(modelSel.value || "").trim();
|
|
335
|
+
if (!model) return;
|
|
336
|
+
|
|
337
|
+
const values = new Set(Array.from(transportSel.options).map((o) => String(o.value)));
|
|
338
|
+
if (model === "eleven_v3") {
|
|
339
|
+
if (values.has("http")) transportSel.value = "http";
|
|
340
|
+
} else if (model.startsWith("eleven_")) {
|
|
341
|
+
if (values.has("ws")) transportSel.value = "ws";
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function loadTTSFieldOptions(engineAlias) {
|
|
346
|
+
const fields = {
|
|
347
|
+
model: $("tts-model"),
|
|
348
|
+
stream_transport: $("tts-transport"),
|
|
349
|
+
};
|
|
350
|
+
const keyAliases = {
|
|
351
|
+
model_id: "model",
|
|
352
|
+
model: "model",
|
|
353
|
+
stream_transport: "stream_transport",
|
|
354
|
+
};
|
|
355
|
+
for (const sel of Object.values(fields)) {
|
|
356
|
+
if (!sel) continue;
|
|
357
|
+
sel.innerHTML = '<option value="">(auto)</option>';
|
|
358
|
+
}
|
|
359
|
+
if (!engineAlias) return;
|
|
360
|
+
try {
|
|
361
|
+
if (providerTemplates.length === 0) {
|
|
362
|
+
const res = await fetch('/v1/admin/provider-templates');
|
|
363
|
+
providerTemplates = (await res.json()).templates || [];
|
|
364
|
+
}
|
|
365
|
+
const engineInfo = (state.engines || []).find(e => e.name === engineAlias);
|
|
366
|
+
const providerKey = (engineInfo && engineInfo.provider) || engineAlias;
|
|
367
|
+
const configuredModel = String((engineInfo && engineInfo.display_info && engineInfo.display_info.model) || "");
|
|
368
|
+
const configuredTransport = String((engineInfo && engineInfo.display_info && engineInfo.display_info.stream_transport) || "");
|
|
369
|
+
const tpl = providerTemplates.find(t => t.provider === providerKey);
|
|
370
|
+
if (!tpl || !tpl.field_options) return;
|
|
371
|
+
|
|
372
|
+
for (const [key, opts] of Object.entries(tpl.field_options)) {
|
|
373
|
+
const mapped = keyAliases[key] || key;
|
|
374
|
+
const sel = fields[mapped];
|
|
375
|
+
if (!sel || !opts || opts.length === 0) continue;
|
|
376
|
+
const existing = new Set(Array.from(sel.options).map(o => String(o.value)));
|
|
377
|
+
for (const o of opts) {
|
|
378
|
+
const v = String(o);
|
|
379
|
+
if (existing.has(v)) continue;
|
|
380
|
+
const opt = document.createElement("option");
|
|
381
|
+
opt.value = v;
|
|
382
|
+
opt.textContent = v;
|
|
383
|
+
sel.appendChild(opt);
|
|
384
|
+
existing.add(v);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (configuredModel && fields.model) {
|
|
388
|
+
const has = Array.from(fields.model.options).some((o) => o.value === configuredModel);
|
|
389
|
+
if (has) fields.model.value = configuredModel;
|
|
390
|
+
}
|
|
391
|
+
if (configuredTransport && fields.stream_transport) {
|
|
392
|
+
const has = Array.from(fields.stream_transport.options).some((o) => o.value === configuredTransport);
|
|
393
|
+
if (has) fields.stream_transport.value = configuredTransport;
|
|
394
|
+
}
|
|
395
|
+
maybeAutoAdjustTTSTransportByModel();
|
|
396
|
+
} catch (_) {}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function loadSTTFieldOptions(engineAlias) {
|
|
400
|
+
const fields = { language: $("stt-language"), model: $("stt-model"), device: $("stt-device") };
|
|
401
|
+
// Normalize provider-specific option keys to Lab's generic STT controls.
|
|
402
|
+
// Example: elevenlabs-stt exposes language_code/model_id, while Lab uses language/model.
|
|
403
|
+
const keyAliases = {
|
|
404
|
+
language: "language",
|
|
405
|
+
language_code: "language",
|
|
406
|
+
model: "model",
|
|
407
|
+
model_id: "model",
|
|
408
|
+
model_name: "model",
|
|
409
|
+
device: "device",
|
|
410
|
+
compute_type: "device",
|
|
411
|
+
};
|
|
412
|
+
// Reset all to just (auto)
|
|
413
|
+
for (const sel of Object.values(fields)) {
|
|
414
|
+
sel.innerHTML = '<option value="">(auto)</option>';
|
|
415
|
+
}
|
|
416
|
+
if (!engineAlias) return;
|
|
417
|
+
try {
|
|
418
|
+
// Ensure providerTemplates are loaded
|
|
419
|
+
if (providerTemplates.length === 0) {
|
|
420
|
+
const res = await fetch('/v1/admin/provider-templates');
|
|
421
|
+
providerTemplates = (await res.json()).templates || [];
|
|
422
|
+
}
|
|
423
|
+
// Look up provider key from cached engine list (set by fetchEngineStatus)
|
|
424
|
+
const engineInfo = (state.engines || []).find(e => e.name === engineAlias);
|
|
425
|
+
const providerKey = (engineInfo && engineInfo.provider) || engineAlias;
|
|
426
|
+
const tpl = providerTemplates.find(t => t.provider === providerKey);
|
|
427
|
+
if (!tpl || !tpl.field_options) return;
|
|
428
|
+
for (const [key, opts] of Object.entries(tpl.field_options)) {
|
|
429
|
+
const normalizedKey = keyAliases[key] || key;
|
|
430
|
+
const sel = fields[normalizedKey];
|
|
431
|
+
if (!sel || !opts || opts.length === 0) continue;
|
|
432
|
+
const existing = new Set(Array.from(sel.options).map(o => String(o.value)));
|
|
433
|
+
for (const o of opts) {
|
|
434
|
+
const v = String(o);
|
|
435
|
+
if (existing.has(v)) continue;
|
|
436
|
+
const opt = document.createElement('option');
|
|
437
|
+
opt.value = v;
|
|
438
|
+
opt.textContent = v;
|
|
439
|
+
sel.appendChild(opt);
|
|
440
|
+
existing.add(v);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} catch (_) {}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function renderTasks(tasks) {
|
|
447
|
+
const body = $("tasks-table");
|
|
448
|
+
if (!body) return;
|
|
449
|
+
body.innerHTML = "";
|
|
450
|
+
for (const t of tasks) {
|
|
451
|
+
const tr = document.createElement("tr");
|
|
452
|
+
tr.innerHTML = `<td>${t.task_id.slice(0, 12)}</td><td>${t.action}</td><td>${t.status}</td><td>${t.phase}</td><td>${t.message}</td>`;
|
|
453
|
+
tr.addEventListener("click", () => {
|
|
454
|
+
$("task-id-input").value = t.task_id;
|
|
455
|
+
});
|
|
456
|
+
body.appendChild(tr);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function refreshDashboard() {
|
|
461
|
+
$("dashboard-status").textContent = "syncing";
|
|
462
|
+
const [enginesData, healthData, tasksData] = await Promise.all([
|
|
463
|
+
fetchJson("/v1/engines"),
|
|
464
|
+
fetchJson("/v1/health"),
|
|
465
|
+
fetchJson("/v1/engines/tasks?limit=20"),
|
|
466
|
+
]);
|
|
467
|
+
|
|
468
|
+
const engines = enginesData.engines || [];
|
|
469
|
+
state.engines = engines;
|
|
470
|
+
renderEngines(engines);
|
|
471
|
+
|
|
472
|
+
const healthMap = healthData.engines || {};
|
|
473
|
+
const healthyCount = Object.values(healthMap).filter(Boolean).length;
|
|
474
|
+
const runningTasks = (tasksData.tasks || []).filter((t) => t.status === "running").length;
|
|
475
|
+
|
|
476
|
+
setText("stat-providers", engines.length);
|
|
477
|
+
setText("stat-healthy", `${healthyCount}/${engines.length}`);
|
|
478
|
+
setText("stat-running-tasks", runningTasks);
|
|
479
|
+
setText("stat-updated-at", nowLabel());
|
|
480
|
+
renderTasks(tasksData.tasks || []);
|
|
481
|
+
|
|
482
|
+
$("dashboard-status").textContent = "ready";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function runSTT(e) {
|
|
486
|
+
e.preventDefault();
|
|
487
|
+
if (sttMode() !== "file") return;
|
|
488
|
+
const file = $("stt-file").files[0];
|
|
489
|
+
const provider = $("stt-provider").value;
|
|
490
|
+
if (!file || !provider) return;
|
|
491
|
+
const language = $("stt-language").value.trim();
|
|
492
|
+
const model = $("stt-model").value.trim();
|
|
493
|
+
const device = $("stt-device").value.trim();
|
|
494
|
+
const beamSizeRaw = $("stt-beam-size").value.trim();
|
|
495
|
+
|
|
496
|
+
const form = new FormData();
|
|
497
|
+
form.append("provider", provider);
|
|
498
|
+
form.append("audio", file, file.name);
|
|
499
|
+
if (language) form.append("language", language);
|
|
500
|
+
if (model) form.append("model", model);
|
|
501
|
+
if (device) form.append("device", device);
|
|
502
|
+
if (beamSizeRaw) form.append("beam_size", beamSizeRaw);
|
|
503
|
+
setText("stt-stream-text", "");
|
|
504
|
+
setText("stt-result", "Running STT...");
|
|
505
|
+
|
|
506
|
+
const sttT0 = performance.now();
|
|
507
|
+
labLog("info", `[STT] Start file transcription — provider=${provider}, file=${file.name} (${(file.size/1024).toFixed(1)} KB)`);
|
|
508
|
+
labLogV("data", `[STT] language=${language || "(auto)"}, model=${model || "(auto)"}, device=${device || "(auto)"}, beam_size=${beamSizeRaw || "(default)"}`);
|
|
509
|
+
labLogV("data", `[STT] POST /v1/stt/transcribe`);
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const data = await fetchJson("/v1/stt/transcribe", { method: "POST", body: form });
|
|
513
|
+
const elapsed = Math.round(performance.now() - sttT0);
|
|
514
|
+
setText("stt-stream-text", String(data.text || ""));
|
|
515
|
+
setText("stt-result", JSON.stringify(data, null, 2));
|
|
516
|
+
labLog("info", `[STT] Completed in ${elapsed}ms — ${(data.text || "").length} chars, lang=${data.language || "?"}`);
|
|
517
|
+
labLogV("data", `[STT] confidence=${data.confidence ?? "N/A"}, duration=${data.duration_ms ?? "N/A"}ms`);
|
|
518
|
+
labLogV("data", `[STT] text: "${(data.text || "").slice(0, 200)}${(data.text || "").length > 200 ? "..." : ""}"`);
|
|
519
|
+
if (data.words && data.words.length > 0) {
|
|
520
|
+
labLogV("data", `[STT] words: ${data.words.length} items`);
|
|
521
|
+
}
|
|
522
|
+
} catch (err) {
|
|
523
|
+
const elapsed = Math.round(performance.now() - sttT0);
|
|
524
|
+
setText("stt-result", `Error: ${err.message}`);
|
|
525
|
+
labLog("error", `[STT] Failed after ${elapsed}ms — ${err.message}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function streamTextMerge(prev, incoming) {
|
|
530
|
+
const p = (prev || "").trim();
|
|
531
|
+
const n = (incoming || "").trim();
|
|
532
|
+
if (!n) return p;
|
|
533
|
+
if (!p) return n;
|
|
534
|
+
if (n === p) return p;
|
|
535
|
+
if (n.startsWith(p)) return n;
|
|
536
|
+
if (p.startsWith(n)) return p;
|
|
537
|
+
if (p.includes(n)) return p;
|
|
538
|
+
return `${p} ${n}`.replace(/\s+/g, " ").trim();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function streamTextSnapshot(prev, incoming) {
|
|
542
|
+
const p = (prev || "").trim();
|
|
543
|
+
const n = (incoming || "").trim();
|
|
544
|
+
if (!n) return p;
|
|
545
|
+
if (!p) return n;
|
|
546
|
+
if (n === p) return p;
|
|
547
|
+
// For snapshot-style streaming, incoming text is the latest full hypothesis.
|
|
548
|
+
// Replacing avoids duplicate growth when decoder revises earlier tokens.
|
|
549
|
+
return n;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function setSTTLiveStatus(text) {
|
|
553
|
+
$("stt-live-status").textContent = text;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function stopSTTLiveMetricsTimer() {
|
|
557
|
+
if (state.sttLiveMetricsTimer) {
|
|
558
|
+
clearInterval(state.sttLiveMetricsTimer);
|
|
559
|
+
state.sttLiveMetricsTimer = null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function formatRate(bytesPerSec) {
|
|
564
|
+
const v = Number(bytesPerSec || 0);
|
|
565
|
+
if (v <= 0) return "0 B/s";
|
|
566
|
+
if (v >= 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(2)} MB/s`;
|
|
567
|
+
if (v >= 1024) return `${(v / 1024).toFixed(1)} KB/s`;
|
|
568
|
+
return `${Math.round(v)} B/s`;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function renderLiveMetrics(ctx, extras = {}) {
|
|
572
|
+
const now = performance.now();
|
|
573
|
+
const startedAt = Number(ctx?.startedAt || now);
|
|
574
|
+
const elapsedMs = Math.max(0, Math.round(now - startedAt));
|
|
575
|
+
const sentBytes = Number(ctx?.sentBytes || 0);
|
|
576
|
+
const sentChunks = Number(ctx?.sentChunks || 0);
|
|
577
|
+
const bps = elapsedMs > 0 ? (sentBytes * 1000) / elapsedMs : 0;
|
|
578
|
+
const firstTokenLatencyMs = ctx?.firstTokenAt ? Math.max(0, Math.round(ctx.firstTokenAt - startedAt)) : null;
|
|
579
|
+
|
|
580
|
+
setText("stt-live-metrics", JSON.stringify({
|
|
581
|
+
provider: ctx?.provider || null,
|
|
582
|
+
transport: ctx?.transport || null,
|
|
583
|
+
status: ctx?.status || null,
|
|
584
|
+
elapsed_ms: elapsedMs,
|
|
585
|
+
sent_bytes: sentBytes,
|
|
586
|
+
sent_chunks: sentChunks,
|
|
587
|
+
upload_rate: formatRate(bps),
|
|
588
|
+
first_token_latency_ms: firstTokenLatencyMs,
|
|
589
|
+
...extras,
|
|
590
|
+
}, null, 2));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function startSTTLiveMetricsTimer(ctx, extrasFactory = () => ({})) {
|
|
594
|
+
stopSTTLiveMetricsTimer();
|
|
595
|
+
state.sttLiveMetricsTimer = setInterval(() => {
|
|
596
|
+
if (state.sttLive !== ctx) {
|
|
597
|
+
stopSTTLiveMetricsTimer();
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
renderLiveMetrics(ctx, extrasFactory());
|
|
601
|
+
}, 250);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function wsUrl(path, params = {}) {
|
|
605
|
+
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
606
|
+
const u = new URL(`${proto}//${window.location.host}${path}`);
|
|
607
|
+
Object.entries(params).forEach(([k, v]) => {
|
|
608
|
+
if (v != null && String(v).trim() !== "") u.searchParams.set(k, String(v));
|
|
609
|
+
});
|
|
610
|
+
return u.toString();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function downsampleFloat32(input, inputRate, outputRate) {
|
|
614
|
+
if (!input || input.length === 0) return new Float32Array(0);
|
|
615
|
+
if (outputRate >= inputRate) return input;
|
|
616
|
+
const ratio = inputRate / outputRate;
|
|
617
|
+
const outLen = Math.max(1, Math.round(input.length / ratio));
|
|
618
|
+
const out = new Float32Array(outLen);
|
|
619
|
+
let inOffset = 0;
|
|
620
|
+
for (let i = 0; i < outLen; i += 1) {
|
|
621
|
+
const nextInOffset = Math.min(input.length, Math.round((i + 1) * ratio));
|
|
622
|
+
let acc = 0;
|
|
623
|
+
let count = 0;
|
|
624
|
+
for (let j = inOffset; j < nextInOffset; j += 1) {
|
|
625
|
+
acc += input[j];
|
|
626
|
+
count += 1;
|
|
627
|
+
}
|
|
628
|
+
out[i] = count > 0 ? acc / count : 0;
|
|
629
|
+
inOffset = nextInOffset;
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function float32ToPCM16Buffer(input) {
|
|
635
|
+
const ab = new ArrayBuffer(input.length * 2);
|
|
636
|
+
const view = new DataView(ab);
|
|
637
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
638
|
+
let s = Math.max(-1, Math.min(1, input[i]));
|
|
639
|
+
s = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
640
|
+
view.setInt16(i * 2, s | 0, true);
|
|
641
|
+
}
|
|
642
|
+
return ab;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function pickRecorderMimeType() {
|
|
646
|
+
const candidates = [
|
|
647
|
+
"audio/webm;codecs=opus",
|
|
648
|
+
"audio/webm",
|
|
649
|
+
"audio/mp4",
|
|
650
|
+
];
|
|
651
|
+
for (const t of candidates) {
|
|
652
|
+
if (window.MediaRecorder && MediaRecorder.isTypeSupported && MediaRecorder.isTypeSupported(t)) {
|
|
653
|
+
return t;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return "";
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function transcribeLiveSnapshot(ctx, phase = "streaming") {
|
|
660
|
+
if (!ctx || ctx.transcribing) return;
|
|
661
|
+
if (!ctx.chunks || ctx.chunks.length === 0) return;
|
|
662
|
+
ctx.transcribing = true;
|
|
663
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - ctx.startedAt));
|
|
664
|
+
try {
|
|
665
|
+
const blob = new Blob(ctx.chunks, { type: ctx.mimeType || "audio/webm" });
|
|
666
|
+
if (blob.size === 0) return;
|
|
667
|
+
labLogV("data", `[STT] HTTP snapshot (${phase}) — ${ctx.chunks.length} chunks, ${(blob.size/1024).toFixed(1)} KB`);
|
|
668
|
+
const form = new FormData();
|
|
669
|
+
form.append("provider", ctx.provider);
|
|
670
|
+
form.append("audio", blob, "live.webm");
|
|
671
|
+
if (ctx.language) form.append("language", ctx.language);
|
|
672
|
+
if (ctx.model) form.append("model", ctx.model);
|
|
673
|
+
if (ctx.device) form.append("device", ctx.device);
|
|
674
|
+
if (ctx.beamSizeRaw) form.append("beam_size", ctx.beamSizeRaw);
|
|
675
|
+
const data = await fetchJson("/v1/stt/transcribe", { method: "POST", body: form });
|
|
676
|
+
ctx.lastDurationMs = data.duration_ms ?? null;
|
|
677
|
+
ctx.lastLanguage = data.language ?? null;
|
|
678
|
+
const text = String((data && data.text) || "").trim();
|
|
679
|
+
if (text) {
|
|
680
|
+
if (!ctx.firstTokenAt) {
|
|
681
|
+
ctx.firstTokenAt = performance.now();
|
|
682
|
+
labLog("info", `[STT] First token received — latency=${Math.round(ctx.firstTokenAt - ctx.startedAt)}ms`);
|
|
683
|
+
}
|
|
684
|
+
state.sttLiveText = streamTextSnapshot(state.sttLiveText, text);
|
|
685
|
+
setText("stt-stream-text", state.sttLiveText);
|
|
686
|
+
labLogV("data", `[STT] Snapshot result: "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}" (lang=${data.language ?? "?"})`);
|
|
687
|
+
}
|
|
688
|
+
setText("stt-result", JSON.stringify({
|
|
689
|
+
provider: ctx.provider,
|
|
690
|
+
mode: "live",
|
|
691
|
+
status: phase,
|
|
692
|
+
elapsed_ms: elapsedMs,
|
|
693
|
+
chunks: ctx.chunks.length,
|
|
694
|
+
last_duration_ms: data.duration_ms ?? null,
|
|
695
|
+
language: data.language ?? null,
|
|
696
|
+
}, null, 2));
|
|
697
|
+
} catch (err) {
|
|
698
|
+
setSTTLiveStatus("error");
|
|
699
|
+
setText("stt-result", `Error: ${err.message}`);
|
|
700
|
+
} finally {
|
|
701
|
+
ctx.transcribing = false;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function cleanupLiveContext(ctx) {
|
|
706
|
+
if (!ctx) return;
|
|
707
|
+
try {
|
|
708
|
+
if (ctx.noTokenFallbackTimer) {
|
|
709
|
+
clearTimeout(ctx.noTokenFallbackTimer);
|
|
710
|
+
ctx.noTokenFallbackTimer = null;
|
|
711
|
+
}
|
|
712
|
+
} catch (_) {}
|
|
713
|
+
try {
|
|
714
|
+
if (ctx.stream) ctx.stream.getTracks().forEach((t) => t.stop());
|
|
715
|
+
} catch (_) {}
|
|
716
|
+
try {
|
|
717
|
+
if (ctx.processor) ctx.processor.disconnect();
|
|
718
|
+
} catch (_) {}
|
|
719
|
+
try {
|
|
720
|
+
if (ctx.source) ctx.source.disconnect();
|
|
721
|
+
} catch (_) {}
|
|
722
|
+
try {
|
|
723
|
+
if (ctx.gain) ctx.gain.disconnect();
|
|
724
|
+
} catch (_) {}
|
|
725
|
+
try {
|
|
726
|
+
if (ctx.audioContext && ctx.audioContext.state !== "closed") {
|
|
727
|
+
ctx.audioContext.close().catch(() => {});
|
|
728
|
+
}
|
|
729
|
+
} catch (_) {}
|
|
730
|
+
try {
|
|
731
|
+
if (ctx.ws && (ctx.ws.readyState === WebSocket.OPEN || ctx.ws.readyState === WebSocket.CONNECTING)) {
|
|
732
|
+
ctx.ws.close();
|
|
733
|
+
}
|
|
734
|
+
} catch (_) {}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function startLiveSTTFallback(provider, language, model, device, beamSizeRaw) {
|
|
738
|
+
if (!window.MediaRecorder) {
|
|
739
|
+
throw new Error("MediaRecorder is not supported in this browser");
|
|
740
|
+
}
|
|
741
|
+
let stream;
|
|
742
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
743
|
+
audio: {
|
|
744
|
+
channelCount: 1,
|
|
745
|
+
echoCancellation: true,
|
|
746
|
+
noiseSuppression: true,
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
const mimeType = pickRecorderMimeType();
|
|
750
|
+
const recorder = mimeType
|
|
751
|
+
? new MediaRecorder(stream, { mimeType })
|
|
752
|
+
: new MediaRecorder(stream);
|
|
753
|
+
const startedAt = performance.now();
|
|
754
|
+
const ctx = {
|
|
755
|
+
provider,
|
|
756
|
+
language,
|
|
757
|
+
model,
|
|
758
|
+
device,
|
|
759
|
+
beamSizeRaw,
|
|
760
|
+
stream,
|
|
761
|
+
recorder,
|
|
762
|
+
mimeType,
|
|
763
|
+
chunks: [],
|
|
764
|
+
transcribing: false,
|
|
765
|
+
startedAt,
|
|
766
|
+
transport: "http-chunk",
|
|
767
|
+
sentBytes: 0,
|
|
768
|
+
sentChunks: 0,
|
|
769
|
+
firstTokenAt: null,
|
|
770
|
+
status: "recording",
|
|
771
|
+
};
|
|
772
|
+
state.sttLive = ctx;
|
|
773
|
+
startSTTLiveMetricsTimer(ctx, () => ({
|
|
774
|
+
mode: "live",
|
|
775
|
+
chunks_buffered: ctx.chunks.length,
|
|
776
|
+
text_chars: state.sttLiveText.length,
|
|
777
|
+
last_duration_ms: ctx.lastDurationMs ?? null,
|
|
778
|
+
language: ctx.lastLanguage ?? ctx.language ?? null,
|
|
779
|
+
}));
|
|
780
|
+
|
|
781
|
+
recorder.onstart = () => {
|
|
782
|
+
setSTTLiveStatus("recording");
|
|
783
|
+
setText("stt-result", JSON.stringify({
|
|
784
|
+
provider,
|
|
785
|
+
mode: "live",
|
|
786
|
+
status: "recording",
|
|
787
|
+
transport: "http-chunk",
|
|
788
|
+
}, null, 2));
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
recorder.ondataavailable = async (event) => {
|
|
792
|
+
if (!event.data || event.data.size <= 0) return;
|
|
793
|
+
if (state.sttLive !== ctx) return;
|
|
794
|
+
ctx.sentBytes += Number(event.data.size || 0);
|
|
795
|
+
ctx.sentChunks += 1;
|
|
796
|
+
ctx.chunks.push(event.data);
|
|
797
|
+
await transcribeLiveSnapshot(ctx, "streaming");
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
recorder.onerror = (event) => {
|
|
801
|
+
const err = event && event.error ? event.error.message || String(event.error) : "recorder error";
|
|
802
|
+
setSTTLiveStatus("error");
|
|
803
|
+
setText("stt-result", `Error: ${err}`);
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
recorder.onstop = async () => {
|
|
807
|
+
if (state.sttLive === ctx) {
|
|
808
|
+
ctx.status = "closing";
|
|
809
|
+
await transcribeLiveSnapshot(ctx, "closed");
|
|
810
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
811
|
+
setText("stt-result", JSON.stringify({
|
|
812
|
+
provider,
|
|
813
|
+
mode: "live",
|
|
814
|
+
status: "closed",
|
|
815
|
+
transport: "http-chunk",
|
|
816
|
+
elapsed_ms: elapsedMs,
|
|
817
|
+
text: state.sttLiveText,
|
|
818
|
+
}, null, 2));
|
|
819
|
+
setSTTLiveStatus("stopped");
|
|
820
|
+
ctx.status = "stopped";
|
|
821
|
+
renderLiveMetrics(ctx, {
|
|
822
|
+
mode: "live",
|
|
823
|
+
chunks_buffered: ctx.chunks.length,
|
|
824
|
+
text_chars: state.sttLiveText.length,
|
|
825
|
+
last_duration_ms: ctx.lastDurationMs ?? null,
|
|
826
|
+
language: ctx.lastLanguage ?? ctx.language ?? null,
|
|
827
|
+
});
|
|
828
|
+
stopSTTLiveMetricsTimer();
|
|
829
|
+
state.sttLive = null;
|
|
830
|
+
}
|
|
831
|
+
cleanupLiveContext(ctx);
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
recorder.start(1200);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function startLiveSTTWebSocket(provider, language, model, device, beamSizeRaw) {
|
|
838
|
+
const targetRate = 16000;
|
|
839
|
+
const startedAt = performance.now();
|
|
840
|
+
|
|
841
|
+
// Create WebSocket immediately — handshake runs in parallel with getUserMedia
|
|
842
|
+
const ws = new WebSocket(
|
|
843
|
+
wsUrl("/v1/stt/stream", { provider, language: language || "", sample_rate: targetRate }),
|
|
844
|
+
);
|
|
845
|
+
ws.binaryType = "arraybuffer";
|
|
846
|
+
|
|
847
|
+
// Buffer early messages (e.g. "meta") that arrive before onmessage handler is set
|
|
848
|
+
const earlyMessages = [];
|
|
849
|
+
ws.onmessage = (event) => { earlyMessages.push(event); };
|
|
850
|
+
|
|
851
|
+
const wsOpenPromise = new Promise((resolve, reject) => {
|
|
852
|
+
let timer = window.setTimeout(() => reject(new Error("WebSocket connect timeout")), 6000);
|
|
853
|
+
ws.onopen = () => { clearTimeout(timer); timer = 0; resolve(); };
|
|
854
|
+
ws.onerror = () => {
|
|
855
|
+
if (timer) { clearTimeout(timer); timer = 0; reject(new Error("WebSocket connection failed")); }
|
|
856
|
+
};
|
|
857
|
+
ws.onclose = () => {
|
|
858
|
+
if (timer) { clearTimeout(timer); timer = 0; reject(new Error("WebSocket closed during setup")); }
|
|
859
|
+
};
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Run getUserMedia and WebSocket handshake in parallel to overlap
|
|
863
|
+
// mic permission prompt with server-side WS setup + provider eager-start
|
|
864
|
+
const micPromise = navigator.mediaDevices.getUserMedia({
|
|
865
|
+
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
|
866
|
+
});
|
|
867
|
+
let stream;
|
|
868
|
+
try {
|
|
869
|
+
[stream] = await Promise.all([micPromise, wsOpenPromise]);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
// Clean up mic stream if getUserMedia already resolved
|
|
872
|
+
micPromise.then(s => s.getTracks().forEach(t => t.stop())).catch(() => {});
|
|
873
|
+
try { ws.close(); } catch (_) {}
|
|
874
|
+
throw err;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
878
|
+
await audioContext.resume();
|
|
879
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
880
|
+
// bufferSize=2048 → ~42ms per frame at 48kHz, ~1280 bytes after
|
|
881
|
+
// downsampling to 16kHz PCM16 — matches iFlytek's recommended 40ms frame.
|
|
882
|
+
const processor = audioContext.createScriptProcessor(2048, 1, 1);
|
|
883
|
+
const gain = audioContext.createGain();
|
|
884
|
+
gain.gain.value = 0.0;
|
|
885
|
+
|
|
886
|
+
setSTTLiveStatus("recording");
|
|
887
|
+
labLogV("data", `[STT] WS opened — audioContext.sampleRate=${audioContext.sampleRate}, targetRate=${targetRate}`);
|
|
888
|
+
setText("stt-result", JSON.stringify({
|
|
889
|
+
provider,
|
|
890
|
+
mode: "live",
|
|
891
|
+
status: "recording",
|
|
892
|
+
transport: "ws",
|
|
893
|
+
audio_context_rate: audioContext.sampleRate,
|
|
894
|
+
target_rate: targetRate,
|
|
895
|
+
}, null, 2));
|
|
896
|
+
|
|
897
|
+
const ctx = {
|
|
898
|
+
provider,
|
|
899
|
+
language,
|
|
900
|
+
stream,
|
|
901
|
+
audioContext,
|
|
902
|
+
source,
|
|
903
|
+
processor,
|
|
904
|
+
gain,
|
|
905
|
+
ws,
|
|
906
|
+
startedAt,
|
|
907
|
+
transport: "ws",
|
|
908
|
+
stopping: false,
|
|
909
|
+
sentBytes: 0,
|
|
910
|
+
sentChunks: 0,
|
|
911
|
+
firstTokenAt: null,
|
|
912
|
+
status: "recording",
|
|
913
|
+
noTokenFallbackTimer: null,
|
|
914
|
+
isBatchMode: false,
|
|
915
|
+
closedPromiseResolve: null,
|
|
916
|
+
};
|
|
917
|
+
state.sttLive = ctx;
|
|
918
|
+
startSTTLiveMetricsTimer(ctx, () => ({
|
|
919
|
+
mode: "live",
|
|
920
|
+
text_chars: state.sttLiveText.length,
|
|
921
|
+
last_event: ctx.lastEventType || null,
|
|
922
|
+
ws_ready_state: Number(ctx.ws?.readyState ?? -1),
|
|
923
|
+
}));
|
|
924
|
+
|
|
925
|
+
processor.onaudioprocess = (event) => {
|
|
926
|
+
if (state.sttLive !== ctx) return;
|
|
927
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
928
|
+
const input = event.inputBuffer.getChannelData(0);
|
|
929
|
+
const mono = downsampleFloat32(input, audioContext.sampleRate, targetRate);
|
|
930
|
+
if (!mono || mono.length === 0) return;
|
|
931
|
+
const pcm = float32ToPCM16Buffer(mono);
|
|
932
|
+
ws.send(pcm);
|
|
933
|
+
ctx.sentBytes += Number(pcm.byteLength || 0);
|
|
934
|
+
ctx.sentChunks += 1;
|
|
935
|
+
if (ctx.sentChunks % 50 === 0) {
|
|
936
|
+
labLogV("data", `[STT] Sent ${ctx.sentChunks} chunks, ${(ctx.sentBytes/1024).toFixed(1)} KB`);
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
source.connect(processor);
|
|
941
|
+
processor.connect(gain);
|
|
942
|
+
gain.connect(audioContext.destination);
|
|
943
|
+
|
|
944
|
+
// Only set fallback timer for streaming providers; batch providers
|
|
945
|
+
// are expected to return results only after "stop" — no partials during recording.
|
|
946
|
+
ctx.noTokenFallbackTimer = window.setTimeout(async () => {
|
|
947
|
+
if (state.sttLive !== ctx) return;
|
|
948
|
+
if (ctx.firstTokenAt) return;
|
|
949
|
+
if ((ctx.sentChunks || 0) <= 0) return;
|
|
950
|
+
// Batch providers never send partials — don't fall back
|
|
951
|
+
if (ctx.isBatchMode) return;
|
|
952
|
+
ctx.stopping = true;
|
|
953
|
+
setSTTLiveStatus("fallback");
|
|
954
|
+
setText("stt-result", "WS has no transcript yet, falling back to chunk HTTP...");
|
|
955
|
+
try {
|
|
956
|
+
if (ctx.ws && ctx.ws.readyState === WebSocket.OPEN) {
|
|
957
|
+
ctx.ws.send(JSON.stringify({ type: "stop" }));
|
|
958
|
+
}
|
|
959
|
+
} catch (_) {}
|
|
960
|
+
try {
|
|
961
|
+
if (ctx.ws) ctx.ws.close();
|
|
962
|
+
} catch (_) {}
|
|
963
|
+
cleanupLiveContext(ctx);
|
|
964
|
+
if (state.sttLive === ctx) state.sttLive = null;
|
|
965
|
+
try {
|
|
966
|
+
await startLiveSTTFallback(provider, language, model, device, beamSizeRaw);
|
|
967
|
+
} catch (err) {
|
|
968
|
+
setSTTLiveStatus("error");
|
|
969
|
+
setText("stt-result", `Fallback failed: ${err && err.message ? err.message : err}`);
|
|
970
|
+
}
|
|
971
|
+
}, 7000);
|
|
972
|
+
|
|
973
|
+
const handleMessage = (event) => {
|
|
974
|
+
if (state.sttLive !== ctx) return;
|
|
975
|
+
let msg;
|
|
976
|
+
try {
|
|
977
|
+
msg = JSON.parse(String(event.data || "{}"));
|
|
978
|
+
} catch (_) {
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (msg.type === "meta") {
|
|
982
|
+
ctx.isBatchMode = !msg.streaming;
|
|
983
|
+
labLogV("data", `[STT] Meta received — streaming=${msg.streaming}, batch_mode=${ctx.isBatchMode}`);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (msg.type === "error") {
|
|
987
|
+
ctx.lastEventType = "error";
|
|
988
|
+
ctx.status = "error";
|
|
989
|
+
setSTTLiveStatus("error");
|
|
990
|
+
setText("stt-result", `Error: ${msg.detail || "stream error"}`);
|
|
991
|
+
labLog("error", `[STT] Stream error: ${msg.detail || "unknown"}`);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (msg.type === "partial" || msg.type === "final") {
|
|
995
|
+
ctx.lastEventType = msg.type;
|
|
996
|
+
if (!ctx.firstTokenAt) {
|
|
997
|
+
ctx.firstTokenAt = performance.now();
|
|
998
|
+
labLog("info", `[STT] First token received — latency=${Math.round(ctx.firstTokenAt - startedAt)}ms`);
|
|
999
|
+
}
|
|
1000
|
+
if (ctx.noTokenFallbackTimer) {
|
|
1001
|
+
clearTimeout(ctx.noTokenFallbackTimer);
|
|
1002
|
+
ctx.noTokenFallbackTimer = null;
|
|
1003
|
+
}
|
|
1004
|
+
const text = String(msg.text || "").trim();
|
|
1005
|
+
if (text) {
|
|
1006
|
+
state.sttLiveText = streamTextSnapshot(state.sttLiveText, text);
|
|
1007
|
+
setText("stt-stream-text", state.sttLiveText);
|
|
1008
|
+
if (msg.type === "final") {
|
|
1009
|
+
labLog("data", `[STT] Final: "${text.slice(0, 120)}${text.length > 120 ? "..." : ""}"`);
|
|
1010
|
+
} else {
|
|
1011
|
+
labLogV("data", `[STT] Partial: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}" (conf=${msg.confidence ?? "N/A"})`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1015
|
+
setText("stt-result", JSON.stringify({
|
|
1016
|
+
provider,
|
|
1017
|
+
mode: "live",
|
|
1018
|
+
status: msg.type === "final" ? "final" : "streaming",
|
|
1019
|
+
transport: "ws",
|
|
1020
|
+
elapsed_ms: elapsedMs,
|
|
1021
|
+
language: msg.language ?? language ?? null,
|
|
1022
|
+
confidence: msg.confidence ?? null,
|
|
1023
|
+
}, null, 2));
|
|
1024
|
+
|
|
1025
|
+
// When we receive a "final" result (e.g. iFlytek VAD triggered),
|
|
1026
|
+
// auto-stop recording: release mic & audio resources immediately,
|
|
1027
|
+
// then let the WS "closed" message handle the rest naturally.
|
|
1028
|
+
if (msg.type === "final" && state.sttLive === ctx) {
|
|
1029
|
+
labLog("info", `[STT] VAD end-of-speech detected — auto-stopping recording`);
|
|
1030
|
+
ctx.stopping = true;
|
|
1031
|
+
// Stop mic & audio pipeline (but keep WS open for "closed" msg)
|
|
1032
|
+
try { if (ctx.stream) ctx.stream.getTracks().forEach((t) => t.stop()); } catch (_) {}
|
|
1033
|
+
try { if (ctx.processor) ctx.processor.disconnect(); } catch (_) {}
|
|
1034
|
+
try { if (ctx.source) ctx.source.disconnect(); } catch (_) {}
|
|
1035
|
+
try { if (ctx.gain) ctx.gain.disconnect(); } catch (_) {}
|
|
1036
|
+
try {
|
|
1037
|
+
if (ctx.audioContext && ctx.audioContext.state !== "closed") {
|
|
1038
|
+
ctx.audioContext.close().catch(() => {});
|
|
1039
|
+
}
|
|
1040
|
+
} catch (_) {}
|
|
1041
|
+
setSTTLiveStatus("stopped");
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (msg.type === "closed") {
|
|
1047
|
+
ctx.lastEventType = "closed";
|
|
1048
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1049
|
+
labLog("info", `[STT] Session closed — elapsed=${elapsedMs}ms, result=${state.sttLiveText.length} chars`);
|
|
1050
|
+
setSTTLiveStatus("stopped");
|
|
1051
|
+
ctx.status = "stopped";
|
|
1052
|
+
setText("stt-result", JSON.stringify({
|
|
1053
|
+
provider,
|
|
1054
|
+
mode: "live",
|
|
1055
|
+
status: "closed",
|
|
1056
|
+
transport: "ws",
|
|
1057
|
+
elapsed_ms: elapsedMs,
|
|
1058
|
+
text: state.sttLiveText,
|
|
1059
|
+
}, null, 2));
|
|
1060
|
+
if (state.sttLive === ctx) {
|
|
1061
|
+
renderLiveMetrics(ctx, {
|
|
1062
|
+
mode: "live",
|
|
1063
|
+
text_chars: state.sttLiveText.length,
|
|
1064
|
+
last_event: ctx.lastEventType || null,
|
|
1065
|
+
ws_ready_state: Number(ctx.ws?.readyState ?? -1),
|
|
1066
|
+
});
|
|
1067
|
+
stopSTTLiveMetricsTimer();
|
|
1068
|
+
state.sttLive = null;
|
|
1069
|
+
}
|
|
1070
|
+
if (ctx.closedPromiseResolve) {
|
|
1071
|
+
ctx.closedPromiseResolve();
|
|
1072
|
+
ctx.closedPromiseResolve = null;
|
|
1073
|
+
}
|
|
1074
|
+
cleanupLiveContext(ctx);
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
ws.onmessage = handleMessage;
|
|
1078
|
+
// Replay any messages that arrived before the handler was set (e.g. "meta")
|
|
1079
|
+
for (const ev of earlyMessages) handleMessage(ev);
|
|
1080
|
+
|
|
1081
|
+
ws.onerror = () => {
|
|
1082
|
+
if (state.sttLive !== ctx) return;
|
|
1083
|
+
ctx.lastEventType = "error";
|
|
1084
|
+
ctx.status = "error";
|
|
1085
|
+
if (ctx.noTokenFallbackTimer) {
|
|
1086
|
+
clearTimeout(ctx.noTokenFallbackTimer);
|
|
1087
|
+
ctx.noTokenFallbackTimer = null;
|
|
1088
|
+
}
|
|
1089
|
+
setSTTLiveStatus("error");
|
|
1090
|
+
setText("stt-result", "Error: live WS stream failed");
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
ws.onclose = () => {
|
|
1094
|
+
// Resolve the stop promise in case WS closed without a "closed" message
|
|
1095
|
+
if (ctx.closedPromiseResolve) {
|
|
1096
|
+
ctx.closedPromiseResolve();
|
|
1097
|
+
ctx.closedPromiseResolve = null;
|
|
1098
|
+
}
|
|
1099
|
+
if (state.sttLive !== ctx) return;
|
|
1100
|
+
if (!ctx.stopping) {
|
|
1101
|
+
if (ctx.noTokenFallbackTimer) {
|
|
1102
|
+
clearTimeout(ctx.noTokenFallbackTimer);
|
|
1103
|
+
ctx.noTokenFallbackTimer = null;
|
|
1104
|
+
}
|
|
1105
|
+
ctx.lastEventType = "closed";
|
|
1106
|
+
ctx.status = "stopped";
|
|
1107
|
+
setSTTLiveStatus("stopped");
|
|
1108
|
+
const elapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1109
|
+
setText("stt-result", JSON.stringify({
|
|
1110
|
+
provider,
|
|
1111
|
+
mode: "live",
|
|
1112
|
+
status: "closed",
|
|
1113
|
+
transport: "ws",
|
|
1114
|
+
elapsed_ms: elapsedMs,
|
|
1115
|
+
text: state.sttLiveText,
|
|
1116
|
+
}, null, 2));
|
|
1117
|
+
renderLiveMetrics(ctx, {
|
|
1118
|
+
mode: "live",
|
|
1119
|
+
text_chars: state.sttLiveText.length,
|
|
1120
|
+
last_event: ctx.lastEventType || null,
|
|
1121
|
+
ws_ready_state: Number(ctx.ws?.readyState ?? -1),
|
|
1122
|
+
});
|
|
1123
|
+
stopSTTLiveMetricsTimer();
|
|
1124
|
+
state.sttLive = null;
|
|
1125
|
+
cleanupLiveContext(ctx);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function startLiveSTT() {
|
|
1131
|
+
await stopLiveSTT();
|
|
1132
|
+
const provider = $("stt-provider").value;
|
|
1133
|
+
if (!provider) {
|
|
1134
|
+
setText("stt-result", "Error: provider is required");
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const language = $("stt-language").value.trim();
|
|
1138
|
+
const model = $("stt-model").value.trim();
|
|
1139
|
+
const device = $("stt-device").value.trim();
|
|
1140
|
+
const beamSizeRaw = $("stt-beam-size").value.trim();
|
|
1141
|
+
state.sttLiveText = "";
|
|
1142
|
+
setText("stt-stream-text", "");
|
|
1143
|
+
setText("stt-result", "Starting live STT...");
|
|
1144
|
+
setText("stt-live-metrics", JSON.stringify({ status: "initializing" }, null, 2));
|
|
1145
|
+
setSTTLiveStatus("initializing");
|
|
1146
|
+
labLog("info", `[STT] Start live recording — provider=${provider}, lang=${language || "auto"}`);
|
|
1147
|
+
labLogV("data", `[STT] model=${model || "(auto)"}, device=${device || "(auto)"}, beam_size=${beamSizeRaw || "(default)"}`);
|
|
1148
|
+
try {
|
|
1149
|
+
labLogV("data", `[STT] Connecting WebSocket /v1/stt/stream ...`);
|
|
1150
|
+
await startLiveSTTWebSocket(provider, language, model, device, beamSizeRaw);
|
|
1151
|
+
labLog("info", `[STT] WebSocket connected — transport=ws`);
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
labLog("warn", `[STT] WebSocket failed: ${err.message || err}, falling back to HTTP chunk`);
|
|
1154
|
+
cleanupLiveContext(state.sttLive);
|
|
1155
|
+
state.sttLive = null;
|
|
1156
|
+
setSTTLiveStatus("fallback");
|
|
1157
|
+
setText("stt-result", `WS init failed, fallback to chunk HTTP: ${err.message || err}`);
|
|
1158
|
+
await startLiveSTTFallback(provider, language, model, device, beamSizeRaw);
|
|
1159
|
+
labLog("info", `[STT] Fallback recording started — transport=http-chunk`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function stopLiveSTT() {
|
|
1164
|
+
const ctx = state.sttLive;
|
|
1165
|
+
if (!ctx) return;
|
|
1166
|
+
labLog("info", `[STT] Stopping live recording...`);
|
|
1167
|
+
labLogV("data", `[STT] transport=${ctx.transport || "?"}, sent=${ctx.sentChunks} chunks / ${(ctx.sentBytes/1024).toFixed(1)} KB, text=${state.sttLiveText.length} chars`);
|
|
1168
|
+
setSTTLiveStatus("stopping");
|
|
1169
|
+
ctx.status = "stopping";
|
|
1170
|
+
if (ctx.ws) {
|
|
1171
|
+
ctx.stopping = true;
|
|
1172
|
+
try {
|
|
1173
|
+
if (ctx.ws.readyState === WebSocket.OPEN) {
|
|
1174
|
+
ctx.ws.send(JSON.stringify({ type: "stop" }));
|
|
1175
|
+
}
|
|
1176
|
+
} catch (_) {}
|
|
1177
|
+
|
|
1178
|
+
// Wait for backend to send "final" + "closed" before closing WS.
|
|
1179
|
+
// This is critical for batch providers (e.g. native_stt) which only
|
|
1180
|
+
// transcribe after receiving "stop".
|
|
1181
|
+
if (ctx.ws.readyState === WebSocket.OPEN || ctx.ws.readyState === WebSocket.CONNECTING) {
|
|
1182
|
+
if (ctx.isBatchMode) {
|
|
1183
|
+
setSTTLiveStatus("transcribing");
|
|
1184
|
+
setText("stt-result", JSON.stringify({
|
|
1185
|
+
provider: ctx.provider,
|
|
1186
|
+
mode: "live",
|
|
1187
|
+
status: "transcribing",
|
|
1188
|
+
transport: "ws",
|
|
1189
|
+
info: "Waiting for batch recognition result...",
|
|
1190
|
+
}, null, 2));
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
await Promise.race([
|
|
1194
|
+
new Promise((resolve) => { ctx.closedPromiseResolve = resolve; }),
|
|
1195
|
+
new Promise((resolve) => setTimeout(resolve, 20000)), // 20s safety timeout
|
|
1196
|
+
]);
|
|
1197
|
+
} catch (_) {}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
try {
|
|
1201
|
+
if (ctx.ws.readyState !== WebSocket.CLOSED) ctx.ws.close();
|
|
1202
|
+
} catch (_) {}
|
|
1203
|
+
// Only clean up if the "closed" handler didn't already do it
|
|
1204
|
+
if (state.sttLive === ctx) {
|
|
1205
|
+
cleanupLiveContext(ctx);
|
|
1206
|
+
renderLiveMetrics(ctx, {
|
|
1207
|
+
mode: "live",
|
|
1208
|
+
text_chars: state.sttLiveText.length,
|
|
1209
|
+
last_event: ctx.lastEventType || null,
|
|
1210
|
+
ws_ready_state: Number(ctx.ws?.readyState ?? -1),
|
|
1211
|
+
});
|
|
1212
|
+
stopSTTLiveMetricsTimer();
|
|
1213
|
+
state.sttLive = null;
|
|
1214
|
+
}
|
|
1215
|
+
setSTTLiveStatus("stopped");
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
if (ctx.recorder && ctx.recorder.state !== "inactive") {
|
|
1220
|
+
try {
|
|
1221
|
+
ctx.recorder.requestData();
|
|
1222
|
+
} catch (_) {}
|
|
1223
|
+
ctx.recorder.stop();
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
} catch (_) {}
|
|
1227
|
+
cleanupLiveContext(ctx);
|
|
1228
|
+
renderLiveMetrics(ctx, {
|
|
1229
|
+
mode: "live",
|
|
1230
|
+
chunks_buffered: ctx.chunks ? ctx.chunks.length : 0,
|
|
1231
|
+
text_chars: state.sttLiveText.length,
|
|
1232
|
+
last_duration_ms: ctx.lastDurationMs ?? null,
|
|
1233
|
+
language: ctx.lastLanguage ?? ctx.language ?? null,
|
|
1234
|
+
});
|
|
1235
|
+
stopSTTLiveMetricsTimer();
|
|
1236
|
+
state.sttLive = null;
|
|
1237
|
+
setSTTLiveStatus("stopped");
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async function runTTS(e) {
|
|
1241
|
+
e.preventDefault();
|
|
1242
|
+
const provider = $("tts-provider").value;
|
|
1243
|
+
const text = $("tts-text").value.trim();
|
|
1244
|
+
const model = $("tts-model").value.trim();
|
|
1245
|
+
const streamTransport = $("tts-transport").value.trim();
|
|
1246
|
+
if (!provider || !text) return;
|
|
1247
|
+
|
|
1248
|
+
stopTTSTimer();
|
|
1249
|
+
hideTTSDownload();
|
|
1250
|
+
const startedAt = performance.now();
|
|
1251
|
+
const audioChunks = [];
|
|
1252
|
+
let receivedBytes = 0;
|
|
1253
|
+
let meta = {};
|
|
1254
|
+
let firstChunkAt = 0;
|
|
1255
|
+
|
|
1256
|
+
labLog("info", `[TTS] Start synthesis — provider=${provider}, text=${text.length} chars, voice=${$("tts-voice").value || "(default)"}`);
|
|
1257
|
+
labLogV("data", `[TTS] speed=${parseFloat($("tts-speed").value) || 1.0}, model=${model || "(auto)"}, transport=${streamTransport || "(auto)"}`);
|
|
1258
|
+
labLogV("data", `[TTS] text="${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`);
|
|
1259
|
+
|
|
1260
|
+
const renderStreaming = () => {
|
|
1261
|
+
const elapsed = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1262
|
+
setText("tts-result", JSON.stringify({
|
|
1263
|
+
provider,
|
|
1264
|
+
status: "streaming",
|
|
1265
|
+
received_bytes: receivedBytes,
|
|
1266
|
+
chunks: audioChunks.length,
|
|
1267
|
+
elapsed_ms: elapsed,
|
|
1268
|
+
first_chunk_ms: firstChunkAt ? Math.round(firstChunkAt - startedAt) : null,
|
|
1269
|
+
text_chars: text.length,
|
|
1270
|
+
}, null, 2));
|
|
1271
|
+
};
|
|
1272
|
+
renderStreaming();
|
|
1273
|
+
// Show download panel immediately in streaming mode, but keep button disabled
|
|
1274
|
+
// until we have a complete audio buffer.
|
|
1275
|
+
showTTSDownload(meta.audio_format || "streaming", false);
|
|
1276
|
+
state.ttsTimer = setInterval(renderStreaming, 120);
|
|
1277
|
+
|
|
1278
|
+
const wsProto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
1279
|
+
const wsUrl = `${wsProto}//${location.host}/v1/tts/stream`;
|
|
1280
|
+
|
|
1281
|
+
// Streaming playback state
|
|
1282
|
+
let streamPlayer = null;
|
|
1283
|
+
|
|
1284
|
+
try {
|
|
1285
|
+
await new Promise((resolve, reject) => {
|
|
1286
|
+
const ws = new WebSocket(wsUrl);
|
|
1287
|
+
ws.binaryType = "arraybuffer";
|
|
1288
|
+
|
|
1289
|
+
ws.onopen = () => {
|
|
1290
|
+
labLog("info", `[TTS] WebSocket connected, sending request...`);
|
|
1291
|
+
const payload = {
|
|
1292
|
+
provider,
|
|
1293
|
+
text,
|
|
1294
|
+
voice: $("tts-voice").value || null,
|
|
1295
|
+
speed: parseFloat($("tts-speed").value) || 1.0,
|
|
1296
|
+
model: model || null,
|
|
1297
|
+
stream_transport: streamTransport || null,
|
|
1298
|
+
};
|
|
1299
|
+
labLogV("data", `[TTS] WS send: ${JSON.stringify(payload).slice(0, 200)}`);
|
|
1300
|
+
ws.send(JSON.stringify(payload));
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
ws.onmessage = (event) => {
|
|
1304
|
+
if (event.data instanceof ArrayBuffer) {
|
|
1305
|
+
if (!firstChunkAt) {
|
|
1306
|
+
firstChunkAt = performance.now();
|
|
1307
|
+
labLog("info", `[TTS] First audio chunk — latency=${Math.round(firstChunkAt - startedAt)}ms, size=${event.data.byteLength}B`);
|
|
1308
|
+
}
|
|
1309
|
+
audioChunks.push(event.data);
|
|
1310
|
+
receivedBytes += event.data.byteLength;
|
|
1311
|
+
if (audioChunks.length % 20 === 0) {
|
|
1312
|
+
labLogV("data", `[TTS] Received ${audioChunks.length} chunks, ${(receivedBytes/1024).toFixed(1)} KB`);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Feed chunk to streaming player
|
|
1316
|
+
if (streamPlayer) {
|
|
1317
|
+
streamPlayer.feed(new Uint8Array(event.data));
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
try {
|
|
1321
|
+
const msg = JSON.parse(event.data);
|
|
1322
|
+
if (msg.type === "meta") {
|
|
1323
|
+
meta = msg;
|
|
1324
|
+
labLog("info", `[TTS] Stream meta received — format=${msg.audio_format || "?"}, rate=${msg.sample_rate || "?"}, streaming=${msg.streaming}`);
|
|
1325
|
+
// Initialize streaming player based on audio format
|
|
1326
|
+
const audioFormat = msg.audio_format || "wav";
|
|
1327
|
+
streamPlayer = createStreamPlayer(audioFormat);
|
|
1328
|
+
streamPlayer.start();
|
|
1329
|
+
} else if (msg.type === "done") {
|
|
1330
|
+
meta = { ...meta, ...msg };
|
|
1331
|
+
if (streamPlayer) streamPlayer.end();
|
|
1332
|
+
ws.close();
|
|
1333
|
+
resolve();
|
|
1334
|
+
} else if (msg.type === "error") {
|
|
1335
|
+
labLog("error", `[TTS] Stream error: ${msg.detail || "unknown"}`);
|
|
1336
|
+
ws.close();
|
|
1337
|
+
reject(new Error(msg.detail || "TTS streaming error"));
|
|
1338
|
+
}
|
|
1339
|
+
} catch (_) {}
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
ws.onerror = () => {
|
|
1344
|
+
labLogV("error", `[TTS] WS onerror event`);
|
|
1345
|
+
reject(new Error("WebSocket connection failed"));
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
ws.onclose = (ev) => {
|
|
1349
|
+
labLogV("data", `[TTS] WS closed — code=${ev.code}, reason=${ev.reason || "(none)"}`);
|
|
1350
|
+
if (ev.code !== 1000 && ev.code !== 1005 && audioChunks.length === 0) {
|
|
1351
|
+
reject(new Error(`WebSocket closed: ${ev.reason || ev.code}`));
|
|
1352
|
+
} else {
|
|
1353
|
+
resolve();
|
|
1354
|
+
}
|
|
1355
|
+
};
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
const totalSize = audioChunks.reduce((s, c) => s + c.byteLength, 0);
|
|
1359
|
+
const clientElapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1360
|
+
|
|
1361
|
+
// Combine all chunks into a single buffer
|
|
1362
|
+
const combined = new Uint8Array(totalSize);
|
|
1363
|
+
let offset = 0;
|
|
1364
|
+
for (const chunk of audioChunks) {
|
|
1365
|
+
combined.set(new Uint8Array(chunk), offset);
|
|
1366
|
+
offset += chunk.byteLength;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// If streamPlayer was not used (no meta message), fallback to full-buffer playback
|
|
1370
|
+
if (!streamPlayer) {
|
|
1371
|
+
const blob = new Blob([combined], { type: "audio/wav" });
|
|
1372
|
+
const audio = $("tts-audio");
|
|
1373
|
+
audio.src = URL.createObjectURL(blob);
|
|
1374
|
+
audio.play().catch(() => {});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Store audio data for download
|
|
1378
|
+
const audioFormat = meta.audio_format || "wav";
|
|
1379
|
+
state.ttsAudioData = combined;
|
|
1380
|
+
state.ttsAudioFormat = audioFormat;
|
|
1381
|
+
state.ttsSampleRate = meta.sample_rate || 16000;
|
|
1382
|
+
state.ttsChannels = meta.channels || 1;
|
|
1383
|
+
showTTSDownload(audioFormat, true);
|
|
1384
|
+
|
|
1385
|
+
setText("tts-result", JSON.stringify({
|
|
1386
|
+
provider,
|
|
1387
|
+
status: "done",
|
|
1388
|
+
mode: meta.streaming ? "streaming" : "batch",
|
|
1389
|
+
format: audioFormat,
|
|
1390
|
+
bytes: totalSize,
|
|
1391
|
+
chunks: audioChunks.length,
|
|
1392
|
+
sample_rate: meta.sample_rate || null,
|
|
1393
|
+
duration_ms: meta.duration_ms || 0,
|
|
1394
|
+
elapsed_ms: clientElapsedMs,
|
|
1395
|
+
first_chunk_ms: firstChunkAt ? Math.round(firstChunkAt - startedAt) : null,
|
|
1396
|
+
}, null, 2));
|
|
1397
|
+
labLog("info", `[TTS] Completed in ${clientElapsedMs}ms — ${totalSize} bytes, ${audioChunks.length} chunks, format=${audioFormat}`);
|
|
1398
|
+
labLogV("data", `[TTS] mode=${meta.streaming ? "streaming" : "batch"}, sample_rate=${meta.sample_rate || "?"}, duration=${meta.duration_ms || "?"}ms, first_chunk=${firstChunkAt ? Math.round(firstChunkAt - startedAt) + "ms" : "N/A"}`);
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
if (streamPlayer) streamPlayer.destroy();
|
|
1401
|
+
const clientElapsedMs = Math.max(0, Math.round(performance.now() - startedAt));
|
|
1402
|
+
setText("tts-result", JSON.stringify({
|
|
1403
|
+
provider,
|
|
1404
|
+
status: "error",
|
|
1405
|
+
elapsed_ms: clientElapsedMs,
|
|
1406
|
+
error: String(err && err.message ? err.message : err),
|
|
1407
|
+
}, null, 2));
|
|
1408
|
+
labLog("error", `[TTS] Failed after ${clientElapsedMs}ms — ${err && err.message ? err.message : err}`);
|
|
1409
|
+
} finally {
|
|
1410
|
+
stopTTSTimer();
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// --- TTS Download ---
|
|
1415
|
+
|
|
1416
|
+
function showTTSDownload(originalFormat, ready = true) {
|
|
1417
|
+
const area = $("tts-download-area");
|
|
1418
|
+
const select = $("tts-download-format");
|
|
1419
|
+
const btn = $("tts-download-btn");
|
|
1420
|
+
const status = $("tts-download-status");
|
|
1421
|
+
if (!area || !select) return;
|
|
1422
|
+
|
|
1423
|
+
// Update the "Original" option label
|
|
1424
|
+
const origOpt = select.querySelector('option[value="__original__"]');
|
|
1425
|
+
if (origOpt) origOpt.textContent = "Original (" + (originalFormat || "raw").toUpperCase() + ")";
|
|
1426
|
+
|
|
1427
|
+
// Load available formats from server
|
|
1428
|
+
fetchJson("/v1/tts/formats").then(data => {
|
|
1429
|
+
const formats = data.formats || [];
|
|
1430
|
+
// Enable/disable options based on ffmpeg availability
|
|
1431
|
+
for (const opt of select.options) {
|
|
1432
|
+
if (opt.value === "__original__") continue;
|
|
1433
|
+
const fmt = formats.find(f => f.id === opt.value);
|
|
1434
|
+
if (fmt) {
|
|
1435
|
+
opt.disabled = !fmt.available;
|
|
1436
|
+
opt.textContent = fmt.label + (fmt.available ? "" : " (needs ffmpeg)");
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
}).catch(() => {});
|
|
1440
|
+
|
|
1441
|
+
area.classList.remove("hidden");
|
|
1442
|
+
if (btn) btn.disabled = !ready;
|
|
1443
|
+
if (status) {
|
|
1444
|
+
status.textContent = ready ? "" : "Available after synthesis completes";
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function hideTTSDownload() {
|
|
1449
|
+
const area = $("tts-download-area");
|
|
1450
|
+
const btn = $("tts-download-btn");
|
|
1451
|
+
const status = $("tts-download-status");
|
|
1452
|
+
if (area) area.classList.add("hidden");
|
|
1453
|
+
if (btn) btn.disabled = false;
|
|
1454
|
+
if (status) status.textContent = "";
|
|
1455
|
+
state.ttsAudioData = null;
|
|
1456
|
+
state.ttsAudioFormat = null;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
window.downloadTTSAudio = async function(event) {
|
|
1460
|
+
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
1461
|
+
if (event && typeof event.stopPropagation === "function") event.stopPropagation();
|
|
1462
|
+
if (!state.ttsAudioData) return;
|
|
1463
|
+
|
|
1464
|
+
const select = $("tts-download-format");
|
|
1465
|
+
const statusEl = $("tts-download-status");
|
|
1466
|
+
const targetFormat = select.value;
|
|
1467
|
+
|
|
1468
|
+
// Direct download if original format or same format
|
|
1469
|
+
if (targetFormat === "__original__" || targetFormat === state.ttsAudioFormat) {
|
|
1470
|
+
const ext = { mp3: ".mp3", wav: ".wav", ogg: ".ogg", flac: ".flac", opus: ".opus" };
|
|
1471
|
+
const mimes = { mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", opus: "audio/opus" };
|
|
1472
|
+
const fmt = state.ttsAudioFormat || "wav";
|
|
1473
|
+
const blob = new Blob([state.ttsAudioData], { type: mimes[fmt] || "application/octet-stream" });
|
|
1474
|
+
triggerDownload(blob, "speech" + (ext[fmt] || ".bin"));
|
|
1475
|
+
statusEl.textContent = "Downloaded";
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Convert via server
|
|
1480
|
+
statusEl.textContent = "Converting...";
|
|
1481
|
+
try {
|
|
1482
|
+
const formData = new FormData();
|
|
1483
|
+
const srcFmt = state.ttsAudioFormat || "wav";
|
|
1484
|
+
const mimes = { mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac" };
|
|
1485
|
+
const blob = new Blob([state.ttsAudioData], { type: mimes[srcFmt] || "application/octet-stream" });
|
|
1486
|
+
formData.append("audio", blob, "audio." + srcFmt);
|
|
1487
|
+
formData.append("source_format", srcFmt);
|
|
1488
|
+
formData.append("target_format", targetFormat);
|
|
1489
|
+
formData.append("sample_rate", String(state.ttsSampleRate));
|
|
1490
|
+
formData.append("channels", String(state.ttsChannels));
|
|
1491
|
+
|
|
1492
|
+
const resp = await fetch("/v1/tts/convert", { method: "POST", body: formData });
|
|
1493
|
+
if (!resp.ok) {
|
|
1494
|
+
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
|
1495
|
+
throw new Error(err.detail || "Conversion failed");
|
|
1496
|
+
}
|
|
1497
|
+
const resultBlob = await resp.blob();
|
|
1498
|
+
const ext = { wav: ".wav", mp3: ".mp3", ogg: ".ogg", flac: ".flac", opus: ".opus" };
|
|
1499
|
+
triggerDownload(resultBlob, "speech" + (ext[targetFormat] || ".bin"));
|
|
1500
|
+
statusEl.textContent = "Downloaded (" + targetFormat.toUpperCase() + ")";
|
|
1501
|
+
} catch (e) {
|
|
1502
|
+
statusEl.textContent = "Error: " + e.message;
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
function triggerDownload(blob, filename) {
|
|
1507
|
+
const url = URL.createObjectURL(blob);
|
|
1508
|
+
const a = document.createElement("a");
|
|
1509
|
+
a.href = url;
|
|
1510
|
+
a.download = filename;
|
|
1511
|
+
document.body.appendChild(a);
|
|
1512
|
+
a.click();
|
|
1513
|
+
document.body.removeChild(a);
|
|
1514
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Create a streaming audio player that plays chunks as they arrive.
|
|
1519
|
+
* Strategy:
|
|
1520
|
+
* 1. MSE (MediaSource Extensions) for MP3 — true chunk-by-chunk playback
|
|
1521
|
+
* 2. Progressive Blob — grow a Blob URL as chunks arrive, good enough for MP3
|
|
1522
|
+
* 3. Full-buffer fallback for WAV/PCM
|
|
1523
|
+
*/
|
|
1524
|
+
function createStreamPlayer(audioFormat) {
|
|
1525
|
+
const mseType = audioFormat === "mp3" ? 'audio/mpeg' : 'audio/wav';
|
|
1526
|
+
const mseSupported = window.MediaSource && MediaSource.isTypeSupported(mseType);
|
|
1527
|
+
console.log('[StreamPlayer] format=' + audioFormat + ' MSE(' + mseType + ')=' + mseSupported);
|
|
1528
|
+
|
|
1529
|
+
if (mseSupported) {
|
|
1530
|
+
return createMSEPlayer(mseType);
|
|
1531
|
+
}
|
|
1532
|
+
// Fallback: progressive blob for MP3, full-buffer for others
|
|
1533
|
+
if (audioFormat === "mp3") {
|
|
1534
|
+
return createProgressiveBlobPlayer("audio/mpeg");
|
|
1535
|
+
}
|
|
1536
|
+
return createFullBufferPlayer();
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/** MSE-based streaming player — true low-latency chunk playback */
|
|
1540
|
+
function createMSEPlayer(mimeType) {
|
|
1541
|
+
const audio = $("tts-audio");
|
|
1542
|
+
let mediaSource = null;
|
|
1543
|
+
let sourceBuffer = null;
|
|
1544
|
+
let queue = [];
|
|
1545
|
+
let ended = false;
|
|
1546
|
+
let sourceOpen = false;
|
|
1547
|
+
let chunkIdx = 0;
|
|
1548
|
+
|
|
1549
|
+
function appendNext() {
|
|
1550
|
+
if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) return;
|
|
1551
|
+
const chunk = queue.shift();
|
|
1552
|
+
try {
|
|
1553
|
+
sourceBuffer.appendBuffer(chunk);
|
|
1554
|
+
console.log('[MSE] appended chunk #' + (chunkIdx++) + ', ' + chunk.byteLength + ' bytes, buffered=' + (sourceBuffer.buffered.length > 0 ? sourceBuffer.buffered.end(0).toFixed(2) + 's' : '0s'));
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
console.warn("[MSE] appendBuffer error:", e);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
return {
|
|
1561
|
+
start() {
|
|
1562
|
+
mediaSource = new MediaSource();
|
|
1563
|
+
audio.src = URL.createObjectURL(mediaSource);
|
|
1564
|
+
|
|
1565
|
+
mediaSource.addEventListener('sourceopen', () => {
|
|
1566
|
+
sourceOpen = true;
|
|
1567
|
+
console.log('[MSE] sourceopen fired');
|
|
1568
|
+
try {
|
|
1569
|
+
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
|
1570
|
+
sourceBuffer.addEventListener('updateend', () => {
|
|
1571
|
+
if (queue.length > 0) {
|
|
1572
|
+
appendNext();
|
|
1573
|
+
} else if (ended && mediaSource.readyState === 'open') {
|
|
1574
|
+
try { mediaSource.endOfStream(); console.log('[MSE] endOfStream called'); } catch (_) {}
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
// Flush any queued data that arrived before sourceopen
|
|
1578
|
+
if (queue.length > 0) appendNext();
|
|
1579
|
+
} catch (e) {
|
|
1580
|
+
console.error("[MSE] addSourceBuffer error:", e);
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
audio.play().catch(e => console.log('[MSE] play() rejected:', e.message));
|
|
1585
|
+
},
|
|
1586
|
+
feed(data) {
|
|
1587
|
+
const buf = (data.buffer && data.buffer instanceof ArrayBuffer) ? data.buffer : data;
|
|
1588
|
+
queue.push(buf);
|
|
1589
|
+
if (sourceOpen && sourceBuffer && !sourceBuffer.updating) {
|
|
1590
|
+
appendNext();
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
end() {
|
|
1594
|
+
ended = true;
|
|
1595
|
+
console.log('[MSE] end() called, queue=' + queue.length + ', updating=' + (sourceBuffer ? sourceBuffer.updating : 'N/A'));
|
|
1596
|
+
if (sourceBuffer && !sourceBuffer.updating && queue.length === 0 && mediaSource.readyState === 'open') {
|
|
1597
|
+
try { mediaSource.endOfStream(); } catch (_) {}
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
destroy() {
|
|
1601
|
+
try {
|
|
1602
|
+
if (mediaSource && mediaSource.readyState === 'open') mediaSource.endOfStream();
|
|
1603
|
+
} catch (_) {}
|
|
1604
|
+
},
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Progressive Blob player for MP3 — rebuilds Blob URL on each chunk.
|
|
1610
|
+
* Less efficient than MSE but works universally.
|
|
1611
|
+
* Starts playback after the first chunk arrives.
|
|
1612
|
+
*/
|
|
1613
|
+
function createProgressiveBlobPlayer(mimeType) {
|
|
1614
|
+
const audio = $("tts-audio");
|
|
1615
|
+
const chunks = [];
|
|
1616
|
+
let playing = false;
|
|
1617
|
+
|
|
1618
|
+
return {
|
|
1619
|
+
start() {},
|
|
1620
|
+
feed(data) {
|
|
1621
|
+
chunks.push(new Uint8Array(data));
|
|
1622
|
+
console.log('[ProgressiveBlob] chunk #' + chunks.length + ', ' + data.byteLength + ' bytes');
|
|
1623
|
+
// Start playback after the first chunk
|
|
1624
|
+
if (!playing && chunks.length >= 1) {
|
|
1625
|
+
playing = true;
|
|
1626
|
+
const blob = new Blob(chunks, { type: mimeType });
|
|
1627
|
+
audio.src = URL.createObjectURL(blob);
|
|
1628
|
+
audio.play().catch(e => console.log('[ProgressiveBlob] play rejected:', e.message));
|
|
1629
|
+
}
|
|
1630
|
+
},
|
|
1631
|
+
end() {
|
|
1632
|
+
// Rebuild final Blob with all chunks for full playback + seeking
|
|
1633
|
+
console.log('[ProgressiveBlob] end(), total chunks=' + chunks.length);
|
|
1634
|
+
const blob = new Blob(chunks, { type: mimeType });
|
|
1635
|
+
const url = URL.createObjectURL(blob);
|
|
1636
|
+
const currentTime = audio.currentTime;
|
|
1637
|
+
audio.src = url;
|
|
1638
|
+
audio.currentTime = currentTime;
|
|
1639
|
+
if (audio.paused) audio.play().catch(() => {});
|
|
1640
|
+
},
|
|
1641
|
+
destroy() {},
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/** Full-buffer player — collects all chunks, plays once at the end */
|
|
1646
|
+
function createFullBufferPlayer() {
|
|
1647
|
+
const chunks = [];
|
|
1648
|
+
const audio = $("tts-audio");
|
|
1649
|
+
|
|
1650
|
+
return {
|
|
1651
|
+
start() {},
|
|
1652
|
+
feed(data) {
|
|
1653
|
+
chunks.push(new Uint8Array(data));
|
|
1654
|
+
},
|
|
1655
|
+
end() {
|
|
1656
|
+
const totalSize = chunks.reduce((s, c) => s + c.byteLength, 0);
|
|
1657
|
+
const combined = new Uint8Array(totalSize);
|
|
1658
|
+
let offset = 0;
|
|
1659
|
+
for (const c of chunks) { combined.set(c, offset); offset += c.byteLength; }
|
|
1660
|
+
const blob = new Blob([combined], { type: "audio/wav" });
|
|
1661
|
+
audio.src = URL.createObjectURL(blob);
|
|
1662
|
+
audio.play().catch(() => {});
|
|
1663
|
+
},
|
|
1664
|
+
destroy() {},
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function enginePayload(action) {
|
|
1669
|
+
const dockerImage = $("engine-docker-image").value.trim();
|
|
1670
|
+
const modelRepo = $("engine-model-repo").value.trim();
|
|
1671
|
+
const pullOnStart = Boolean($("engine-pull-on-start").checked);
|
|
1672
|
+
const simulateDownload = Boolean($("engine-simulate-download").checked);
|
|
1673
|
+
const installDir = $("engine-install-dir").value.trim() || "~/AI/services";
|
|
1674
|
+
const options = {};
|
|
1675
|
+
if (dockerImage) {
|
|
1676
|
+
options.docker_image = dockerImage;
|
|
1677
|
+
}
|
|
1678
|
+
if (modelRepo) {
|
|
1679
|
+
options.native_model_repo = modelRepo;
|
|
1680
|
+
}
|
|
1681
|
+
options.native_simulate_download = simulateDownload;
|
|
1682
|
+
options.pull_on_start = pullOnStart;
|
|
1683
|
+
return {
|
|
1684
|
+
engine: ($("engine-name") || {}).value?.trim() || "fish-speech",
|
|
1685
|
+
action,
|
|
1686
|
+
runtime: ($("engine-runtime") || {}).value || "native",
|
|
1687
|
+
api_url: ($("engine-api-url") || {}).value?.trim() || "http://127.0.0.1:8080",
|
|
1688
|
+
install_dir: installDir,
|
|
1689
|
+
options,
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function subscribeTask(taskId) {
|
|
1694
|
+
if (!taskId) return;
|
|
1695
|
+
if (state.eventSource) {
|
|
1696
|
+
state.eventSource.close();
|
|
1697
|
+
}
|
|
1698
|
+
if ($("task-id-input")) $("task-id-input").value = taskId;
|
|
1699
|
+
const es = new EventSource(`/v1/engines/tasks/${taskId}/events`);
|
|
1700
|
+
state.eventSource = es;
|
|
1701
|
+
|
|
1702
|
+
es.addEventListener("snapshot", (ev) => {
|
|
1703
|
+
const task = JSON.parse(ev.data);
|
|
1704
|
+
($("engine-state")||{}).textContent = task.status;
|
|
1705
|
+
setOutput(`${task.action} ${task.phase} (${task.status}) :: ${task.message}`);
|
|
1706
|
+
refreshTasksOnly();
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
es.addEventListener("progress", (ev) => {
|
|
1710
|
+
const p = JSON.parse(ev.data);
|
|
1711
|
+
const percent = p.progress == null ? "--" : `${Number(p.progress).toFixed(1)}%`;
|
|
1712
|
+
const eta = p.eta_seconds == null ? "--:--" : formatEta(p.eta_seconds);
|
|
1713
|
+
setOutput(`${p.action} ${p.phase} ${percent} ETA ${eta} :: ${p.message}`);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
es.addEventListener("done", (ev) => {
|
|
1717
|
+
const task = JSON.parse(ev.data);
|
|
1718
|
+
setOutput(`DONE ${task.status}: ${task.message}`);
|
|
1719
|
+
($("engine-state")||{}).textContent = task.status;
|
|
1720
|
+
es.close();
|
|
1721
|
+
state.eventSource = null;
|
|
1722
|
+
refreshTasksOnly();
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
es.onerror = () => {
|
|
1726
|
+
setOutput("SSE disconnected.");
|
|
1727
|
+
es.close();
|
|
1728
|
+
if (state.eventSource === es) state.eventSource = null;
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
async function triggerEngineAction(action) {
|
|
1733
|
+
try {
|
|
1734
|
+
const data = await fetchJson("/v1/engines/actions", {
|
|
1735
|
+
method: "POST",
|
|
1736
|
+
headers: { "Content-Type": "application/json" },
|
|
1737
|
+
body: JSON.stringify(enginePayload(action)),
|
|
1738
|
+
});
|
|
1739
|
+
const task = data.task;
|
|
1740
|
+
setOutput(`Task created: ${task.task_id} (${action})`);
|
|
1741
|
+
subscribeTask(task.task_id);
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
setOutput(`Action failed: ${err.message}`);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Quick engine action from dashboard (uses provider alias to guess engine name)
|
|
1748
|
+
window.engineAction = async function(providerAlias, action) {
|
|
1749
|
+
const engineMap = {
|
|
1750
|
+
"tts": "fish-speech", "stt_fw": "faster-whisper", "stt_whisper": "whisper",
|
|
1751
|
+
"stt_wlk": "whisperlivekit", "stt_sherpa": "sherpa-onnx",
|
|
1752
|
+
};
|
|
1753
|
+
const engine = engineMap[providerAlias] || providerAlias;
|
|
1754
|
+
addLog(engine, action, 'running', `${action}ing...`);
|
|
1755
|
+
try {
|
|
1756
|
+
const data = await fetchJson("/v1/engines/actions", {
|
|
1757
|
+
method: "POST",
|
|
1758
|
+
headers: { "Content-Type": "application/json" },
|
|
1759
|
+
body: JSON.stringify({
|
|
1760
|
+
engine: engine, action: action, runtime: "native",
|
|
1761
|
+
api_url: "http://127.0.0.1:8080", install_dir: "~/AI/services",
|
|
1762
|
+
}),
|
|
1763
|
+
});
|
|
1764
|
+
addLog(engine, action, 'ok', `Task: ${data.task.task_id}`);
|
|
1765
|
+
setTimeout(() => { refreshDashboard(); loadCatalog(); }, 3000);
|
|
1766
|
+
} catch (err) {
|
|
1767
|
+
addLog(engine, action, 'failed', err.message);
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
|
|
1771
|
+
async function refreshTasksOnly() {
|
|
1772
|
+
try {
|
|
1773
|
+
const tasksData = await fetchJson("/v1/engines/tasks?limit=20");
|
|
1774
|
+
renderTasks(tasksData.tasks || []);
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
setOutput(`Task refresh failed: ${err.message}`);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function fetchEngineStatus() {
|
|
1781
|
+
try {
|
|
1782
|
+
const params = new URLSearchParams({
|
|
1783
|
+
engine: $("engine-name").value.trim() || "fish-speech",
|
|
1784
|
+
runtime: $("engine-runtime").value,
|
|
1785
|
+
api_url: $("engine-api-url").value.trim() || "http://127.0.0.1:8080",
|
|
1786
|
+
install_dir: $("engine-install-dir").value.trim() || "~/AI/services",
|
|
1787
|
+
});
|
|
1788
|
+
const data = await fetchJson(`/v1/engines/status?${params.toString()}`);
|
|
1789
|
+
($("engine-state")||{}).textContent = data.running ? (data.healthy ? "healthy" : "degraded") : "stopped";
|
|
1790
|
+
setOutput(`Status: running=${data.running}, healthy=${data.healthy}, detail=${data.detail}`);
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
setOutput(`Status failed: ${err.message}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
async function fetchEngineLogs() {
|
|
1797
|
+
try {
|
|
1798
|
+
const params = new URLSearchParams({
|
|
1799
|
+
engine: $("engine-name").value.trim() || "fish-speech",
|
|
1800
|
+
runtime: $("engine-runtime").value,
|
|
1801
|
+
api_url: $("engine-api-url").value.trim() || "http://127.0.0.1:8080",
|
|
1802
|
+
install_dir: $("engine-install-dir").value.trim() || "~/AI/services",
|
|
1803
|
+
lines: "120",
|
|
1804
|
+
});
|
|
1805
|
+
const data = await fetchJson(`/v1/engines/logs?${params.toString()}`);
|
|
1806
|
+
setOutput(`Logs:\n${data.logs || "(empty)"}`);
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
setOutput(`Logs failed: ${err.message}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
async function followTaskFromInput() {
|
|
1813
|
+
const taskId = $("task-id-input").value.trim();
|
|
1814
|
+
if (!taskId) return;
|
|
1815
|
+
subscribeTask(taskId);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
async function cancelTaskFromInput() {
|
|
1819
|
+
const taskId = $("task-id-input").value.trim();
|
|
1820
|
+
if (!taskId) return;
|
|
1821
|
+
try {
|
|
1822
|
+
const data = await fetchJson(`/v1/engines/tasks/${taskId}/cancel`, { method: "POST" });
|
|
1823
|
+
setOutput(`Cancel requested: ${data.task.task_id}`);
|
|
1824
|
+
subscribeTask(taskId);
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
setOutput(`Cancel failed: ${err.message}`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
function bindEvents() {
|
|
1831
|
+
$("refresh-all").addEventListener("click", () => {
|
|
1832
|
+
refreshDashboard().catch((err) => setOutput(`Refresh failed: ${err.message}`));
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
$("stt-form").addEventListener("submit", runSTT);
|
|
1836
|
+
$("stt-mode").addEventListener("change", updateSTTModeUI);
|
|
1837
|
+
$("stt-live-start").addEventListener("click", (e) => {
|
|
1838
|
+
e.preventDefault();
|
|
1839
|
+
startLiveSTT().catch((err) => {
|
|
1840
|
+
setSTTLiveStatus("error");
|
|
1841
|
+
setText("stt-result", `Error: ${err.message}`);
|
|
1842
|
+
});
|
|
1843
|
+
});
|
|
1844
|
+
$("stt-live-stop").addEventListener("click", (e) => {
|
|
1845
|
+
e.preventDefault();
|
|
1846
|
+
stopLiveSTT().catch((err) => {
|
|
1847
|
+
setSTTLiveStatus("error");
|
|
1848
|
+
setText("stt-result", `Error: ${err.message}`);
|
|
1849
|
+
});
|
|
1850
|
+
});
|
|
1851
|
+
$("stt-provider").addEventListener("change", () => {
|
|
1852
|
+
loadSTTFieldOptions($("stt-provider").value);
|
|
1853
|
+
});
|
|
1854
|
+
$("tts-provider").addEventListener("change", () => {
|
|
1855
|
+
loadTTSVoices($("tts-provider").value);
|
|
1856
|
+
loadTTSFieldOptions($("tts-provider").value);
|
|
1857
|
+
});
|
|
1858
|
+
$("tts-model").addEventListener("change", () => {
|
|
1859
|
+
maybeAutoAdjustTTSTransportByModel();
|
|
1860
|
+
});
|
|
1861
|
+
$("tts-form").addEventListener("submit", runTTS);
|
|
1862
|
+
|
|
1863
|
+
document.querySelectorAll("button[data-action]").forEach((btn) => {
|
|
1864
|
+
btn.addEventListener("click", (e) => {
|
|
1865
|
+
e.preventDefault();
|
|
1866
|
+
const action = btn.dataset.action;
|
|
1867
|
+
triggerEngineAction(action);
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
if ($("engine-status-btn")) $("engine-status-btn").addEventListener("click", (e) => {
|
|
1872
|
+
e.preventDefault();
|
|
1873
|
+
fetchEngineStatus();
|
|
1874
|
+
});
|
|
1875
|
+
if ($("engine-logs-btn")) $("engine-logs-btn").addEventListener("click", (e) => {
|
|
1876
|
+
e.preventDefault();
|
|
1877
|
+
fetchEngineLogs();
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
if ($("task-follow-btn")) $("task-follow-btn").addEventListener("click", (e) => {
|
|
1881
|
+
e.preventDefault();
|
|
1882
|
+
followTaskFromInput();
|
|
1883
|
+
});
|
|
1884
|
+
if ($("task-cancel-btn")) $("task-cancel-btn").addEventListener("click", (e) => {
|
|
1885
|
+
e.preventDefault();
|
|
1886
|
+
cancelTaskFromInput();
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function formatEta(sec) {
|
|
1891
|
+
const v = Math.max(0, Number(sec || 0));
|
|
1892
|
+
const h = Math.floor(v / 3600);
|
|
1893
|
+
const m = Math.floor((v % 3600) / 60);
|
|
1894
|
+
const s = Math.floor(v % 60);
|
|
1895
|
+
if (h > 0) return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
1896
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// --- Engine Catalog ---
|
|
1900
|
+
async function loadCatalog() {
|
|
1901
|
+
try {
|
|
1902
|
+
const [catalogData, healthData] = await Promise.all([
|
|
1903
|
+
fetchJson('/v1/engine-catalog'),
|
|
1904
|
+
fetchJson('/v1/health'),
|
|
1905
|
+
]);
|
|
1906
|
+
renderCatalog(catalogData.engines || [], healthData.engines || {});
|
|
1907
|
+
} catch (e) {
|
|
1908
|
+
const el = document.getElementById('catalog-list');
|
|
1909
|
+
if (el) el.innerHTML = '<p>Failed to load catalog: ' + e.message + '</p>';
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function renderCatalog(engines, healthMap) {
|
|
1914
|
+
const container = document.getElementById('catalog-list');
|
|
1915
|
+
container.innerHTML = '';
|
|
1916
|
+
|
|
1917
|
+
// Batch action toolbar
|
|
1918
|
+
const toolbar = document.createElement('div');
|
|
1919
|
+
toolbar.className = 'catalog-toolbar';
|
|
1920
|
+
toolbar.innerHTML =
|
|
1921
|
+
'<label class="catalog-select-all"><input type="checkbox" id="catalog-select-all-cb"> Select all compatible</label>' +
|
|
1922
|
+
'<button class="btn btn-xs btn-install" id="catalog-install-selected" disabled>Install Selected</button>' +
|
|
1923
|
+
'<button class="btn btn-xs btn-install" id="catalog-install-all">Install All Compatible</button>';
|
|
1924
|
+
container.appendChild(toolbar);
|
|
1925
|
+
|
|
1926
|
+
const groups = ENGINE_GROUPS;
|
|
1927
|
+
|
|
1928
|
+
for (const g of groups) {
|
|
1929
|
+
const items = engines.filter(g.filter);
|
|
1930
|
+
if (items.length === 0) continue;
|
|
1931
|
+
|
|
1932
|
+
const section = document.createElement('div');
|
|
1933
|
+
section.className = 'catalog-group';
|
|
1934
|
+
section.innerHTML = '<h3 class="provider-group-title">' + g.label + '</h3>';
|
|
1935
|
+
|
|
1936
|
+
for (const e of items) {
|
|
1937
|
+
// Determine health from actual health API (by alias)
|
|
1938
|
+
const healthy = e.installed && healthMap[e.default_alias];
|
|
1939
|
+
const isLocal = e.category === 'local' || e.category === 'native';
|
|
1940
|
+
const compatible = e.compatible !== false;
|
|
1941
|
+
|
|
1942
|
+
let statusClass, statusLabel;
|
|
1943
|
+
if (!compatible) {
|
|
1944
|
+
statusClass = 'dot-gray';
|
|
1945
|
+
statusLabel = 'Not compatible';
|
|
1946
|
+
} else if (!e.installed) {
|
|
1947
|
+
statusClass = 'dot-gray';
|
|
1948
|
+
statusLabel = 'Not installed';
|
|
1949
|
+
} else if (healthy) {
|
|
1950
|
+
statusClass = 'dot-green';
|
|
1951
|
+
statusLabel = 'Healthy';
|
|
1952
|
+
} else {
|
|
1953
|
+
statusClass = 'dot-yellow';
|
|
1954
|
+
statusLabel = 'Installed (not configured)';
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Build action buttons based on state
|
|
1958
|
+
let actions = '';
|
|
1959
|
+
if (!compatible) {
|
|
1960
|
+
actions = '<span class="badge-incompatible">Not compatible</span>';
|
|
1961
|
+
} else if (!e.installed) {
|
|
1962
|
+
actions = '<button class="btn btn-xs btn-install" onclick="catalogInstall(\'' + e.name + '\')">Install</button>';
|
|
1963
|
+
} else {
|
|
1964
|
+
if (isLocal) {
|
|
1965
|
+
actions += '<button class="btn btn-xs" onclick="engineAction(\'' + e.default_alias + '\',\'start\')">Start</button>';
|
|
1966
|
+
actions += '<button class="btn btn-xs" onclick="engineAction(\'' + e.default_alias + '\',\'stop\')">Stop</button>';
|
|
1967
|
+
}
|
|
1968
|
+
actions += '<button class="btn btn-xs btn-danger" onclick="catalogUninstall(\'' + e.name + '\')">Uninstall</button>';
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// Checkbox for batch select (only for compatible, not-installed engines)
|
|
1972
|
+
const canSelect = compatible && !e.installed;
|
|
1973
|
+
const checkbox = canSelect
|
|
1974
|
+
? '<input type="checkbox" class="catalog-cb" data-engine="' + e.name + '">'
|
|
1975
|
+
: '<input type="checkbox" class="catalog-cb" disabled>';
|
|
1976
|
+
|
|
1977
|
+
const row = document.createElement('div');
|
|
1978
|
+
row.className = 'provider-row' + (!compatible ? ' incompatible' : '');
|
|
1979
|
+
const displayName = e.display_name || e.name;
|
|
1980
|
+
const nameLink = e.installed
|
|
1981
|
+
? '<a href="#" class="engine-name-link" data-alias="' + e.default_alias + '">' + displayName + '</a>'
|
|
1982
|
+
: displayName;
|
|
1983
|
+
row.innerHTML =
|
|
1984
|
+
'<div class="provider-info">' +
|
|
1985
|
+
checkbox +
|
|
1986
|
+
'<span class="dot ' + statusClass + '" title="' + statusLabel + '"></span>' +
|
|
1987
|
+
'<strong>' + nameLink + '</strong>' +
|
|
1988
|
+
'<span class="provider-engine">' + e.name + '</span>' +
|
|
1989
|
+
'<span class="provider-detail">' + e.description + '</span>' +
|
|
1990
|
+
'</div>' +
|
|
1991
|
+
'<div class="provider-actions">' + actions + '</div>';
|
|
1992
|
+
// Click engine name → jump to Config
|
|
1993
|
+
const link = row.querySelector('.engine-name-link');
|
|
1994
|
+
if (link) {
|
|
1995
|
+
link.addEventListener('click', (ev) => {
|
|
1996
|
+
ev.preventDefault();
|
|
1997
|
+
navigateToConfig(link.dataset.alias);
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
section.appendChild(row);
|
|
2001
|
+
}
|
|
2002
|
+
container.appendChild(section);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Wire up batch controls
|
|
2006
|
+
const selectAllCb = document.getElementById('catalog-select-all-cb');
|
|
2007
|
+
const installSelectedBtn = document.getElementById('catalog-install-selected');
|
|
2008
|
+
const installAllBtn = document.getElementById('catalog-install-all');
|
|
2009
|
+
|
|
2010
|
+
function updateInstallSelectedBtn() {
|
|
2011
|
+
const checked = container.querySelectorAll('.catalog-cb:checked');
|
|
2012
|
+
installSelectedBtn.disabled = checked.length === 0;
|
|
2013
|
+
installSelectedBtn.textContent = checked.length > 0
|
|
2014
|
+
? 'Install Selected (' + checked.length + ')'
|
|
2015
|
+
: 'Install Selected';
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
container.addEventListener('change', function(ev) {
|
|
2019
|
+
if (ev.target.classList.contains('catalog-cb')) {
|
|
2020
|
+
updateInstallSelectedBtn();
|
|
2021
|
+
// Update select-all state
|
|
2022
|
+
const allCbs = container.querySelectorAll('.catalog-cb:not(:disabled)');
|
|
2023
|
+
const checkedCbs = container.querySelectorAll('.catalog-cb:checked');
|
|
2024
|
+
selectAllCb.checked = allCbs.length > 0 && allCbs.length === checkedCbs.length;
|
|
2025
|
+
selectAllCb.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
|
|
2029
|
+
if (selectAllCb) {
|
|
2030
|
+
selectAllCb.addEventListener('change', function() {
|
|
2031
|
+
const cbs = container.querySelectorAll('.catalog-cb:not(:disabled)');
|
|
2032
|
+
cbs.forEach(cb => { cb.checked = selectAllCb.checked; });
|
|
2033
|
+
updateInstallSelectedBtn();
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
if (installSelectedBtn) {
|
|
2038
|
+
installSelectedBtn.addEventListener('click', async function() {
|
|
2039
|
+
const checked = container.querySelectorAll('.catalog-cb:checked');
|
|
2040
|
+
const names = Array.from(checked).map(cb => cb.dataset.engine);
|
|
2041
|
+
if (names.length === 0) return;
|
|
2042
|
+
await catalogBatchInstall(names);
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (installAllBtn) {
|
|
2047
|
+
installAllBtn.addEventListener('click', async function() {
|
|
2048
|
+
if (!confirm('Install all compatible engines?')) return;
|
|
2049
|
+
await catalogBatchInstall(['all']);
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
window.catalogInstall = async function(name) {
|
|
2055
|
+
try {
|
|
2056
|
+
addLog(name, 'install', 'running', 'Installing...');
|
|
2057
|
+
const data = await fetchJson('/v1/engine-catalog/' + name + '/install', { method: 'POST' });
|
|
2058
|
+
addLog(name, 'install', 'ok', 'Installed as ' + (data.alias || name));
|
|
2059
|
+
await loadCatalog();
|
|
2060
|
+
await refreshDashboard();
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
addLog(name, 'install', 'failed', e.message);
|
|
2063
|
+
alert('Install failed: ' + e.message);
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
window.catalogUninstall = async function(name) {
|
|
2068
|
+
if (!confirm('Uninstall ' + name + '? This will remove it from the config.')) return;
|
|
2069
|
+
try {
|
|
2070
|
+
addLog(name, 'uninstall', 'running', 'Uninstalling...');
|
|
2071
|
+
const data = await fetchJson('/v1/engine-catalog/' + name + '/uninstall', { method: 'POST' });
|
|
2072
|
+
addLog(name, 'uninstall', 'ok', 'Removed: ' + (data.removed || []).join(', '));
|
|
2073
|
+
await loadCatalog();
|
|
2074
|
+
await refreshDashboard();
|
|
2075
|
+
} catch (e) {
|
|
2076
|
+
addLog(name, 'uninstall', 'failed', e.message);
|
|
2077
|
+
alert('Uninstall failed: ' + e.message);
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
|
|
2081
|
+
window.catalogBatchInstall = async function(names) {
|
|
2082
|
+
try {
|
|
2083
|
+
addLog('batch', 'install', 'running', 'Installing ' + (names[0] === 'all' ? 'all compatible engines' : names.length + ' engines') + '...');
|
|
2084
|
+
const data = await fetchJson('/v1/engine-catalog/batch-install', {
|
|
2085
|
+
method: 'POST',
|
|
2086
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2087
|
+
body: JSON.stringify({ engines: names }),
|
|
2088
|
+
});
|
|
2089
|
+
const installed = (data.results || []).map(r => r.alias).join(', ');
|
|
2090
|
+
addLog('batch', 'install', 'ok', 'Installed: ' + (installed || 'none'));
|
|
2091
|
+
await loadCatalog();
|
|
2092
|
+
await refreshDashboard();
|
|
2093
|
+
} catch (e) {
|
|
2094
|
+
addLog('batch', 'install', 'failed', e.message);
|
|
2095
|
+
alert('Batch install failed: ' + e.message);
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
|
|
2099
|
+
// Navigate from Engines → Config tab with alias selected
|
|
2100
|
+
function navigateToConfig(alias) {
|
|
2101
|
+
// Switch to config tab
|
|
2102
|
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
2103
|
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
2104
|
+
document.querySelector('.tab-btn[data-tab="config"]').classList.add('active');
|
|
2105
|
+
document.getElementById('tab-config').classList.add('active');
|
|
2106
|
+
// Load config and select the alias
|
|
2107
|
+
selectedConfigAlias = alias;
|
|
2108
|
+
loadConfig();
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// --- Logs ---
|
|
2112
|
+
const activityLogs = [];
|
|
2113
|
+
|
|
2114
|
+
function addLog(engine, action, status, message) {
|
|
2115
|
+
activityLogs.unshift({
|
|
2116
|
+
time: new Date().toLocaleTimeString(),
|
|
2117
|
+
engine, action, status, message,
|
|
2118
|
+
});
|
|
2119
|
+
if (activityLogs.length > 200) activityLogs.length = 200;
|
|
2120
|
+
renderLogs();
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function renderLogs() {
|
|
2124
|
+
const tbody = document.getElementById('logs-table');
|
|
2125
|
+
if (!tbody) return;
|
|
2126
|
+
tbody.innerHTML = '';
|
|
2127
|
+
for (const log of activityLogs) {
|
|
2128
|
+
const statusCls = log.status === 'ok' ? 'color:var(--accent)' :
|
|
2129
|
+
log.status === 'failed' ? 'color:var(--danger)' : '';
|
|
2130
|
+
const tr = document.createElement('tr');
|
|
2131
|
+
tr.innerHTML = '<td>' + log.time + '</td><td>' + log.engine + '</td><td>' + log.action +
|
|
2132
|
+
'</td><td style="' + statusCls + '">' + log.status + '</td><td>' + log.message + '</td>';
|
|
2133
|
+
tbody.appendChild(tr);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function loadLogs() {
|
|
2138
|
+
renderLogs();
|
|
2139
|
+
// Also load engine tasks
|
|
2140
|
+
fetchJson('/v1/engines/tasks?limit=50').then(data => {
|
|
2141
|
+
const detail = document.getElementById('logs-detail');
|
|
2142
|
+
if (detail && data.tasks && data.tasks.length) {
|
|
2143
|
+
detail.textContent = data.tasks.map(t =>
|
|
2144
|
+
`[${t.status}] ${t.engine || ''} ${t.action} — ${t.message || ''}`
|
|
2145
|
+
).join('\n');
|
|
2146
|
+
}
|
|
2147
|
+
}).catch(() => {});
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// --- Config page (split layout) ---
|
|
2151
|
+
let configData = {};
|
|
2152
|
+
let configProvidersData = {}; // Shared credential providers
|
|
2153
|
+
let configHealthData = {};
|
|
2154
|
+
let providerTemplates = [];
|
|
2155
|
+
let vendorTemplates = {};
|
|
2156
|
+
let selectedConfigAlias = null;
|
|
2157
|
+
let selectedProviderName = null; // Currently selected provider in sidebar
|
|
2158
|
+
|
|
2159
|
+
async function loadConfig() {
|
|
2160
|
+
try {
|
|
2161
|
+
const [cfgRes, tplRes, healthRes, vendorRes, provRes] = await Promise.all([
|
|
2162
|
+
fetch('/v1/admin/config'),
|
|
2163
|
+
fetch('/v1/admin/provider-templates'),
|
|
2164
|
+
fetch('/v1/health'),
|
|
2165
|
+
fetch('/v1/admin/vendor-templates'),
|
|
2166
|
+
fetch('/v1/admin/providers'),
|
|
2167
|
+
]);
|
|
2168
|
+
const cfgJson = await cfgRes.json();
|
|
2169
|
+
configData = cfgJson.engines || {};
|
|
2170
|
+
configProvidersData = cfgJson.providers || {};
|
|
2171
|
+
providerTemplates = (await tplRes.json()).templates || [];
|
|
2172
|
+
configHealthData = (await healthRes.json()).engines || {};
|
|
2173
|
+
vendorTemplates = (await vendorRes.json()).vendors || {};
|
|
2174
|
+
// Merge providers from dedicated endpoint (may have more detail)
|
|
2175
|
+
const provJson = await provRes.json();
|
|
2176
|
+
if (provJson.providers) {
|
|
2177
|
+
configProvidersData = { ...configProvidersData, ...provJson.providers };
|
|
2178
|
+
}
|
|
2179
|
+
renderConfigSidebar();
|
|
2180
|
+
if (selectedProviderName && configProvidersData[selectedProviderName]) {
|
|
2181
|
+
renderProviderDetail(selectedProviderName);
|
|
2182
|
+
} else if (selectedConfigAlias && configData[selectedConfigAlias]) {
|
|
2183
|
+
renderConfigDetail(selectedConfigAlias);
|
|
2184
|
+
} else {
|
|
2185
|
+
document.getElementById('config-detail-content').innerHTML =
|
|
2186
|
+
'<p class="config-placeholder">Select a vendor or engine from the left to edit its settings.</p>';
|
|
2187
|
+
}
|
|
2188
|
+
} catch (e) {
|
|
2189
|
+
showConfigStatus('Failed to load config: ' + e.message, true);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function configGroupLabel(alias, cfg) {
|
|
2194
|
+
const ptype = cfg.type || 'unknown';
|
|
2195
|
+
const isCloud = cfg.category === 'cloud';
|
|
2196
|
+
if (ptype === 'stt') return isCloud ? 'STT — Cloud' : 'STT — Local / Native';
|
|
2197
|
+
if (ptype === 'tts') return isCloud ? 'TTS — Cloud' : 'TTS — Local / Native';
|
|
2198
|
+
return 'Other';
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
function renderConfigSidebar() {
|
|
2202
|
+
const sidebar = document.getElementById('config-sidebar-list');
|
|
2203
|
+
sidebar.innerHTML = '';
|
|
2204
|
+
|
|
2205
|
+
// --- Providers (shared credentials) section ---
|
|
2206
|
+
const providerNames = Object.keys(configProvidersData);
|
|
2207
|
+
if (providerNames.length > 0) {
|
|
2208
|
+
const provSection = document.createElement('div');
|
|
2209
|
+
provSection.className = 'config-sidebar-group';
|
|
2210
|
+
provSection.innerHTML = '<h4>Vendors</h4>';
|
|
2211
|
+
for (const name of providerNames) {
|
|
2212
|
+
const vendorTpl = vendorTemplates[name] || {};
|
|
2213
|
+
const displayLabel = vendorTpl.display_name || name;
|
|
2214
|
+
// Count engines using this provider
|
|
2215
|
+
const usedBy = Object.entries(configData)
|
|
2216
|
+
.filter(([_, cfg]) => cfg.provider === name)
|
|
2217
|
+
.map(([alias]) => alias);
|
|
2218
|
+
const item = document.createElement('div');
|
|
2219
|
+
item.className = 'config-sidebar-item' + (name === selectedProviderName ? ' active' : '');
|
|
2220
|
+
item.innerHTML = `<span class="dot dot-blue"></span>${displayLabel}` +
|
|
2221
|
+
(usedBy.length ? ` <span class="provider-count">(${usedBy.length})</span>` : '');
|
|
2222
|
+
item.addEventListener('click', () => {
|
|
2223
|
+
selectedProviderName = name;
|
|
2224
|
+
selectedConfigAlias = null;
|
|
2225
|
+
renderConfigSidebar();
|
|
2226
|
+
renderProviderDetail(name);
|
|
2227
|
+
});
|
|
2228
|
+
provSection.appendChild(item);
|
|
2229
|
+
}
|
|
2230
|
+
sidebar.appendChild(provSection);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// --- Engine groups ---
|
|
2234
|
+
const groups = {};
|
|
2235
|
+
const groupOrder = ['STT — Cloud', 'STT — Local / Native', 'TTS — Cloud', 'TTS — Local / Native', 'Other'];
|
|
2236
|
+
for (const [alias, cfg] of Object.entries(configData)) {
|
|
2237
|
+
const g = configGroupLabel(alias, cfg);
|
|
2238
|
+
if (!groups[g]) groups[g] = [];
|
|
2239
|
+
groups[g].push({ alias, cfg });
|
|
2240
|
+
}
|
|
2241
|
+
for (const gName of groupOrder) {
|
|
2242
|
+
const items = groups[gName] || [];
|
|
2243
|
+
const section = document.createElement('div');
|
|
2244
|
+
section.className = 'config-sidebar-group';
|
|
2245
|
+
section.innerHTML = `<h4>${gName}</h4>`;
|
|
2246
|
+
for (const { alias, cfg } of items) {
|
|
2247
|
+
const healthy = configHealthData[alias] || false;
|
|
2248
|
+
const displayLabel = cfg.display_name || alias;
|
|
2249
|
+
const item = document.createElement('div');
|
|
2250
|
+
item.className = 'config-sidebar-item' + (alias === selectedConfigAlias ? ' active' : '');
|
|
2251
|
+
item.innerHTML = `<span class="dot ${healthy ? 'dot-green' : 'dot-red'}"></span>${displayLabel}`;
|
|
2252
|
+
item.addEventListener('click', () => {
|
|
2253
|
+
selectedConfigAlias = alias;
|
|
2254
|
+
selectedProviderName = null;
|
|
2255
|
+
renderConfigSidebar();
|
|
2256
|
+
renderConfigDetail(alias);
|
|
2257
|
+
});
|
|
2258
|
+
section.appendChild(item);
|
|
2259
|
+
}
|
|
2260
|
+
sidebar.appendChild(section);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
function renderProviderDetail(name) {
|
|
2265
|
+
const creds = configProvidersData[name] || {};
|
|
2266
|
+
const vendorTpl = vendorTemplates[name] || {};
|
|
2267
|
+
const displayLabel = vendorTpl.display_name || name;
|
|
2268
|
+
const sharedFields = vendorTpl.shared_fields || {};
|
|
2269
|
+
|
|
2270
|
+
// Find engines using this provider
|
|
2271
|
+
const usedBy = Object.entries(configData)
|
|
2272
|
+
.filter(([_, cfg]) => cfg.provider === name)
|
|
2273
|
+
.map(([alias]) => alias);
|
|
2274
|
+
|
|
2275
|
+
const detail = document.getElementById('config-detail-content');
|
|
2276
|
+
let fieldsHtml = '';
|
|
2277
|
+
|
|
2278
|
+
// Render fields from vendor template
|
|
2279
|
+
const allKeys = new Set([...Object.keys(sharedFields), ...Object.keys(creds)]);
|
|
2280
|
+
for (const k of allKeys) {
|
|
2281
|
+
const fieldDef = sharedFields[k] || {};
|
|
2282
|
+
const isSecret = fieldDef.secret || k.includes('key') || k.includes('secret') || k.includes('token');
|
|
2283
|
+
const val = creds[k] !== undefined ? creds[k] : (fieldDef.default || '');
|
|
2284
|
+
const desc = fieldDef.description || k;
|
|
2285
|
+
const required = fieldDef.required ? ' *' : '';
|
|
2286
|
+
fieldsHtml += `<label>${desc}${required}
|
|
2287
|
+
<input data-provider="${name}" data-field="${k}" value="${val}"
|
|
2288
|
+
type="${isSecret ? 'password' : 'text'}"
|
|
2289
|
+
placeholder="${fieldDef.default || ''}"
|
|
2290
|
+
autocomplete="off" />
|
|
2291
|
+
</label>`;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
detail.innerHTML = `
|
|
2295
|
+
<div class="config-detail-head">
|
|
2296
|
+
<h3>${displayLabel} <span class="badge provider">Vendor</span></h3>
|
|
2297
|
+
<button class="btn btn-danger btn-xs" onclick="removeConfigProvider('${name}')">Remove</button>
|
|
2298
|
+
</div>
|
|
2299
|
+
<div class="config-fields">
|
|
2300
|
+
${fieldsHtml}
|
|
2301
|
+
</div>
|
|
2302
|
+
${usedBy.length ? `<div class="provider-used-by"><strong>Used by:</strong> ${usedBy.join(', ')}</div>` : ''}
|
|
2303
|
+
<div style="margin-top:12px">
|
|
2304
|
+
<button class="btn" onclick="saveProviderCredentials('${name}')">Save Vendor</button>
|
|
2305
|
+
</div>
|
|
2306
|
+
`;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
window.saveProviderCredentials = async function(name) {
|
|
2310
|
+
const fields = document.querySelectorAll(`[data-provider="${name}"][data-field]`);
|
|
2311
|
+
const creds = {};
|
|
2312
|
+
fields.forEach(el => {
|
|
2313
|
+
const val = el.value;
|
|
2314
|
+
// Preserve masked values
|
|
2315
|
+
if (val.includes('****')) {
|
|
2316
|
+
const oldVal = configProvidersData[name] ? configProvidersData[name][el.dataset.field] : val;
|
|
2317
|
+
creds[el.dataset.field] = oldVal || val;
|
|
2318
|
+
} else {
|
|
2319
|
+
creds[el.dataset.field] = val;
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
try {
|
|
2324
|
+
// Get raw config to resolve masked values
|
|
2325
|
+
const rawRes = await fetch('/v1/admin/config/raw');
|
|
2326
|
+
const rawConfig = await rawRes.json();
|
|
2327
|
+
const rawProviders = rawConfig.providers || {};
|
|
2328
|
+
const rawCreds = rawProviders[name] || {};
|
|
2329
|
+
|
|
2330
|
+
// Replace masked values with raw ones
|
|
2331
|
+
for (const [k, v] of Object.entries(creds)) {
|
|
2332
|
+
if (typeof v === 'string' && v.includes('****') && rawCreds[k]) {
|
|
2333
|
+
creds[k] = rawCreds[k];
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Update providers section
|
|
2338
|
+
const allProviders = { ...rawProviders, [name]: creds };
|
|
2339
|
+
const res = await fetch('/v1/admin/providers', {
|
|
2340
|
+
method: 'PUT',
|
|
2341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2342
|
+
body: JSON.stringify({ providers: allProviders }),
|
|
2343
|
+
});
|
|
2344
|
+
const data = await res.json();
|
|
2345
|
+
if (res.ok) {
|
|
2346
|
+
showConfigStatus('Vendor saved!', false);
|
|
2347
|
+
await loadConfig();
|
|
2348
|
+
} else {
|
|
2349
|
+
showConfigStatus('Save failed: ' + (data.detail || 'unknown error'), true);
|
|
2350
|
+
}
|
|
2351
|
+
} catch (e) {
|
|
2352
|
+
showConfigStatus('Save error: ' + e.message, true);
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2356
|
+
window.removeConfigProvider = function(name) {
|
|
2357
|
+
delete configProvidersData[name];
|
|
2358
|
+
if (selectedProviderName === name) {
|
|
2359
|
+
selectedProviderName = null;
|
|
2360
|
+
document.getElementById('config-detail-content').innerHTML =
|
|
2361
|
+
'<p class="config-placeholder">Vendor removed. Save & Apply to persist.</p>';
|
|
2362
|
+
}
|
|
2363
|
+
renderConfigSidebar();
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
function renderSettingField(alias, k, v, fieldOptions) {
|
|
2367
|
+
const rawKey = k.replace(/^default_/, '');
|
|
2368
|
+
const label = 'Default ' + rawKey.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2369
|
+
const opts = fieldOptions[k];
|
|
2370
|
+
if (opts && opts.length > 0) {
|
|
2371
|
+
// Render as select with auto option + provider options
|
|
2372
|
+
const currentVal = String(v);
|
|
2373
|
+
const autoSelected = !currentVal ? 'selected' : '';
|
|
2374
|
+
const hasCustom = currentVal && !opts.map(String).includes(currentVal);
|
|
2375
|
+
return `<label>${label}
|
|
2376
|
+
<select data-alias="${alias}" data-setting="${k}">
|
|
2377
|
+
<option value="" ${autoSelected}>(auto)</option>
|
|
2378
|
+
${hasCustom ? `<option value="${currentVal}" selected>${currentVal} (custom)</option>` : ''}
|
|
2379
|
+
${opts.map(o => `<option value="${o}" ${String(o) === currentVal ? 'selected' : ''}>${o}</option>`).join('')}
|
|
2380
|
+
</select>
|
|
2381
|
+
</label>`;
|
|
2382
|
+
}
|
|
2383
|
+
// Render as input
|
|
2384
|
+
const isSecret = k.includes('key') || k.includes('secret') || k.includes('token');
|
|
2385
|
+
return `<label>${label}
|
|
2386
|
+
<input data-alias="${alias}" data-setting="${k}" value="${v}"
|
|
2387
|
+
type="${typeof v === 'number' ? 'number' : 'text'}"
|
|
2388
|
+
${isSecret ? 'autocomplete="off"' : ''} />
|
|
2389
|
+
</label>`;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
function renderConfigDetail(alias) {
|
|
2393
|
+
const cfg = configData[alias];
|
|
2394
|
+
if (!cfg) return;
|
|
2395
|
+
const tpl = providerTemplates.find(t => t.provider === (cfg.provider_key || cfg.provider));
|
|
2396
|
+
const ptype = cfg.type || (tpl ? tpl.type : 'unknown');
|
|
2397
|
+
const displayLabel = cfg.display_name || (tpl && tpl.display_name) || alias;
|
|
2398
|
+
const fieldOptions = (tpl && tpl.field_options) ? tpl.field_options : {};
|
|
2399
|
+
const execModes = cfg.category === 'cloud'
|
|
2400
|
+
? ['remote']
|
|
2401
|
+
: ['in_process', 'local', 'subprocess'];
|
|
2402
|
+
const detail = document.getElementById('config-detail-content');
|
|
2403
|
+
detail.innerHTML = `
|
|
2404
|
+
<div class="config-detail-head">
|
|
2405
|
+
<h3>${displayLabel} <span class="badge ${ptype}">${ptype} / ${alias}</span></h3>
|
|
2406
|
+
<button class="btn btn-danger btn-xs" onclick="removeProvider('${alias}')">Remove</button>
|
|
2407
|
+
</div>
|
|
2408
|
+
<div class="config-fields">
|
|
2409
|
+
<label>exec_mode
|
|
2410
|
+
<select data-alias="${alias}" data-key="exec_mode">
|
|
2411
|
+
${execModes.map(m =>
|
|
2412
|
+
`<option value="${m}" ${cfg.exec_mode === m ? 'selected' : ''}>${m}</option>`
|
|
2413
|
+
).join('')}
|
|
2414
|
+
</select>
|
|
2415
|
+
</label>
|
|
2416
|
+
${Object.entries(cfg.settings || {}).map(([k, v]) =>
|
|
2417
|
+
renderSettingField(alias, k, v, fieldOptions)
|
|
2418
|
+
).join('')}
|
|
2419
|
+
</div>
|
|
2420
|
+
`;
|
|
2421
|
+
|
|
2422
|
+
// For TTS engines: load voice list from API and upgrade default_voice to select
|
|
2423
|
+
if (ptype === 'tts') {
|
|
2424
|
+
_loadConfigVoiceOptions(alias, cfg);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
async function _loadConfigVoiceOptions(alias, cfg) {
|
|
2429
|
+
const voiceKey = cfg.settings && 'default_voice' in cfg.settings ? 'default_voice'
|
|
2430
|
+
: cfg.settings && 'voice' in cfg.settings ? 'voice' : null;
|
|
2431
|
+
if (!voiceKey) return;
|
|
2432
|
+
// Check if already rendered as select (has field_options)
|
|
2433
|
+
const existing = document.querySelector(`select[data-alias="${alias}"][data-setting="${voiceKey}"]`);
|
|
2434
|
+
if (existing) return;
|
|
2435
|
+
try {
|
|
2436
|
+
const data = await fetchJson(`/v1/tts/${alias}/voices`);
|
|
2437
|
+
const voices = data.voices || [];
|
|
2438
|
+
if (voices.length === 0) return;
|
|
2439
|
+
const input = document.querySelector(`input[data-alias="${alias}"][data-setting="${voiceKey}"]`);
|
|
2440
|
+
if (!input) return;
|
|
2441
|
+
const currentVal = input.value;
|
|
2442
|
+
const label = input.closest('label');
|
|
2443
|
+
const select = document.createElement('select');
|
|
2444
|
+
select.dataset.alias = alias;
|
|
2445
|
+
select.dataset.setting = voiceKey;
|
|
2446
|
+
// Add (auto) option
|
|
2447
|
+
const autoOpt = document.createElement('option');
|
|
2448
|
+
autoOpt.value = '';
|
|
2449
|
+
autoOpt.textContent = '(auto)';
|
|
2450
|
+
if (!currentVal) autoOpt.selected = true;
|
|
2451
|
+
select.appendChild(autoOpt);
|
|
2452
|
+
// Group voices
|
|
2453
|
+
const groups = {};
|
|
2454
|
+
for (const v of voices) {
|
|
2455
|
+
const g = v.group || '';
|
|
2456
|
+
if (!groups[g]) groups[g] = [];
|
|
2457
|
+
groups[g].push(v);
|
|
2458
|
+
}
|
|
2459
|
+
for (const [gName, gVoices] of Object.entries(groups)) {
|
|
2460
|
+
const optgroup = document.createElement('optgroup');
|
|
2461
|
+
optgroup.label = gName || 'Default';
|
|
2462
|
+
for (const v of gVoices) {
|
|
2463
|
+
const opt = document.createElement('option');
|
|
2464
|
+
opt.value = v.name;
|
|
2465
|
+
opt.textContent = v.name;
|
|
2466
|
+
if (v.name === currentVal) opt.selected = true;
|
|
2467
|
+
optgroup.appendChild(opt);
|
|
2468
|
+
}
|
|
2469
|
+
select.appendChild(optgroup);
|
|
2470
|
+
}
|
|
2471
|
+
input.replaceWith(select);
|
|
2472
|
+
} catch (_) {}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function collectConfigForAlias(alias) {
|
|
2476
|
+
const cfg = configData[alias];
|
|
2477
|
+
if (!cfg) return null;
|
|
2478
|
+
const entry = { provider: cfg.provider, exec_mode: cfg.exec_mode, settings: { ...cfg.settings } };
|
|
2479
|
+
const modeSelect = document.querySelector(`select[data-alias="${alias}"][data-key="exec_mode"]`);
|
|
2480
|
+
if (modeSelect) entry.exec_mode = modeSelect.value;
|
|
2481
|
+
// Collect from both <input> and <select> elements with data-setting
|
|
2482
|
+
document.querySelectorAll(`[data-alias="${alias}"][data-setting]`).forEach(el => {
|
|
2483
|
+
const key = el.dataset.setting;
|
|
2484
|
+
let val = el.value;
|
|
2485
|
+
if (typeof cfg.settings[key] === 'number') val = Number(val);
|
|
2486
|
+
if (typeof cfg.settings[key] === 'boolean') val = val === 'true';
|
|
2487
|
+
entry.settings[key] = val;
|
|
2488
|
+
});
|
|
2489
|
+
return entry;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function collectConfig() {
|
|
2493
|
+
// Collect currently-editing alias from DOM, rest from configData
|
|
2494
|
+
const engines = {};
|
|
2495
|
+
for (const [alias, cfg] of Object.entries(configData)) {
|
|
2496
|
+
if (alias === selectedConfigAlias) {
|
|
2497
|
+
const edited = collectConfigForAlias(alias);
|
|
2498
|
+
if (edited) { engines[alias] = edited; continue; }
|
|
2499
|
+
}
|
|
2500
|
+
engines[alias] = { provider: cfg.provider, exec_mode: cfg.exec_mode, settings: { ...cfg.settings } };
|
|
2501
|
+
}
|
|
2502
|
+
return engines;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
window.removeProvider = function(alias) {
|
|
2506
|
+
delete configData[alias];
|
|
2507
|
+
if (selectedConfigAlias === alias) {
|
|
2508
|
+
selectedConfigAlias = null;
|
|
2509
|
+
document.getElementById('config-detail-content').innerHTML =
|
|
2510
|
+
'<p class="config-placeholder">Engine removed. Select another or Save & Apply.</p>';
|
|
2511
|
+
}
|
|
2512
|
+
renderConfigSidebar();
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
function showConfigStatus(msg, isError) {
|
|
2516
|
+
const el = document.getElementById('config-status');
|
|
2517
|
+
el.style.display = 'block';
|
|
2518
|
+
el.textContent = msg;
|
|
2519
|
+
el.style.color = isError ? 'var(--danger)' : 'var(--accent)';
|
|
2520
|
+
setTimeout(() => { el.style.display = 'none'; }, 5000);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
window.closeAddDialog = function() {
|
|
2524
|
+
document.getElementById('config-add-dialog').classList.remove('visible');
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
window.confirmAddProvider = function() {
|
|
2528
|
+
const vendorName = document.getElementById('config-add-template').value;
|
|
2529
|
+
if (!vendorName) { alert('Please select a vendor'); return; }
|
|
2530
|
+
if (configProvidersData[vendorName]) { alert('Vendor already configured'); return; }
|
|
2531
|
+
// Initialize empty credentials from vendor template
|
|
2532
|
+
const vendorTpl = vendorTemplates[vendorName] || {};
|
|
2533
|
+
const creds = {};
|
|
2534
|
+
for (const [k, def] of Object.entries(vendorTpl.shared_fields || {})) {
|
|
2535
|
+
creds[k] = def.default || '';
|
|
2536
|
+
}
|
|
2537
|
+
configProvidersData[vendorName] = creds;
|
|
2538
|
+
selectedProviderName = vendorName;
|
|
2539
|
+
selectedConfigAlias = null;
|
|
2540
|
+
renderConfigSidebar();
|
|
2541
|
+
renderProviderDetail(vendorName);
|
|
2542
|
+
closeAddDialog();
|
|
2543
|
+
};
|
|
2544
|
+
|
|
2545
|
+
function bindConfigEvents() {
|
|
2546
|
+
document.getElementById('config-save-btn').addEventListener('click', async () => {
|
|
2547
|
+
try {
|
|
2548
|
+
const rawRes = await fetch('/v1/admin/config/raw');
|
|
2549
|
+
const rawConfig = await rawRes.json();
|
|
2550
|
+
const rawEngines = rawConfig.engines || rawConfig.providers || {};
|
|
2551
|
+
const rawProviders = rawConfig.providers || {};
|
|
2552
|
+
|
|
2553
|
+
const updates = collectConfig();
|
|
2554
|
+
for (const [alias, entry] of Object.entries(updates)) {
|
|
2555
|
+
const rawEntry = rawEngines[alias];
|
|
2556
|
+
if (!rawEntry) continue;
|
|
2557
|
+
const rawSettings = rawEntry.settings || {};
|
|
2558
|
+
for (const [k, v] of Object.entries(entry.settings)) {
|
|
2559
|
+
if (typeof v === 'string' && v.includes('****')) {
|
|
2560
|
+
entry.settings[k] = rawSettings[k] || v;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
const providersResolved = JSON.parse(JSON.stringify(configProvidersData || {}));
|
|
2565
|
+
for (const [pname, creds] of Object.entries(providersResolved)) {
|
|
2566
|
+
const rawCreds = rawProviders[pname] || {};
|
|
2567
|
+
if (!creds || typeof creds !== 'object') continue;
|
|
2568
|
+
for (const [k, v] of Object.entries(creds)) {
|
|
2569
|
+
if (typeof v === 'string' && v.includes('****')) {
|
|
2570
|
+
providersResolved[pname][k] = rawCreds[k] || v;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
const res = await fetch('/v1/admin/config', {
|
|
2576
|
+
method: 'PUT',
|
|
2577
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2578
|
+
body: JSON.stringify({ engines: updates, providers: providersResolved }),
|
|
2579
|
+
});
|
|
2580
|
+
const data = await res.json();
|
|
2581
|
+
if (res.ok) {
|
|
2582
|
+
const r = data.reload || {};
|
|
2583
|
+
showConfigStatus(
|
|
2584
|
+
`Saved! Added: ${(r.added||[]).length}, Updated: ${(r.updated||[]).length}, Removed: ${(r.removed||[]).length}`,
|
|
2585
|
+
false
|
|
2586
|
+
);
|
|
2587
|
+
await loadConfig();
|
|
2588
|
+
} else {
|
|
2589
|
+
showConfigStatus('Save failed: ' + (data.detail || 'unknown error'), true);
|
|
2590
|
+
}
|
|
2591
|
+
} catch (e) {
|
|
2592
|
+
showConfigStatus('Save error: ' + e.message, true);
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
document.getElementById('config-add-btn').addEventListener('click', () => {
|
|
2597
|
+
const dialog = document.getElementById('config-add-dialog');
|
|
2598
|
+
const select = document.getElementById('config-add-template');
|
|
2599
|
+
// Show vendors not yet configured
|
|
2600
|
+
const availableVendors = Object.entries(vendorTemplates)
|
|
2601
|
+
.filter(([name]) => !configProvidersData[name]);
|
|
2602
|
+
if (availableVendors.length === 0) {
|
|
2603
|
+
alert('All vendors are already configured.');
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
select.innerHTML = availableVendors.map(([name, tpl]) =>
|
|
2607
|
+
`<option value="${name}">${tpl.display_name || name}</option>`
|
|
2608
|
+
).join('');
|
|
2609
|
+
dialog.classList.add('visible');
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
async function init() {
|
|
2614
|
+
// --- Tab switching ---
|
|
2615
|
+
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
2616
|
+
btn.addEventListener('click', () => {
|
|
2617
|
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
2618
|
+
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
2619
|
+
btn.classList.add('active');
|
|
2620
|
+
const tab = document.getElementById('tab-' + btn.dataset.tab);
|
|
2621
|
+
if (tab) tab.classList.add('active');
|
|
2622
|
+
if (btn.dataset.tab === 'config') loadConfig();
|
|
2623
|
+
if (btn.dataset.tab === 'engines') loadCatalog();
|
|
2624
|
+
if (btn.dataset.tab === 'dashboard') refreshDashboard();
|
|
2625
|
+
if (btn.dataset.tab === 'logs') loadLogs();
|
|
2626
|
+
});
|
|
2627
|
+
});
|
|
2628
|
+
|
|
2629
|
+
bindEvents();
|
|
2630
|
+
bindConfigEvents();
|
|
2631
|
+
const catalogRefreshBtn = document.getElementById('catalog-refresh');
|
|
2632
|
+
if (catalogRefreshBtn) catalogRefreshBtn.addEventListener('click', loadCatalog);
|
|
2633
|
+
const logsRefreshBtn = document.getElementById('logs-refresh');
|
|
2634
|
+
if (logsRefreshBtn) logsRefreshBtn.addEventListener('click', loadLogs);
|
|
2635
|
+
const logsClearBtn = document.getElementById('logs-clear');
|
|
2636
|
+
if (logsClearBtn) logsClearBtn.addEventListener('click', () => { activityLogs.length = 0; renderLogs(); $('logs-detail').textContent = ''; });
|
|
2637
|
+
const labLogClearBtn = document.getElementById('lab-log-clear');
|
|
2638
|
+
if (labLogClearBtn) labLogClearBtn.addEventListener('click', labLogClear);
|
|
2639
|
+
window.addEventListener("beforeunload", () => {
|
|
2640
|
+
if (state.sttLive) {
|
|
2641
|
+
stopLiveSTT().catch(() => {});
|
|
2642
|
+
}
|
|
2643
|
+
});
|
|
2644
|
+
updateSTTModeUI();
|
|
2645
|
+
await refreshDashboard();
|
|
2646
|
+
await fetchEngineStatus();
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
init().catch((err) => setOutput(`Init failed: ${err.message}`));
|