| 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); |
|
|