diff --git a/ApiCore-Frontend/src/lib/offline/db.spec.ts b/ApiCore-Frontend/src/lib/offline/db.spec.ts index c105d86..57f251a 100644 --- a/ApiCore-Frontend/src/lib/offline/db.spec.ts +++ b/ApiCore-Frontend/src/lib/offline/db.spec.ts @@ -95,7 +95,7 @@ describe("offline queue database", () => { it.each([ ["a fresh install (0)", 0], ["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) { const old = await openDB(DB_NAME, 1, { upgrade(database) { @@ -120,7 +120,7 @@ describe("offline queue database", () => { ); const upgraded = await openDB(DB_NAME); - expect(upgraded.version).toBe(5); + expect(upgraded.version).toBe(6); expect([...upgraded.objectStoreNames]).toContain("pending-phenology-reports"); expect(await upgraded.count("pending-phenology-reports")).toBe(1); 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"); const upgraded = await openDB(DB_NAME); - expect(upgraded.version).toBe(5); + expect(upgraded.version).toBe(6); upgraded.close(); }); - it("upgrading v2 → v5 keeps already queued inspections", async () => { + it("upgrading v2 → v6 keeps already queued inspections", async () => { await createVersion2Queue({ input: INSPECTION, 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 }); const upgraded = await openDB(DB_NAME); - expect(upgraded.version).toBe(5); + expect(upgraded.version).toBe(6); expect([...upgraded.objectStoreNames]).toEqual( expect.arrayContaining([ ...VERSION_2_STORES, @@ -237,7 +237,7 @@ describe("offline queue database", () => { expect(hive).toMatchObject({ idempotencyKey: "key-h", ownerUserId: "user-1" }); 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-phenology-reports")).toBe(1); expect([...upgraded.objectStoreNames]).toEqual( @@ -246,6 +246,69 @@ describe("offline queue database", () => { 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 () => { const db = await loadDb(); await db.queueHiveChange( diff --git a/ApiCore-Frontend/src/lib/offline/db.ts b/ApiCore-Frontend/src/lib/offline/db.ts index 37b2205..547e52f 100644 --- a/ApiCore-Frontend/src/lib/offline/db.ts +++ b/ApiCore-Frontend/src/lib/offline/db.ts @@ -95,6 +95,21 @@ export interface StoredPhoto { 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 { apiaryId: string; ownerUserId: string; @@ -113,6 +128,7 @@ export interface OfflineDB extends DBSchema, RecordStores { value: StoredPhoto; indexes: { sessionId: string; inspectionIdempotencyKey: string; ownerUserId: string }; }; + "pending-voice": { key: string; value: StoredVoiceNote; indexes: { ownerUserId: string } }; "offline-packs": { key: string; value: StoredPack }; meta: { key: string; value: unknown }; } @@ -120,11 +136,12 @@ export interface OfflineDB extends DBSchema, RecordStores { const DB_NAME = "apicore-offline"; // 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 -// 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 // if it doesn't exist yet (guarded by oldVersion, per idb's upgrade() // convention), never recreated. -const DB_VERSION = 5; +const DB_VERSION = 6; const RECORD_STORES: readonly RecordStoreName[] = [ "pending-inspections", @@ -198,6 +215,10 @@ export function getDb(): Promise> { if (oldVersion < 5) { 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 // instead of blocking it. The next call here opens the new version. @@ -620,10 +641,10 @@ export async function discardQueueItem(item: QueueItem): Promise { 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 { 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 objectStore = tx.objectStore(store); const entries: QueuedValue[] = await objectStore.getAll(); @@ -635,6 +656,12 @@ export async function clearQueueOf(ownerUserId: string): Promise { }); const photos = tx.objectStore("pending-photos"); 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; } diff --git a/ApiCore-Frontend/src/lib/voice/pending-voice.spec.ts b/ApiCore-Frontend/src/lib/voice/pending-voice.spec.ts new file mode 100644 index 0000000..cbfb8e2 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/pending-voice.spec.ts @@ -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 { + 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]); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/pending-voice.ts b/ApiCore-Frontend/src/lib/voice/pending-voice.ts new file mode 100644 index 0000000..97f69ac --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/pending-voice.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/ApiCore-Frontend/src/lib/voice/recorder.spec.ts b/ApiCore-Frontend/src/lib/voice/recorder.spec.ts new file mode 100644 index 0000000..6770f07 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/recorder.spec.ts @@ -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; + let track: ReturnType["track"]; + let wakeRelease: ReturnType; + + 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); + }); +}); diff --git a/ApiCore-Frontend/src/lib/voice/recorder.ts b/ApiCore-Frontend/src/lib/voice/recorder.ts new file mode 100644 index 0000000..6a4bdb7 --- /dev/null +++ b/ApiCore-Frontend/src/lib/voice/recorder.ts @@ -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; + 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 { + 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 { + 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((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 => { + 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(); + } + }, + }; +}