Build Wisp: on-device transcription studio (web + native, one codebase)
CI / test (push) Has been cancelled
CI / deploy-web (push) Has been cancelled
CI / build-apk (push) Has been cancelled

Private, offline speech-to-text that runs Whisper on the user's own device —
free, no account, no per-minute fees. Replaces Otter.ai / Rev.

- Pure, tested engine: chunking, overlap timestamp-stitching, exports
  (SRT/VTT/TXT/MD/JSON), WAV codec, resampler, job queue, model catalog (142 tests).
- Platform-abstracted TranscriptionEngine: transformers.js on web (loaded from
  CDN at runtime to dodge Metro's onnxruntime-web bundling limits), whisper.rn
  on native. Shared pipeline orchestrates decode -> chunk -> transcribe -> stitch.
- Cross-platform StorageRepo (Dexie web / expo-sqlite native), Zod-validated.
- UI: library + search, import, live-progress transcription, synced click-to-seek
  editor, multi-format export; model picker + privacy in settings.
- Web ships as a single-page PWA with COOP/COEP isolation for threaded WASM;
  Docker (nginx) image + Traefik compose for wisp.briggen.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 17:54:21 +02:00
parent 97996c9846
commit 9f42ee2460
72 changed files with 7293 additions and 421 deletions
+214
View File
@@ -0,0 +1,214 @@
// Minimal, dependency-free WAV (RIFF/WAVE) codec for PCM audio.
//
// Supports decoding:
// - 16-bit integer PCM (fmt audioFormat === 1)
// - 32-bit IEEE float PCM (fmt audioFormat === 3)
// Chunks may appear in any order; unknown chunks are skipped. Channels are
// de-interleaved into one Float32Array per channel, with samples in [-1, 1].
//
// Encoding produces a mono, 16-bit PCM WAVE file.
//
// All multi-byte header fields are little-endian (per the WAVE spec). We use
// DataView so endianness is explicit and never relies on the host platform.
/** A decoded WAVE file: sample rate plus one Float32Array per channel. */
export interface DecodedWav {
sampleRate: number;
/** One entry per channel; each holds that channel's samples in [-1, 1]. */
channelData: Float32Array[];
}
const WAVE_FORMAT_PCM = 1;
const WAVE_FORMAT_IEEE_FLOAT = 3;
/** Read a 4-byte ASCII tag (e.g. "RIFF") at the given byte offset. */
function readTag(view: DataView, offset: number): string {
return (
String.fromCharCode(view.getUint8(offset)) +
String.fromCharCode(view.getUint8(offset + 1)) +
String.fromCharCode(view.getUint8(offset + 2)) +
String.fromCharCode(view.getUint8(offset + 3))
);
}
/**
* Parse a RIFF/WAVE byte buffer.
*
* @throws if the buffer is too small, is not a RIFF container, is not a WAVE
* form, lacks fmt/data chunks, or uses an unsupported sample format.
*/
export function decodeWav(bytes: Uint8Array): DecodedWav {
// A valid WAVE needs at least the 12-byte RIFF/WAVE descriptor.
if (bytes.byteLength < 12) {
throw new Error('Invalid WAV: file too small to contain a RIFF header');
}
const view = new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
);
if (readTag(view, 0) !== 'RIFF') {
throw new Error("Invalid WAV: missing 'RIFF' magic");
}
if (readTag(view, 8) !== 'WAVE') {
throw new Error("Invalid WAV: not a 'WAVE' file");
}
let audioFormat = -1;
let numChannels = 0;
let sampleRate = 0;
let bitsPerSample = 0;
let dataOffset = -1;
let dataLength = 0;
let haveFmt = false;
let haveData = false;
// Walk the chunk list starting right after the 12-byte descriptor.
let offset = 12;
while (offset + 8 <= bytes.byteLength) {
const chunkId = readTag(view, offset);
const chunkSize = view.getUint32(offset + 4, true);
const chunkBody = offset + 8;
if (chunkId === 'fmt ') {
if (chunkBody + 16 > bytes.byteLength) {
throw new Error('Invalid WAV: truncated fmt chunk');
}
audioFormat = view.getUint16(chunkBody, true);
numChannels = view.getUint16(chunkBody + 2, true);
sampleRate = view.getUint32(chunkBody + 4, true);
// bytes 8..12: byteRate, bytes 12..14: blockAlign (derived; ignored)
bitsPerSample = view.getUint16(chunkBody + 14, true);
haveFmt = true;
} else if (chunkId === 'data') {
dataOffset = chunkBody;
// Clamp to the actual buffer in case the declared size overruns it.
dataLength = Math.min(chunkSize, bytes.byteLength - chunkBody);
haveData = true;
}
// Unknown chunks fall through and are skipped.
// Chunks are word-aligned: an odd size is followed by a pad byte.
let advance = chunkSize;
if (advance % 2 === 1) advance += 1;
offset = chunkBody + advance;
}
if (!haveFmt) {
throw new Error("Invalid WAV: missing 'fmt ' chunk");
}
if (!haveData) {
throw new Error("Invalid WAV: missing 'data' chunk");
}
if (numChannels < 1) {
throw new Error(`Invalid WAV: bad channel count ${numChannels}`);
}
const channelData: Float32Array[] = [];
if (audioFormat === WAVE_FORMAT_PCM && bitsPerSample === 16) {
const bytesPerSample = 2;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
const int16 = view.getInt16(base + c * bytesPerSample, true);
// Asymmetric int16 range: divide negatives by 32768, positives by 32767.
const f = int16 < 0 ? int16 / 0x8000 : int16 / 0x7fff;
channelData[c]![frame] = f;
}
}
} else if (
audioFormat === WAVE_FORMAT_IEEE_FLOAT &&
bitsPerSample === 32
) {
const bytesPerSample = 4;
const frameSize = bytesPerSample * numChannels;
const numFrames = Math.floor(dataLength / frameSize);
for (let c = 0; c < numChannels; c++) {
channelData.push(new Float32Array(numFrames));
}
for (let frame = 0; frame < numFrames; frame++) {
const base = dataOffset + frame * frameSize;
for (let c = 0; c < numChannels; c++) {
channelData[c]![frame] = view.getFloat32(
base + c * bytesPerSample,
true,
);
}
}
} else {
throw new Error(
`Unsupported WAV format: audioFormat=${audioFormat}, ` +
`bitsPerSample=${bitsPerSample} (supported: 16-bit PCM, 32-bit float)`,
);
}
return { sampleRate, channelData };
}
/**
* Encode mono samples (in [-1, 1]) into a 16-bit PCM WAVE file.
* Out-of-range samples are clamped before quantization.
*/
export function encodeWav(
samples: Float32Array,
sampleRate: number,
): Uint8Array {
const numChannels = 1;
const bitsPerSample = 16;
const bytesPerSample = bitsPerSample / 8;
const blockAlign = numChannels * bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const headerSize = 44;
const totalSize = headerSize + dataSize;
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const writeTag = (offset: number, tag: string): void => {
for (let i = 0; i < tag.length; i++) {
view.setUint8(offset + i, tag.charCodeAt(i));
}
};
// RIFF descriptor
writeTag(0, 'RIFF');
view.setUint32(4, totalSize - 8, true); // chunkSize = file size minus first 8
writeTag(8, 'WAVE');
// fmt chunk (16 bytes of PCM format data)
writeTag(12, 'fmt ');
view.setUint32(16, 16, true); // fmt chunk body size
view.setUint16(20, WAVE_FORMAT_PCM, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
// data chunk
writeTag(36, 'data');
view.setUint32(40, dataSize, true);
let offset = headerSize;
for (let i = 0; i < samples.length; i++) {
let s = samples[i]!;
// Clamp to [-1, 1] to avoid integer overflow/wraparound on quantize.
if (s > 1) s = 1;
else if (s < -1) s = -1;
// Match the asymmetric int16 range used when decoding.
const int16 = s < 0 ? Math.round(s * 0x8000) : Math.round(s * 0x7fff);
view.setInt16(offset, int16, true);
offset += bytesPerSample;
}
return new Uint8Array(buffer);
}