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,150 @@
|
||||
// Contract tests for the storage layer, run against the web (Dexie) repo.
|
||||
//
|
||||
// fake-indexeddb/auto polyfills IndexedDB into the Node global so Dexie works
|
||||
// under vitest. We test the Dexie repo as the reference implementation of the
|
||||
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
|
||||
// imported here because expo-sqlite cannot load outside a device/simulator.
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { repo } from './repo.web';
|
||||
import { parseDraft, type TranscriptDraft } from './schema';
|
||||
|
||||
// Helper: a minimal valid draft, overridable per test.
|
||||
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
|
||||
return {
|
||||
title: 'My Recording',
|
||||
durationSec: 12.5,
|
||||
modelId: 'tiny.en',
|
||||
segments: [
|
||||
{ start: 0, end: 2, text: 'hello world' },
|
||||
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
|
||||
],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
// Wipe storage between tests so ordering/search assertions are deterministic.
|
||||
beforeEach(async () => {
|
||||
const all = await repo.list();
|
||||
await Promise.all(all.map((m) => repo.remove(m.id)));
|
||||
});
|
||||
|
||||
describe('StorageRepo (Dexie web impl)', () => {
|
||||
it('create -> get round-trips segments and metadata', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
|
||||
expect(created.id).toMatch(/^t_/);
|
||||
expect(created.createdAt).toBeTypeOf('number');
|
||||
expect(created.updatedAt).toBe(created.createdAt);
|
||||
expect(created.segmentCount).toBe(2);
|
||||
|
||||
const fetched = await repo.get(created.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.title).toBe('My Recording');
|
||||
expect(fetched!.durationSec).toBe(12.5);
|
||||
expect(fetched!.modelId).toBe('tiny.en');
|
||||
// Segments survive the round-trip exactly.
|
||||
expect(fetched!.segments).toEqual([
|
||||
{ start: 0, end: 2, text: 'hello world' },
|
||||
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('get returns undefined for an unknown id', async () => {
|
||||
expect(await repo.get('t_nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('list returns metadatas newest-first (without segments)', async () => {
|
||||
const a = await repo.create(makeDraft({ title: 'first' }));
|
||||
// Force distinct, increasing createdAt timestamps.
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const b = await repo.create(makeDraft({ title: 'second' }));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const c = await repo.create(makeDraft({ title: 'third' }));
|
||||
|
||||
const metas = await repo.list();
|
||||
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
|
||||
// Metas omit the segment body.
|
||||
expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
|
||||
expect(metas[0]!.segmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('update changes title + segments and re-derives searchText for search', async () => {
|
||||
const created = await repo.create(
|
||||
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
|
||||
);
|
||||
const originalUpdatedAt = created.updatedAt;
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
|
||||
const updated = await repo.update(created.id, {
|
||||
title: 'Brand New Title',
|
||||
segments: [
|
||||
{ start: 0, end: 1, text: 'banana' },
|
||||
{ start: 1, end: 2, text: 'cherry' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('Brand New Title');
|
||||
expect(updated.segmentCount).toBe(2);
|
||||
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
|
||||
|
||||
// Old text is no longer findable...
|
||||
expect(await repo.search('apple')).toHaveLength(0);
|
||||
// ...and the new text + new title are.
|
||||
const byText = await repo.search('cherry');
|
||||
expect(byText.map((m) => m.id)).toEqual([created.id]);
|
||||
const byTitle = await repo.search('brand new');
|
||||
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
|
||||
});
|
||||
|
||||
it('update throws for an unknown id', async () => {
|
||||
await expect(repo.update('t_missing', { title: 'x' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('search matches title and segment text case-insensitively', async () => {
|
||||
const t = await repo.create(
|
||||
makeDraft({
|
||||
title: 'Quarterly MEETING notes',
|
||||
segments: [{ start: 0, end: 3, text: 'Discuss the BUDGET forecast' }],
|
||||
}),
|
||||
);
|
||||
|
||||
// Title match, different case.
|
||||
expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Segment-text match, different case.
|
||||
expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Non-match.
|
||||
expect(await repo.search('zzz-not-present')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove deletes a transcript', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
expect(await repo.get(created.id)).toBeDefined();
|
||||
|
||||
await repo.remove(created.id);
|
||||
expect(await repo.get(created.id)).toBeUndefined();
|
||||
expect(await repo.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parseDraft rejects a NaN duration', () => {
|
||||
expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
|
||||
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
|
||||
});
|
||||
|
||||
it('parseDraft rejects a non-finite segment time', () => {
|
||||
expect(() =>
|
||||
parseDraft(makeDraft({ segments: [{ start: 0, end: Infinity, text: 'x' }] })),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseDraft(makeDraft({ segments: [{ start: NaN, end: 1, text: 'x' }] })),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('create rejects an invalid draft (defense in depth)', async () => {
|
||||
// create() calls parseDraft internally, so bad input never persists.
|
||||
await expect(
|
||||
repo.create(makeDraft({ durationSec: -1 })),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user