feat: Aufnahme und lokaler Speicher für Sprachnotizen (T7 L5 Task 17)

Recorder (webm/opus oder mp4, 24 kbit/s, Warnung bei 150 s, Stopp bei
180 s oder im Hintergrund; Mikrofon und Wake-Lock werden in jedem Fall
freigegeben, auch bei Fehlern und ausgestecktem Mikrofon) und
IndexedDB v6 mit Store pending-voice (Index ownerUserId); volle
Speicherquote ergibt VoiceStorageFullError ohne Datenverlust; Abmelden
löscht nur die eigenen Notizen.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 10:47:45 +02:00
1 parent 1dc39f7658
commit ce472164b5
6 files changed
+829 -11

No files matched your search

@@ -0,0 +1,120 @@
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { beforeEach, describe, expect, it, vi } from "vitest";
type Module = typeof import("./pending-voice");
async function load(): Promise<Module> {
vi.resetModules();
return import("./pending-voice");
}
const input = (ownerUserId = "user-1") => ({
ownerUserId,
blob: new Blob(["audio"], { type: "audio/webm;codecs=opus" }),
mime: "audio/webm;codecs=opus",
durationSec: 42,
colonyId: "colony-1",
inspectionDate: "2026-10-16",
});
describe("pending voice notes", () => {
beforeEach(() => {
globalThis.indexedDB = new IDBFactory();
vi.stubGlobal("navigator", { storage: { persist: vi.fn().mockResolvedValue(true) } });
});
it("queues a note with its own id and upload key", async () => {
const voice = await load();
const note = await voice.queueVoiceNote(input());
expect(note).toMatchObject({ ownerUserId: "user-1", state: "recorded", durationSec: 42 });
expect(note.id).toBeTruthy();
expect(note.uploadIdempotencyKey).toBeTruthy();
expect(note.uploadIdempotencyKey).not.toBe(note.id);
expect(await voice.listPendingVoice("user-1")).toHaveLength(1);
});
it("asks the browser to keep the storage, once", async () => {
const voice = await load();
await voice.queueVoiceNote(input());
await voice.queueVoiceNote(input());
expect(navigator.storage.persist).toHaveBeenCalledTimes(1);
});
it("works when the browser has no persist", async () => {
vi.stubGlobal("navigator", {});
const voice = await load();
await expect(voice.queueVoiceNote(input())).resolves.toMatchObject({ state: "recorded" });
});
it("never lists another user's notes", async () => {
const voice = await load();
await voice.queueVoiceNote(input("user-1"));
await voice.queueVoiceNote(input("user-2"));
await voice.queueVoiceNote(input("user-2"));
expect((await voice.listPendingVoice("user-1")).map((n) => n.ownerUserId)).toEqual(["user-1"]);
expect(await voice.countForeignVoice("user-1")).toBe(2);
expect(await voice.countForeignVoice("user-3")).toBe(3);
});
it("lists oldest first", async () => {
const voice = await load();
const first = await voice.queueVoiceNote(input());
await new Promise((resolve) => setTimeout(resolve, 5));
const second = await voice.queueVoiceNote(input());
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([first.id, second.id]);
});
it("discards one note and leaves the rest", async () => {
const voice = await load();
const first = await voice.queueVoiceNote(input());
const second = await voice.queueVoiceNote(input());
await voice.discardPendingVoice(first.id, "user-1");
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([second.id]);
});
it("does not discard another user's note", async () => {
const voice = await load();
const foreign = await voice.queueVoiceNote(input("user-2"));
await voice.discardPendingVoice(foreign.id, "user-1");
expect(await voice.listPendingVoice("user-2")).toHaveLength(1);
});
it("discards all notes of one owner only", async () => {
const voice = await load();
await voice.queueVoiceNote(input("user-1"));
await voice.queueVoiceNote(input("user-1"));
await voice.queueVoiceNote(input("user-2"));
expect(await voice.discardAllPendingVoice("user-1")).toBe(2);
expect(await voice.listPendingVoice("user-1")).toEqual([]);
expect(await voice.countForeignVoice("user-1")).toBe(1);
});
it("rejects a new note when storage is full and keeps the stored ones", async () => {
const voice = await load();
const kept = await voice.queueVoiceNote(input());
const db = await import("@/lib/offline/db");
const connection = await db.getDb();
const spy = vi
.spyOn(connection, "add")
.mockRejectedValue(new DOMException("full", "QuotaExceededError"));
await expect(voice.queueVoiceNote(input())).rejects.toBeInstanceOf(voice.VoiceStorageFullError);
spy.mockRestore();
expect((await voice.listPendingVoice("user-1")).map((n) => n.id)).toEqual([kept.id]);
});
});
@@ -0,0 +1,100 @@
import { getDb, type StoredVoiceNote } from "@/lib/offline/db";
import { newIdempotencyKey } from "@/lib/offline/idempotency";
export type { StoredVoiceNote } from "@/lib/offline/db";
/*
* Voice notes recorded on this device. The audio stays here until the sync
* uploads it and is deleted on success, on discard and when the account signs
* out (db.clearQueueOf). It is never logged and never leaves the device
* before the user's upload.
*/
/** The device had no room left for another recording. */
export class VoiceStorageFullError extends Error {
constructor() {
super("Auf dem Gerät ist kein Platz mehr für die Aufnahme.");
this.name = "VoiceStorageFullError";
}
}
export type NewVoiceNote = Omit<
StoredVoiceNote,
"id" | "createdAt" | "uploadIdempotencyKey" | "state"
>;
let persistAsked = false;
/** Asks once per page for storage the browser will not clear on its own. Best effort. */
async function askForPersistentStorage(): Promise<void> {
if (persistAsked) {
return;
}
persistAsked = true;
try {
await navigator.storage?.persist?.();
} catch {
// Without persistent storage the note is still saved; the browser may clear it under pressure.
}
}
function isQuotaError(error: unknown): boolean {
return error instanceof DOMException && error.name === "QuotaExceededError";
}
export async function queueVoiceNote(input: NewVoiceNote): Promise<StoredVoiceNote> {
await askForPersistentStorage();
const note: StoredVoiceNote = {
...input,
id: newIdempotencyKey(),
createdAt: new Date().toISOString(),
uploadIdempotencyKey: newIdempotencyKey(),
state: "recorded",
};
const db = await getDb();
try {
await db.add("pending-voice", note);
} catch (error) {
if (isQuotaError(error)) {
throw new VoiceStorageFullError();
}
throw error;
}
return note;
}
/** The signed-in user's notes, oldest first. Another account's notes never appear. */
export async function listPendingVoice(currentUserId: string): Promise<StoredVoiceNote[]> {
const db = await getDb();
const notes = await db.getAllFromIndex("pending-voice", "ownerUserId", currentUserId);
return notes.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
/** How many notes of other accounts wait on this device - shown as a number only. */
export async function countForeignVoice(currentUserId: string): Promise<number> {
const db = await getDb();
const all = await db.getAllKeys("pending-voice");
const own = await db.countFromIndex("pending-voice", "ownerUserId", currentUserId);
return all.length - own;
}
/** Deletes one note, but only if it belongs to `ownerUserId`. */
export async function discardPendingVoice(id: string, ownerUserId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const note = await tx.store.get(id);
if (note?.ownerUserId === ownerUserId) {
await tx.store.delete(id);
}
await tx.done;
}
/** Deletes every note one account recorded on this device; returns how many. */
export async function discardAllPendingVoice(ownerUserId: string): Promise<number> {
const db = await getDb();
const tx = db.transaction("pending-voice", "readwrite");
const ids = await tx.store.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all(ids.map((id) => tx.store.delete(id)));
await tx.done;
return ids.length;
}
@@ -0,0 +1,301 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pickMimeType, startRecording } from "./recorder";
class FakeRecorder {
static instances: FakeRecorder[] = [];
static supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
static isTypeSupported(type: string): boolean {
return FakeRecorder.supported.has(type);
}
state: "inactive" | "recording" = "inactive";
mimeType: string;
ondataavailable: ((event: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
constructor(
public stream: unknown,
public options: { mimeType: string; audioBitsPerSecond: number },
) {
this.mimeType = options.mimeType;
FakeRecorder.instances.push(this);
}
static failStart = false;
static emptyData = false;
start(): void {
if (FakeRecorder.failStart) throw new Error("start failed");
this.state = "recording";
}
stop(): void {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob(FakeRecorder.emptyData ? [] : ["audio-bytes"], { type: this.mimeType }) });
this.onstop?.();
}
}
function makeStream() {
const handlers: Array<() => void> = [];
const track = {
stop: vi.fn(),
addEventListener: (_type: string, handler: () => void) => handlers.push(handler),
end: () => handlers.forEach((handler) => handler()),
};
return { track, stream: { getTracks: () => [track] } };
}
const options = () => ({
maxSec: 180,
warnAtSec: 150,
onTick: vi.fn(),
onWarn: vi.fn(),
onAutoStop: vi.fn(),
});
describe("pickMimeType", () => {
beforeEach(() => {
FakeRecorder.supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
});
it("prefers webm with opus", () => {
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBe("audio/webm;codecs=opus");
});
it("falls back to mp4 on Safari", () => {
FakeRecorder.supported = new Set(["audio/mp4"]);
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBe("audio/mp4");
});
it("returns null without MediaRecorder or a known type", () => {
expect(pickMimeType({} as never)).toBeNull();
FakeRecorder.supported = new Set();
expect(pickMimeType({ MediaRecorder: FakeRecorder } as never)).toBeNull();
});
});
describe("startRecording", () => {
let getUserMedia: ReturnType<typeof vi.fn>;
let track: ReturnType<typeof makeStream>["track"];
let wakeRelease: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
FakeRecorder.instances = [];
FakeRecorder.failStart = false;
FakeRecorder.emptyData = false;
FakeRecorder.supported = new Set(["audio/webm;codecs=opus", "audio/mp4"]);
wakeRelease = vi.fn().mockResolvedValue(undefined);
const made = makeStream();
track = made.track;
getUserMedia = vi.fn().mockResolvedValue(made.stream);
vi.stubGlobal("MediaRecorder", FakeRecorder);
vi.stubGlobal("navigator", {
mediaDevices: { getUserMedia },
wakeLock: { request: vi.fn().mockResolvedValue({ release: wakeRelease }) },
});
});
it("cleans up when the recorder cannot start", async () => {
FakeRecorder.failStart = true;
const removeSpy = vi.spyOn(document, "removeEventListener");
const opts = options();
await expect(startRecording(opts)).rejects.toThrow("start failed");
expect(track.stop).toHaveBeenCalledTimes(1);
expect(removeSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
await vi.advanceTimersByTimeAsync(3000);
expect(opts.onTick).not.toHaveBeenCalled();
});
it("releases a wake lock that arrives after the recording ended", async () => {
let grant: (lock: unknown) => void = () => undefined;
const request = vi.fn().mockReturnValue(new Promise((resolve) => (grant = resolve)));
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia }, wakeLock: { request } });
const handle = await startRecording(options());
handle.cancel();
grant({ release: wakeRelease });
await vi.advanceTimersByTimeAsync(0);
expect(wakeRelease).toHaveBeenCalledTimes(1);
});
it("releases the wake lock, the listener and the timer after stop", async () => {
const removeSpy = vi.spyOn(document, "removeEventListener");
const opts = options();
const handle = await startRecording(opts);
await vi.advanceTimersByTimeAsync(2000);
await handle.stop();
await vi.advanceTimersByTimeAsync(5000);
expect(wakeRelease).toHaveBeenCalledTimes(1);
expect(removeSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
expect(opts.onTick).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("does not raise an unhandled rejection when onAutoStop throws", async () => {
const opts = options();
opts.onAutoStop.mockImplementation(() => {
throw new Error("handler failed");
});
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
await startRecording(opts);
await vi.advanceTimersByTimeAsync(180_000);
await vi.advanceTimersByTimeAsync(10);
process.off("unhandledRejection", unhandled);
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
expect(unhandled).not.toHaveBeenCalled();
expect(track.stop).toHaveBeenCalledTimes(1);
});
it("rejects an empty recording", async () => {
FakeRecorder.emptyData = true;
const handle = await startRecording(options());
await expect(handle.stop()).rejects.toThrow("empty");
expect(track.stop).toHaveBeenCalledTimes(1);
});
it("rejects stop after cancel", async () => {
const handle = await startRecording(options());
handle.cancel();
await expect(handle.stop()).rejects.toThrow("cancelled");
});
it("treats an unplugged microphone like an error", async () => {
const handle = await startRecording(options());
track.end();
await expect(handle.stop()).rejects.toThrow("failed");
expect(track.stop).toHaveBeenCalledTimes(1);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("requests the microphone only on start and releases it on stop", async () => {
expect(getUserMedia).not.toHaveBeenCalled();
const handle = await startRecording(options());
expect(getUserMedia).toHaveBeenCalledWith({ audio: { channelCount: 1 } });
expect(FakeRecorder.instances[0].options).toEqual({
mimeType: "audio/webm;codecs=opus",
audioBitsPerSecond: 24000,
});
expect(track.stop).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3000);
const recording = await handle.stop();
expect(recording.mime).toBe("audio/webm;codecs=opus");
expect(recording.durationSec).toBe(3);
expect(recording.blob.size).toBeGreaterThan(0);
expect(track.stop).toHaveBeenCalledTimes(1);
});
it("releases the microphone on cancel and delivers nothing", async () => {
const opts = options();
const handle = await startRecording(opts);
handle.cancel();
await vi.advanceTimersByTimeAsync(5000);
expect(track.stop).toHaveBeenCalledTimes(1);
expect(opts.onAutoStop).not.toHaveBeenCalled();
expect(opts.onTick).not.toHaveBeenCalled();
});
it("reports every second", async () => {
const opts = options();
await startRecording(opts);
await vi.advanceTimersByTimeAsync(2000);
expect(opts.onTick.mock.calls.map(([sec]) => sec)).toEqual([1, 2]);
});
it("warns at 150 seconds", async () => {
const opts = options();
await startRecording(opts);
await vi.advanceTimersByTimeAsync(149_000);
expect(opts.onWarn).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(opts.onWarn).toHaveBeenCalledTimes(1);
});
it("stops at 180 seconds and keeps the recording", async () => {
const opts = options();
await startRecording(opts);
await vi.advanceTimersByTimeAsync(180_000);
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
const recording = opts.onAutoStop.mock.calls[0][0];
expect(recording.durationSec).toBe(180);
expect(recording.blob.size).toBeGreaterThan(0);
expect(track.stop).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(5000);
expect(opts.onTick).toHaveBeenCalledTimes(180);
});
it("saves what was recorded when the page goes to the background", async () => {
const opts = options();
await startRecording(opts);
await vi.advanceTimersByTimeAsync(7000);
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
await vi.advanceTimersByTimeAsync(0);
expect(opts.onAutoStop).toHaveBeenCalledTimes(1);
expect(opts.onAutoStop.mock.calls[0][0].durationSec).toBe(7);
expect(track.stop).toHaveBeenCalledTimes(1);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
});
it("releases the microphone when the recorder cannot be created", async () => {
class Broken extends FakeRecorder {
constructor() {
super(null, { mimeType: "", audioBitsPerSecond: 0 });
throw new Error("boom");
}
}
vi.stubGlobal("MediaRecorder", Broken);
await expect(startRecording(options())).rejects.toThrow("boom");
expect(track.stop).toHaveBeenCalledTimes(1);
});
it("rejects without a supported type before asking for the microphone", async () => {
FakeRecorder.supported = new Set();
await expect(startRecording(options())).rejects.toThrow();
expect(getUserMedia).not.toHaveBeenCalled();
});
it("passes a denied microphone through", async () => {
getUserMedia.mockRejectedValue(new DOMException("denied", "NotAllowedError"));
await expect(startRecording(options())).rejects.toMatchObject({ name: "NotAllowedError" });
});
it("releases the microphone and rejects stop when the recorder fails", async () => {
const handle = await startRecording(options());
const recorder = FakeRecorder.instances[0];
recorder.onerror?.({});
await expect(handle.stop()).rejects.toThrow();
expect(track.stop).toHaveBeenCalledTimes(1);
});
});
+207
View File
@@ -0,0 +1,207 @@
/*
* Records a voice note with MediaRecorder. The microphone is asked for only
* here, when the user taps record, and every track is stopped again on stop,
* cancel, auto-stop and error. The audio leaves this module only as the
* Recording the caller receives; nothing about it is logged.
*/
export interface Recording {
blob: Blob;
mime: string;
durationSec: number;
}
export interface RecordingHandle {
stop(): Promise<Recording>;
cancel(): void;
}
export interface StartRecordingOptions {
maxSec: number;
warnAtSec: number;
onTick(sec: number): void;
onWarn(): void;
onAutoStop(recording: Recording): void;
}
const MIME_CANDIDATES = ["audio/webm;codecs=opus", "audio/mp4"] as const;
const AUDIO_BITS_PER_SECOND = 24000;
const TICK_MS = 1000;
/** Anything that carries a MediaRecorder constructor: window, or a stand-in in tests. */
export interface RecorderHost {
MediaRecorder?: { isTypeSupported(type: string): boolean };
}
/** The first audio type this browser can record, or null (no voice notes then). */
export function pickMimeType(win?: RecorderHost): string | null {
const target = win ?? (typeof window === "undefined" ? undefined : window);
const Recorder = (target as RecorderHost | undefined)?.MediaRecorder;
if (typeof Recorder?.isTypeSupported !== "function") {
return null;
}
return MIME_CANDIDATES.find((type) => Recorder.isTypeSupported(type)) ?? null;
}
function releaseTracks(stream: MediaStream): void {
for (const track of stream.getTracks()) {
track.stop();
}
}
async function requestWakeLock(): Promise<WakeLockSentinel | null> {
try {
return (await navigator.wakeLock?.request("screen")) ?? null;
} catch {
// A wake lock is a convenience; recording works without it.
return null;
}
}
function releaseWakeLock(lock: WakeLockSentinel | null): void {
lock?.release().catch(() => undefined);
}
export async function startRecording(opts: StartRecordingOptions): Promise<RecordingHandle> {
const mime = pickMimeType(globalThis as RecorderHost);
if (!mime) {
throw new Error("Recording is not supported on this device");
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, {
mimeType: mime,
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
});
} catch (error) {
releaseTracks(stream);
throw error;
}
const chunks: Blob[] = [];
const startedAt = Date.now();
let ending = false;
let cancelled = false;
let wakeLock: WakeLockSentinel | null = null;
let resolveRecording: (recording: Recording) => void = () => undefined;
let rejectRecording: (error: Error) => void = () => undefined;
const result = new Promise<Recording>((resolve, reject) => {
resolveRecording = resolve;
rejectRecording = reject;
});
// A rejection nobody awaits (error after an auto-stop) must stay quiet.
result.catch(() => undefined);
let sec = 0;
const onVisibility = (): void => {
if (document.visibilityState === "hidden") {
autoStop();
}
};
const timer = setInterval(() => {
sec += 1;
opts.onTick(sec);
if (sec === opts.warnAtSec) {
opts.onWarn();
}
if (sec >= opts.maxSec) {
autoStop();
}
}, TICK_MS);
let cleanedUp = false;
const cleanUp = (): void => {
if (cleanedUp) {
return;
}
cleanedUp = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibility);
releaseTracks(stream);
releaseWakeLock(wakeLock);
};
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
recorder.onstop = () => {
cleanUp();
if (cancelled) {
rejectRecording(new Error("Recording cancelled"));
} else if (chunks.length === 0) {
rejectRecording(new Error("Recording is empty"));
} else {
resolveRecording({
blob: new Blob(chunks, { type: mime }),
mime,
durationSec: Math.min(opts.maxSec, Math.round((Date.now() - startedAt) / TICK_MS)),
});
}
};
const fail = (): void => {
cleanUp();
rejectRecording(new Error("Recording failed"));
};
recorder.onerror = fail;
// An unplugged microphone ends its tracks without a recorder error.
for (const track of stream.getTracks()) {
track.addEventListener("ended", fail);
}
const stop = (): Promise<Recording> => {
if (!ending) {
ending = true;
if (recorder.state !== "inactive") {
recorder.stop();
}
}
return result;
};
function autoStop(): void {
if (!ending) {
stop().then(
(recording) => {
try {
opts.onAutoStop(recording);
} catch {
// A failing handler must not become an unhandled rejection;
// the microphone is already released.
}
},
() => undefined,
);
}
}
document.addEventListener("visibilitychange", onVisibility);
try {
recorder.start();
} catch (error) {
cleanUp();
throw error;
}
void requestWakeLock().then((lock) => {
wakeLock = lock;
if (ending || cleanedUp) {
releaseWakeLock(lock);
}
});
return {
stop,
cancel(): void {
if (ending) {
return;
}
ending = true;
cancelled = true;
if (recorder.state !== "inactive") {
recorder.stop();
} else {
cleanUp();
}
},
};
}