Download frontend/src/services/inferenceRouter.js from SiliconLanguage/open-asr-model-explorer: direct link, hf CLI and curl.
- Browser
- Download file 7.44 kB
-
https://huggingface.co/spaces/SiliconLanguage/open-asr-model-explorer/resolve/main/frontend/src/services/inferenceRouter.js
- Command line
-
hf download hf://spaces/SiliconLanguage/open-asr-model-explorer/frontend/src/services/inferenceRouter.js
-
curl -L -o inferenceRouter.js https://huggingface.co/spaces/SiliconLanguage/open-asr-model-explorer/resolve/main/frontend/src/services/inferenceRouter.js
7.44 kB
| /** | |
| * inferenceRouter.js | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * Strategy-based routing: | |
| * - Model name contains "WebGPU" β client-side via WebWorker (transformers.js) | |
| * - All other models β server-side via FastAPI / vLLM | |
| * | |
| * Exports: | |
| * route(model, audioBuffer, onProgress) β Promise<TranscribeResult> | |
| * | |
| * TranscribeResult = { transcript, ttft_ms, itl_ms, rtfx, mode } | |
| */ | |
| // ββ WebGPU / WebWorker client βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let workerInstance = null; | |
| let workerReadyResolve = null; | |
| let workerReadyReject = null; // companion reject for the load promise | |
| let workerLoadedModelId = null; // mirrors worker's loadedModelId for fast cache hit | |
| const pendingCallbacks = new Map(); // id β { resolve, reject, onProgress } | |
| function getWorker() { | |
| if (workerInstance) return workerInstance; | |
| // Vite's ?worker syntax creates an ES-module Worker automatically | |
| workerInstance = new Worker( | |
| new URL('../workers/webgpu.worker.js', import.meta.url), | |
| { type: 'module' } | |
| ); | |
| workerInstance.addEventListener('message', (event) => { | |
| const { type, id, stable, unstable, transcript, ttft_ms, itl_ms, rtfx, message } = event.data; | |
| // ββ Load lifecycle messages βββββββββββββββββββββββββββββββββββββββββββββ | |
| if (type === 'ready') { | |
| workerLoadedModelId = event.data.modelId ?? workerLoadedModelId; | |
| if (workerReadyResolve) { | |
| const res = workerReadyResolve; | |
| workerReadyResolve = null; | |
| workerReadyReject = null; | |
| res(); | |
| } | |
| return; | |
| } | |
| // A null id means the error came from loadModel(), not a transcription. | |
| if (type === 'error' && id == null) { | |
| const rej = workerReadyReject; | |
| workerReadyResolve = null; | |
| workerReadyReject = null; | |
| rej?.(new Error(message ?? 'Model failed to load')); | |
| return; | |
| } | |
| // ββ Transcription lifecycle messages ββββββββββββββββββββββββββββββββββββ | |
| const cb = pendingCallbacks.get(id); | |
| if (!cb) return; | |
| switch (type) { | |
| case 'progress': | |
| cb.onProgress?.({ stable, unstable }); | |
| break; | |
| case 'result': | |
| pendingCallbacks.delete(id); | |
| cb.resolve({ transcript, ttft_ms, itl_ms, rtfx, mode: 'webgpu' }); | |
| break; | |
| case 'error': | |
| pendingCallbacks.delete(id); | |
| cb.reject(new Error(message)); | |
| break; | |
| } | |
| }); | |
| return workerInstance; | |
| } | |
| async function ensureModelLoaded(modelId) { | |
| // Fast path: model already cached in worker β skip the message round-trip | |
| if (workerLoadedModelId === modelId) return; | |
| const worker = getWorker(); | |
| return new Promise((resolve, reject) => { | |
| workerReadyResolve = resolve; | |
| workerReadyReject = reject; | |
| worker.postMessage({ type: 'load', model: modelId }); | |
| }); | |
| } | |
| async function routeWebGPU(modelId, audioBuffer, onProgress, language) { | |
| await ensureModelLoaded(modelId); | |
| const worker = getWorker(); | |
| const id = crypto.randomUUID(); | |
| return new Promise((resolve, reject) => { | |
| pendingCallbacks.set(id, { resolve, reject, onProgress }); | |
| worker.postMessage({ type: 'transcribe', audio: audioBuffer, id, language }, [audioBuffer.buffer]); | |
| }); | |
| } | |
| // ββ Server-side / vLLM client βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function routeServer(modelId, audioFile, onProgress, engine, language) { | |
| const form = new FormData(); | |
| form.append('audio', audioFile); | |
| form.append('model', modelId); | |
| if (engine) { | |
| form.append('engine', engine); | |
| } | |
| if (language) { | |
| form.append('language', language); | |
| } | |
| // Use streaming endpoint for token-by-token delivery | |
| const response = await fetch('/api/transcribe/stream', { | |
| method: 'POST', | |
| body: form, | |
| }); | |
| if (!response.ok) { | |
| const err = await response.json().catch(() => ({ detail: response.statusText })); | |
| throw new Error(err.detail ?? `HTTP ${response.status}`); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let finalResult = null; | |
| try { | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| // Parse Server-Sent Events | |
| const events = buffer.split('\n\n'); | |
| buffer = events.pop() ?? ''; | |
| for (const event of events) { | |
| const dataLine = event.split('\n').find((l) => l.startsWith('data: ')); | |
| if (!dataLine) continue; | |
| try { | |
| const payload = JSON.parse(dataLine.slice(6)); | |
| if (payload.done) { | |
| finalResult = payload; | |
| } else if (payload.token) { | |
| onProgress?.({ token: payload.token, ttft_ms: payload.ttft_ms }); | |
| } else if (payload.ttft_ms != null) { | |
| onProgress?.({ ttft_ms: payload.ttft_ms }); | |
| } | |
| } catch { | |
| // ignore malformed SSE frames | |
| } | |
| } | |
| } | |
| } finally { | |
| // Always release the reader so the underlying HTTP connection can be | |
| // returned to the pool. This prevents stream leaks when an exception | |
| // is thrown mid-stream (e.g. network error or component unmount). | |
| reader.cancel(); | |
| } | |
| if (!finalResult) throw new Error('Stream ended without a final result.'); | |
| return { | |
| transcript: finalResult.transcript, | |
| ttft_ms: finalResult.ttft_ms, | |
| itl_ms: finalResult.itl_ms, | |
| rtfx: finalResult.rtfx, | |
| mode: 'server', | |
| }; | |
| } | |
| // ββ Public router βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Route transcription to the appropriate execution mode. | |
| * | |
| * @param {string} modelId Selected model name | |
| * @param {File|Float32Array} audio Audio source | |
| * @param {function} [onProgress] Called with partial results during streaming | |
| * @returns {Promise<{transcript:string, ttft_ms:number, itl_ms:number, rtfx:number, mode:string}>} | |
| */ | |
| export async function route(modelId, audio, onProgress, engine = undefined, mode = 'server', language = undefined) { | |
| if (mode === 'webgpu') { | |
| // Ensure we have a Float32Array of 16 kHz samples | |
| const audioBuffer = audio instanceof Float32Array ? audio : await fileToFloat32(audio); | |
| return routeWebGPU(modelId, audioBuffer, onProgress, language); | |
| } | |
| // Server-side: pass the raw File for multipart upload | |
| return routeServer(modelId, audio, onProgress, engine, language); | |
| } | |
| // ββ Helper: decode File β Float32Array at 16 kHz ββββββββββββββββββββββββββββββ | |
| async function fileToFloat32(file) { | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const audioCtx = new AudioContext({ sampleRate: 16000 }); | |
| const decoded = await audioCtx.decodeAudioData(arrayBuffer); | |
| return decoded.getChannelData(0); // mono, 16 kHz | |
| } | |