9f42ee2460
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>
215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
// 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);
|
|
}
|