"use client";
/**
* Recording the microphone as a WAV whisper.cpp can read directly.
*
* `MediaRecorder` would be less code, but it emits WebM/Opus, and whisper
* would then need ffmpeg on the host to decode it. Capturing raw samples and
* writing the 44-byte WAV header here removes that dependency entirely: the
* server only ever has to write bytes to a file and run one binary.
*
* This is *not* the live capture path — the browser's own `SpeechRecognition`
* still handles that, because it streams partial results as you speak and
* whisper cannot. This exists for the cases where accuracy beats immediacy.
*/
/** What whisper.cpp expects: 16 kHz, mono, 16-bit signed. */
export const TARGET_RATE = 16000;
export interface Recording {
/** Stops capture and returns the WAV bytes. */
stop: () => Promise<Blob>;
/** Stops capture and discards everything. */
cancel: () => void;
}
/** Naive but adequate rate conversion — speech, not mastering. */
function downsample(input: Float32Array, from: number, to: number): Float32Array {
if (from === to) return input;
const ratio = from / to;
const out = new Float32Array(Math.floor(input.length / ratio));
for (let i = 0; i < out.length; i++) {
// Average the source window rather than point-sampling it, so the
// decimation does not alias hiss into the transcript.
const start = Math.floor(i * ratio);
const end = Math.min(input.length, Math.floor((i + 1) * ratio));
let sum = 0;
for (let j = start; j < end; j++) sum += input[j] as number;
out[i] = end > start ? sum / (end - start) : 0;
}
return out;
}
/**
* Converts raw samples to the WAV whisper.cpp reads. Exported because the
* microphone cannot be driven in a headless browser, but this — the part that
* actually decides whether whisper can read our bytes — can.
*/
export function toWhisperWav(samples: Float32Array, sourceRate: number): Blob {
return encodeWav(downsample(samples, sourceRate, TARGET_RATE), TARGET_RATE);
}
function encodeWav(samples: Float32Array, rate: number): Blob {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
const ascii = (offset: number, text: string) => {
for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
};
ascii(0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
view.setUint32(16, 16, true); // PCM chunk size
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true); // byte rate
view.setUint16(32, 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
ascii(36, "data");
view.setUint32(40, samples.length * 2, true);
let offset = 44;
for (const s of samples) {
const clamped = Math.max(-1, Math.min(1, s));
view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: "audio/wav" });
}
/** Opens the microphone and starts collecting samples. */
export async function record(onLevel?: (level: number) => void): Promise<Recording> {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
const Ctx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
const ctx = new Ctx();
const source = ctx.createMediaStreamSource(stream);
// ScriptProcessor is deprecated but universally available and adequate for
// one mono speech stream; an AudioWorklet would need a separate module file
// for no audible gain here.
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks: Float32Array[] = [];
let live = true;
node.onaudioprocess = (e) => {
if (!live) return;
const input = e.inputBuffer.getChannelData(0);
chunks.push(new Float32Array(input));
if (onLevel) {
let sum = 0;
for (const v of input) sum += v * v;
onLevel(Math.min(1, Math.sqrt(sum / input.length) * 9));
}
};
source.connect(node);
// ScriptProcessor only ticks while connected to a destination; a zeroed
// gain node keeps it running without echoing the microphone to the speakers.
const mute = ctx.createGain();
mute.gain.value = 0;
node.connect(mute);
mute.connect(ctx.destination);
const teardown = () => {
live = false;
node.onaudioprocess = null;
node.disconnect();
source.disconnect();
mute.disconnect();
for (const t of stream.getTracks()) t.stop();
void ctx.close().catch(() => {});
onLevel?.(0);
};
return {
async stop() {
const rate = ctx.sampleRate;
teardown();
const total = chunks.reduce((n, c) => n + c.length, 0);
const joined = new Float32Array(total);
let at = 0;
for (const c of chunks) {
joined.set(c, at);
at += c.length;
}
return toWhisperWav(joined, rate);
},
cancel: teardown,
};
}
export interface LocalTranscription {
text: string;
engine: string;
elapsed_ms: number;
}
/** Cached: whether a model is on disk is not going to change mid-session. */
let available: Promise<boolean> | null = null;
/** One quiet probe, rather than a failing upload per utterance. */
export function whisperAvailable(): Promise<boolean> {
available ??= fetch("/api/transcribe")
.then((r) => (r.ok ? r.json() : { configured: false }))
.then((j: { configured?: boolean }) => Boolean(j.configured))
.catch(() => false);
return available;
}
/**
* Sends a recording to the local whisper.cpp behind jac-serve.
*
* Throws when transcription is not configured on the host; callers should fall
* back to the browser's own recogniser rather than failing the turn.
*/
export async function transcribeLocally(wav: Blob): Promise<LocalTranscription> {
const response = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wav,
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`transcribe ${response.status}: ${detail.slice(0, 200)}`);
}
return (await response.json()) as LocalTranscription;
}