File size: 7,440 Bytes
4602745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd2c99f
674994f
4602745
 
 
 
 
 
 
 
 
 
 
 
 
 
cd2c99f
674994f
 
 
 
 
 
 
 
4602745
 
 
cd2c99f
 
 
 
 
 
 
 
 
 
4602745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674994f
 
4602745
cd2c99f
4602745
cd2c99f
4602745
 
 
 
d4f1e7c
4602745
 
 
 
 
d4f1e7c
4602745
 
 
 
 
d4f1e7c
4602745
 
 
674994f
 
 
d4f1e7c
 
 
4602745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd2c99f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4602745
 
 
cd2c99f
 
 
 
 
4602745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4f1e7c
674994f
4602745
 
d4f1e7c
4602745
 
d4f1e7c
4602745
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
/**
 * 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
}