Build Wisp: on-device transcription studio (web + native, one codebase)
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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user