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

+69 -6
View File
@@ -95,7 +95,7 @@ describe("offline queue database", () => {
it.each([ it.each([
["a fresh install (0)", 0], ["a fresh install (0)", 0],
["version 1", 1], ["version 1", 1],
])("opening from %s creates the plant report store at version 5", async (_name, from) => { ])("opening from %s creates the plant report store at version 6", async (_name, from) => {
if (from === 1) { if (from === 1) {
const old = await openDB(DB_NAME, 1, { const old = await openDB(DB_NAME, 1, {
upgrade(database) { upgrade(database) {
@@ -120,7 +120,7 @@ describe("offline queue database", () => {
); );
const upgraded = await openDB(DB_NAME); const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5); expect(upgraded.version).toBe(6);
expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports"); expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports");
expect(await upgraded.count("pending-phenology-reports")).toBe(1); expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0); expect(await upgraded.count("pending-inspections")).toBe(from === 1 ? 1 : 0);
@@ -166,11 +166,11 @@ describe("offline queue database", () => {
expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung"); expect((await db.listQueue("user-1")).map((item) => item.label)).toContain("Pflanzenmeldung");
const upgraded = await openDB(DB_NAME); const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5); expect(upgraded.version).toBe(6);
upgraded.close(); upgraded.close();
}); });
it("upgrading v2 → v5 keeps already queued inspections", async () => { it("upgrading v2 → v6 keeps already queued inspections", async () => {
await createVersion2Queue({ await createVersion2Queue({
input: INSPECTION, input: INSPECTION,
idempotencyKey: "key-from-v2", idempotencyKey: "key-from-v2",
@@ -192,7 +192,7 @@ describe("offline queue database", () => {
expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 }); expect(await db.summarizeQueue("user-1")).toEqual({ own: 1, failed: 0, foreign: 0 });
const upgraded = await openDB(DB_NAME); const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5); expect(upgraded.version).toBe(6);
expect([...upgraded.objectStoreNames]).toEqual( expect([...upgraded.objectStoreNames]).toEqual(
expect.arrayContaining([ expect.arrayContaining([
...VERSION_2_STORES, ...VERSION_2_STORES,
@@ -237,7 +237,7 @@ describe("offline queue database", () => {
expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" }); expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" });
const upgraded = await openDB(DB_NAME); const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(5); expect(upgraded.version).toBe(6);
expect(await upgraded.count("pending-inspections")).toBe(1); expect(await upgraded.count("pending-inspections")).toBe(1);
expect(await upgraded.count("pending-phenology-reports")).toBe(1); expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect([...upgraded.objectStoreNames]).toEqual( expect([...upgraded.objectStoreNames]).toEqual(
@@ -246,6 +246,69 @@ describe("offline queue database", () => {
upgraded.close(); upgraded.close();
}); });
it("upgrading to the new version keeps queued inspections, photos and phenology reports", async () => {
const old = await openDB(DB_NAME, 5, {
upgrade(database) {
for (const store of [
...VERSION_2_STORES,
"pending-bulk",
"pending-phenology-reports",
"pending-hive-changes",
]) {
database.createObjectStore(store, { keyPath: "id", autoIncrement: true });
}
const photos = database.createObjectStore("pending-photos", { keyPath: "id" });
photos.createIndex("sessionId", "sessionId");
photos.createIndex("inspectionIdempotencyKey", "inspectionIdempotencyKey");
photos.createIndex("ownerUserId", "ownerUserId");
database.createObjectStore("offline-packs", { keyPath: "apiaryId" });
database.createObjectStore("meta");
},
});
await old.add("pending-inspections", { input: INSPECTION, ownerUserId: "user-1", queuedAt: "2026-10-01T08:00:00.000Z" });
await old.add("pending-phenology-reports", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T09:00:00.000Z" });
await old.add("pending-hive-changes", { input: {}, ownerUserId: "user-1", queuedAt: "2026-10-01T10:00:00.000Z" });
await old.put("pending-photos", photo("p1", "key-1"));
old.close();
const db = await loadDb();
expect(await db.getPendingEntries("pending-inspections")).toHaveLength(1);
const upgraded = await openDB(DB_NAME);
expect(upgraded.version).toBe(6);
expect(await upgraded.count("pending-phenology-reports")).toBe(1);
expect(await upgraded.count("pending-hive-changes")).toBe(1);
expect(await upgraded.count("pending-photos")).toBe(1);
expect([...upgraded.objectStoreNames]).toContain("pending-voice");
expect([...upgraded.transaction("pending-voice").store.indexNames]).toEqual(["ownerUserId"]);
upgraded.close();
});
it("clearQueueOf removes one owner's voice notes and keeps everyone else's", async () => {
const db = await loadDb();
await db.getDb();
const raw = await openDB(DB_NAME);
const note = (id: string, ownerUserId: string) => ({
id,
ownerUserId,
blob: new Blob(["x"]),
mime: "audio/webm;codecs=opus",
durationSec: 5,
colonyId: "c-1",
inspectionDate: "2026-10-16",
createdAt: "2026-10-16T08:00:00.000Z",
uploadIdempotencyKey: `u-${id}`,
state: "recorded",
});
await raw.put("pending-voice", note("v1", "user-1") as never);
await raw.put("pending-voice", note("v2", "user-2") as never);
await db.clearQueueOf("user-1");
expect(await raw.getAllKeys("pending-voice")).toEqual(["v2"]);
raw.close();
});
it("lists a hive change under its own name and marks inspections that carry one", async () => { it("lists a hive change under its own name and marks inspections that carry one", async () => {
const db = await loadDb(); const db = await loadDb();
await db.queueHiveChange( await db.queueHiveChange(
+32 -5
View File
@@ -95,6 +95,21 @@ export interface StoredPhoto {
lastError?: QueueError; lastError?: QueueError;
} }
/** A voice note recorded on this device, waiting to be uploaded (Sprachnotiz). */
export interface StoredVoiceNote {
id: string;
ownerUserId: string;
blob: Blob;
mime: string;
durationSec: number;
colonyId: string;
inspectionDate: string;
createdAt: string;
uploadIdempotencyKey: string;
state: "recorded" | "uploading";
lastError?: QueueError;
}
export interface StoredPack { export interface StoredPack {
apiaryId: string; apiaryId: string;
ownerUserId: string; ownerUserId: string;
@@ -113,6 +128,7 @@ export interface OfflineDB extends DBSchema, RecordStores {
value: StoredPhoto; value: StoredPhoto;
indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string }; indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string };
}; };
"pending-voice": { key: string; value: StoredVoiceNote; indexes: { ownerUserId: string } };
"offline-packs": { key: string; value: StoredPack }; "offline-packs": { key: string; value: StoredPack };
meta: { key: string; value: unknown }; meta: { key: string; value: unknown };
} }
@@ -120,11 +136,12 @@ export interface OfflineDB extends DBSchema, RecordStores {
const DB_NAME = "apicore-offline"; const DB_NAME = "apicore-offline";
// v1 only had "pending-inspections", v2 added the other five quick-capture // v1 only had "pending-inspections", v2 added the other five quick-capture
// types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant // types, v3 adds bulk entries, photos, offline packs and meta, v4 adds plant
// reports (Pflanzenentwicklung), v5 adds hive changes (Beute). Entries already // reports (Pflanzenentwicklung), v5 adds hive changes (Beute), v6 adds voice
// notes (Sprache). Entries already
// queued on a phone must survive every upgrade, so each store is only created // queued on a phone must survive every upgrade, so each store is only created
// if it doesn't exist yet (guarded by oldVersion, per idb's upgrade() // if it doesn't exist yet (guarded by oldVersion, per idb's upgrade()
// convention), never recreated. // convention), never recreated.
const DB_VERSION = 5; const DB_VERSION = 6;
const RECORD_STORES: readonly RecordStoreName[] = [ const RECORD_STORES: readonly RecordStoreName[] = [
"pending-inspections", "pending-inspections",
@@ -198,6 +215,10 @@ export function getDb(): Promise<IDBPDatabase<OfflineDB>> {
if (oldVersion < 5) { if (oldVersion < 5) {
db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true }); db.createObjectStore("pending-hive-changes", { keyPath: "id", autoIncrement: true });
} }
if (oldVersion < 6) {
const voice = db.createObjectStore("pending-voice", { keyPath: "id" });
voice.createIndex("ownerUserId", "ownerUserId");
}
}, },
// A newer app version in another tab wants to upgrade: step aside // A newer app version in another tab wants to upgrade: step aside
// instead of blocking it. The next call here opens the new version. // instead of blocking it. The next call here opens the new version.
@@ -620,10 +641,10 @@ export async function discardQueueItem(item: QueueItem): Promise<void> {
await tx.done; await tx.done;
} }
/** Deletes everything one account queued on this device, photos included. */ /** Deletes everything one account queued on this device, photos and voice notes included. */
export async function clearQueueOf(ownerUserId: string): Promise<void> { export async function clearQueueOf(ownerUserId: string): Promise<void> {
const db = await getDb(); const db = await getDb();
const tx = db.transaction([...QUEUE_STORES, "pending-photos"], "readwrite"); const tx = db.transaction([...QUEUE_STORES, "pending-photos", "pending-voice"], "readwrite");
const queues = QUEUE_STORES.map(async (store) => { const queues = QUEUE_STORES.map(async (store) => {
const objectStore = tx.objectStore(store); const objectStore = tx.objectStore(store);
const entries: QueuedValue[] = await objectStore.getAll(); const entries: QueuedValue[] = await objectStore.getAll();
@@ -635,6 +656,12 @@ export async function clearQueueOf(ownerUserId: string): Promise<void> {
}); });
const photos = tx.objectStore("pending-photos"); const photos = tx.objectStore("pending-photos");
const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId); const photoIds = await photos.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all([...queues, ...photoIds.map((photoId) => photos.delete(photoId))]); const voice = tx.objectStore("pending-voice");
const voiceIds = await voice.index("ownerUserId").getAllKeys(ownerUserId);
await Promise.all([
...queues,
...photoIds.map((photoId) => photos.delete(photoId)),
...voiceIds.map((voiceId) => voice.delete(voiceId)),
]);
await tx.done; await tx.done;
} }
@@ -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();
}
},
};
}