chua's picture
Clarify multilingual ASR support
9a6be16
Raw
History Blame Contribute Delete
16.5 kB
const recordBtn = document.getElementById("recordBtn");
const stopBtn = document.getElementById("stopBtn");
const uploadBtn = document.getElementById("uploadBtn");
const fileInput = document.getElementById("fileInput");
const preview = document.getElementById("preview");
const transcript = document.getElementById("transcript");
const raw = document.getElementById("raw");
const health = document.getElementById("health");
const recordStatus = document.getElementById("recordStatus");
const maxTokens = document.getElementById("maxTokens");
const hotwords = document.getElementById("hotwords");
const hotwordStrengthSegments = document.getElementById("hotwordStrengthSegments");
const latency = document.getElementById("latency");
const precisionSegments = document.getElementById("precisionSegments");
const precisionNote = document.getElementById("precisionNote");
const audioPrecisionSegments = document.getElementById("audioPrecisionSegments");
const audioPrecisionNote = document.getElementById("audioPrecisionNote");
let stream = null;
let audioContext = null;
let sourceNode = null;
let processorNode = null;
let monitorGain = null;
let recordedBuffers = [];
let recordingSampleRate = 16000;
let isStoppingRecording = false;
const stopTailCaptureMs = 80;
const wavTailSilenceMs = 300;
let selectedPrecision = "int8";
let availablePrecisions = ["fp32", "int8", "int4"];
let selectedAudioPrecision = "int8";
let availableAudioPrecisions = ["fp32", "int8"];
let selectedHotwordStrength = "normal";
const hotwordStrengths = {
normal: {
topk: 50,
startBoost: 6.0,
continuationBoost: 8.0,
},
strong: {
topk: 50,
startBoost: 7.0,
continuationBoost: 9.0,
},
};
function apiUrl(path) {
if (window.location.protocol === "file:") {
return `http://127.0.0.1:7860${path}`;
}
return path;
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return "--";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
}
function formatSeconds(value) {
const number = Number(value);
return Number.isFinite(number) ? `${number.toFixed(2)}s` : "--";
}
function setBusy(isBusy, label = "Ready") {
recordStatus.textContent = label;
recordBtn.disabled = isBusy;
uploadBtn.disabled = isBusy;
precisionSegments.querySelectorAll("button").forEach((button) => {
button.disabled = isBusy || !availablePrecisions.includes(button.dataset.precision);
});
audioPrecisionSegments.querySelectorAll("button").forEach((button) => {
button.disabled = isBusy || !availableAudioPrecisions.includes(button.dataset.audioPrecision);
});
hotwordStrengthSegments.querySelectorAll("button").forEach((button) => {
button.disabled = isBusy;
});
}
function setPrecisionButtons() {
precisionSegments.querySelectorAll("button").forEach((button) => {
const precision = button.dataset.precision;
button.classList.toggle("active", precision === selectedPrecision);
button.disabled = !availablePrecisions.includes(precision);
});
precisionNote.textContent = availablePrecisions.length ? `${availablePrecisions.join(" / ")} available` : "cache unavailable";
}
function setAudioPrecisionButtons() {
audioPrecisionSegments.querySelectorAll("button").forEach((button) => {
const precision = button.dataset.audioPrecision;
button.classList.toggle("active", precision === selectedAudioPrecision);
button.disabled = !availableAudioPrecisions.includes(precision);
});
audioPrecisionNote.textContent = availableAudioPrecisions.length
? `${availableAudioPrecisions.join(" / ")} available`
: "audio quant unavailable";
}
function setHotwordStrengthButtons() {
hotwordStrengthSegments.querySelectorAll("button").forEach((button) => {
const strength = button.dataset.hotwordStrength;
button.classList.toggle("active", strength === selectedHotwordStrength);
});
}
function applyRuntime(runtime) {
if (!runtime) return;
availablePrecisions = Array.isArray(runtime.available_cache_precisions)
? runtime.available_cache_precisions
: ["fp32"];
availableAudioPrecisions = Array.isArray(runtime.available_audio_precisions)
? runtime.available_audio_precisions
: ["fp32"];
selectedPrecision = runtime.cache_precision || selectedPrecision || "fp32";
selectedAudioPrecision = runtime.audio_precision || selectedAudioPrecision || "fp32";
setPrecisionButtons();
setAudioPrecisionButtons();
health.textContent = `${runtime.backend || "--"} · ${runtime.cache_precision || "n/a"} · audio ${runtime.audio_precision || "n/a"}`;
document.getElementById("backend").textContent = runtime.backend || "--";
document.getElementById("precision").textContent = `${runtime.cache_precision || "n/a"} / ${runtime.audio_precision || "n/a"}`;
document.getElementById("peakRss").textContent = formatBytes(runtime.service_peak_rss_bytes);
document.getElementById("requestPeak").textContent = formatBytes(runtime.last_request_peak_rss_bytes);
}
async function checkRuntime() {
try {
const response = await fetch(apiUrl("/api/runtime"));
const data = await response.json();
applyRuntime(data.runtime);
} catch (error) {
health.textContent = `runtime check failed: ${error}`;
}
}
async function switchPrecision(precision) {
if (precision === selectedPrecision || !availablePrecisions.includes(precision)) return;
setBusy(true, "Loading");
const form = new FormData();
form.append("backend", "onnx_cache");
form.append("cache_precision", precision);
form.append("audio_precision", selectedAudioPrecision);
try {
const response = await fetch(apiUrl("/api/reload"), { method: "POST", body: form });
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || response.statusText);
}
applyRuntime(data.runtime);
recordStatus.textContent = "Ready";
} catch (error) {
transcript.textContent = `Runtime switch error: ${error}`;
recordStatus.textContent = "Error";
} finally {
setBusy(false, recordStatus.textContent);
}
}
async function switchAudioPrecision(precision) {
if (precision === selectedAudioPrecision || !availableAudioPrecisions.includes(precision)) return;
setBusy(true, "Loading");
const form = new FormData();
form.append("backend", "onnx_cache");
form.append("cache_precision", selectedPrecision);
form.append("audio_precision", precision);
try {
const response = await fetch(apiUrl("/api/reload"), { method: "POST", body: form });
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || response.statusText);
}
applyRuntime(data.runtime);
recordStatus.textContent = "Ready";
} catch (error) {
transcript.textContent = `Runtime switch error: ${error}`;
recordStatus.textContent = "Error";
} finally {
setBusy(false, recordStatus.textContent);
}
}
async function uploadBlob(blob, name = "recording.wav") {
try {
setBusy(true, "Transcribing");
transcript.textContent = "Working...";
raw.textContent = "";
latency.textContent = "--";
const form = new FormData();
form.append("audio", blob, name);
form.append("max_new_tokens", maxTokens.value || "128");
form.append("cache_precision", selectedPrecision);
form.append("audio_precision", selectedAudioPrecision);
if (hotwords && hotwords.value.trim()) {
form.append("hotwords", hotwords.value.trim());
const strength = hotwordStrengths[selectedHotwordStrength] || hotwordStrengths.normal;
form.append("hotword_topk", String(strength.topk));
form.append("hotword_start_boost", String(strength.startBoost));
form.append("hotword_continuation_boost", String(strength.continuationBoost));
}
const response = await fetch(apiUrl("/asr"), { method: "POST", body: form });
const text = await response.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch (error) {
throw new Error(`Invalid ASR response: ${text.slice(0, 240)}`);
}
if (!response.ok) {
throw new Error(data.detail || response.statusText);
}
transcript.textContent = data.text || "(empty)";
latency.textContent = `${formatSeconds(data.elapsed_seconds)} · ${data.cache_precision || selectedPrecision}`;
raw.textContent = JSON.stringify(data, null, 2);
applyRuntime(data.runtime);
setBusy(false, "Ready");
} catch (error) {
transcript.textContent = `Transcription error: ${error}`;
raw.textContent = "";
setBusy(false, "Error");
throw error;
}
}
function mergeAudioBuffers(buffers) {
const totalLength = buffers.reduce((sum, buffer) => sum + buffer.length, 0);
const merged = new Float32Array(totalLength);
let offset = 0;
for (const buffer of buffers) {
merged.set(buffer, offset);
offset += buffer.length;
}
return merged;
}
function appendSilence(samples, sampleRate, durationMs) {
const silenceLength = Math.max(0, Math.round((sampleRate * durationMs) / 1000));
if (silenceLength === 0) return samples;
const padded = new Float32Array(samples.length + silenceLength);
padded.set(samples);
return padded;
}
function delay(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
function writeString(view, offset, value) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function encodeWav(samples, sampleRate) {
const bytesPerSample = 2;
const channelCount = 1;
const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
const view = new DataView(buffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + samples.length * bytesPerSample, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channelCount * bytesPerSample, true);
view.setUint16(32, channelCount * bytesPerSample, true);
view.setUint16(34, 8 * bytesPerSample, true);
writeString(view, 36, "data");
view.setUint32(40, samples.length * bytesPerSample, true);
let offset = 44;
for (const sample of samples) {
const clamped = Math.max(-1, Math.min(1, sample));
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
offset += bytesPerSample;
}
return new Blob([view], { type: "audio/wav" });
}
async function stopRecording() {
if (!audioContext || isStoppingRecording) return;
isStoppingRecording = true;
recordStatus.textContent = "Finishing";
stopBtn.disabled = true;
await delay(stopTailCaptureMs);
recordStatus.textContent = "Preparing";
if (processorNode) {
processorNode.disconnect();
processorNode.onaudioprocess = null;
}
if (sourceNode) sourceNode.disconnect();
if (monitorGain) monitorGain.disconnect();
if (stream) stream.getTracks().forEach((track) => track.stop());
const samples = appendSilence(mergeAudioBuffers(recordedBuffers), recordingSampleRate, wavTailSilenceMs);
const wav = encodeWav(samples, recordingSampleRate);
preview.src = URL.createObjectURL(wav);
await audioContext.close();
audioContext = null;
sourceNode = null;
processorNode = null;
monitorGain = null;
stream = null;
recordedBuffers = [];
isStoppingRecording = false;
await uploadBlob(wav, "recording.wav");
}
recordBtn.addEventListener("click", async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
});
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContextClass();
recordingSampleRate = audioContext.sampleRate;
recordedBuffers = [];
isStoppingRecording = false;
sourceNode = audioContext.createMediaStreamSource(stream);
processorNode = audioContext.createScriptProcessor(4096, 1, 1);
monitorGain = audioContext.createGain();
monitorGain.gain.value = 0;
processorNode.onaudioprocess = (event) => {
recordedBuffers.push(new Float32Array(event.inputBuffer.getChannelData(0)));
};
sourceNode.connect(processorNode);
processorNode.connect(monitorGain);
monitorGain.connect(audioContext.destination);
recordStatus.textContent = "Recording";
recordBtn.disabled = true;
stopBtn.disabled = false;
uploadBtn.disabled = true;
} catch (error) {
transcript.textContent = `Microphone error: ${error}`;
recordStatus.textContent = "Error";
}
});
stopBtn.addEventListener("click", () => {
stopRecording().catch((error) => {
transcript.textContent = String(error);
setBusy(false, "Error");
});
});
uploadBtn.addEventListener("click", async () => {
const file = fileInput.files && fileInput.files[0];
if (!file) {
transcript.textContent = "Choose an audio file first.";
return;
}
preview.src = URL.createObjectURL(file);
try {
await uploadBlob(file, file.name);
} catch (error) {
transcript.textContent = String(error);
setBusy(false, "Error");
}
});
precisionSegments.addEventListener("click", (event) => {
const button = event.target.closest("button[data-precision]");
if (button) {
switchPrecision(button.dataset.precision);
}
});
audioPrecisionSegments.addEventListener("click", (event) => {
const button = event.target.closest("button[data-audio-precision]");
if (button) {
switchAudioPrecision(button.dataset.audioPrecision);
}
});
hotwordStrengthSegments.addEventListener("click", (event) => {
const button = event.target.closest("button[data-hotword-strength]");
if (!button || !hotwordStrengths[button.dataset.hotwordStrength]) return;
selectedHotwordStrength = button.dataset.hotwordStrength;
setHotwordStrengthButtons();
});
function renderGpuList(gpus) {
const gpuList = document.getElementById("gpuList");
const gpuSummary = document.getElementById("gpuSummary");
gpuList.innerHTML = "";
if (!Array.isArray(gpus) || gpus.length === 0) {
gpuSummary.textContent = "unified / n/a";
gpuList.innerHTML = "<div class='empty'>No discrete GPU telemetry.</div>";
return;
}
const totalUsed = gpus.reduce((sum, gpu) => sum + Number(gpu.memory_used_mb || 0), 0);
const total = gpus.reduce((sum, gpu) => sum + Number(gpu.memory_total_mb || 0), 0);
gpuSummary.textContent = `${totalUsed.toFixed(0)} / ${total.toFixed(0)} MB`;
for (const gpu of gpus) {
const used = Number(gpu.memory_used_mb || 0);
const capacity = Number(gpu.memory_total_mb || 0);
const percent = capacity > 0 ? Math.min(100, Math.max(0, (used / capacity) * 100)) : 0;
const item = document.createElement("div");
item.className = "gpu-item";
item.innerHTML = `
<div class="gpu-line">
<strong>GPU ${gpu.index}</strong>
<span>${used.toFixed(0)} / ${capacity.toFixed(0)} MB · ${Number(gpu.utilization_percent || 0).toFixed(0)}%</span>
</div>
<div class="bar"><i style="width: ${percent.toFixed(1)}%"></i></div>
<small>${gpu.name || ""}</small>
`;
gpuList.appendChild(item);
}
}
async function refreshMetrics() {
try {
const response = await fetch(apiUrl("/metrics"));
const data = await response.json();
document.getElementById("metricStamp").textContent = new Date().toLocaleTimeString();
document.getElementById("rss").textContent = formatBytes(data.process.rss_bytes);
document.getElementById("sysmem").textContent = `${formatBytes(data.system.used_bytes)} / ${formatBytes(
data.system.total_bytes,
)}`;
renderGpuList(data.gpu && data.gpu.nvidia);
applyRuntime(data.runtime);
document.getElementById("metricsRaw").textContent = JSON.stringify(
{
providers: data.runtime && data.runtime.providers,
cache_graph: data.runtime && data.runtime.cache_graph,
audio_graph: data.runtime && data.runtime.audio_graph,
cpu_percent: data.process.cpu_percent,
},
null,
2,
);
} catch (error) {
document.getElementById("metricsRaw").textContent = String(error);
}
}
checkRuntime();
setHotwordStrengthButtons();
refreshMetrics();
setInterval(refreshMetrics, 2000);