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:
1 parent
1dc39f7658
commit
ce472164b5
6 files changed
+829
-11
No files matched your search
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user