jacquardSnapshot

← snapshot

6393 bytes
"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;
}