{"_id":"@axumquant/mv3-audio-replay-buffer","name":"@axumquant/mv3-audio-replay-buffer","dist-tags":{"latest":"0.1.0"},"versions":{"0.1.0":{"name":"@axumquant/mv3-audio-replay-buffer","version":"0.1.0","description":"Encrypted, durable audio frame buffer for MV3 service workers. Survives extension restarts, AES-GCM encrypted at rest, ack-trimmed replay-on-reconnect.","type":"module","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"scripts":{"build":"tsc -p tsconfig.json","test":"vitest run","test:watch":"vitest","typecheck":"tsc --noEmit","prepublishOnly":"npm run typecheck && npm run test && npm run build"},"keywords":["chrome-extension","mv3","audio","replay-buffer","indexeddb","encrypted","service-worker","offscreen"],"author":{"name":"axumquant"},"license":"MIT","repository":{"type":"git","url":"git+https://github.com/axumquant/mv3-audio-replay-buffer.git"},"bugs":{"url":"https://github.com/axumquant/mv3-audio-replay-buffer/issues"},"homepage":"https://github.com/axumquant/mv3-audio-replay-buffer#readme","engines":{"node":">=18"},"devDependencies":{"@types/dom-webcodecs":"^0.1.11","@types/node":"^20.11.0","fake-indexeddb":"^5.0.2","typescript":"^5.3.3","vitest":"^1.2.0"},"gitHead":"69e11f51297949bf5d136cf9776ce61bad35ec18","_id":"@axumquant/mv3-audio-replay-buffer@0.1.0","_nodeVersion":"24.13.0","_npmVersion":"11.6.2","dist":{"integrity":"sha512-yPNkKwMPvER27B4yhjBuyL93iziURu/zuyE8essA7VM98NcJH/0149h5NTWtu4Wh0tskBUzm4mCRLKewjxaUXw==","shasum":"9278147c4b8c0c180adf7b8ed675f0059bf68107","tarball":"https://registry.npmjs.org/@axumquant/mv3-audio-replay-buffer/-/mv3-audio-replay-buffer-0.1.0.tgz","fileCount":29,"unpackedSize":102182,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIHumvOTrQ4cx3zr6wF2gdv/PACWXiOGCkWm5iuScV0roAiEAuHRSVRFVM6k1bmye2nNOdINZTHfAQiqvYh6eLBWjego="}]},"_npmUser":{"name":"junx-10010","email":"n.beck10010@gmail.com"},"directories":{},"maintainers":[{"name":"junx-10010","email":"n.beck10010@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/mv3-audio-replay-buffer_0.1.0_1778956892520_0.6547434545237405"},"_hasShrinkwrap":false}},"time":{"created":"2026-05-16T18:41:32.409Z","0.1.0":"2026-05-16T18:41:32.735Z","modified":"2026-05-16T18:41:33.066Z"},"maintainers":[{"name":"junx-10010","email":"n.beck10010@gmail.com"}],"description":"Encrypted, durable audio frame buffer for MV3 service workers. Survives extension restarts, AES-GCM encrypted at rest, ack-trimmed replay-on-reconnect.","homepage":"https://github.com/axumquant/mv3-audio-replay-buffer#readme","keywords":["chrome-extension","mv3","audio","replay-buffer","indexeddb","encrypted","service-worker","offscreen"],"repository":{"type":"git","url":"git+https://github.com/axumquant/mv3-audio-replay-buffer.git"},"author":{"name":"axumquant"},"bugs":{"url":"https://github.com/axumquant/mv3-audio-replay-buffer/issues"},"license":"MIT","readme":"# `@axumquant/mv3-audio-replay-buffer`\n\n**Encrypted, durable audio frame buffer for MV3 service workers.**\n\nSurvive Chrome service worker restarts mid-call. Keep the last N seconds of audio AES-GCM encrypted at rest. Replay un-acked frames the moment your backend WebSocket reconnects. No `chrome.runtime` coupling — bring your own transport.\n\n[![CI](https://github.com/axumquant/mv3-audio-replay-buffer/actions/workflows/ci.yml/badge.svg)](https://github.com/axumquant/mv3-audio-replay-buffer/actions/workflows/ci.yml)\n\n---\n\n## The problem\n\nYou're streaming live audio from a Chrome MV3 extension to a backend STT service (Deepgram, AssemblyAI, your own Whisper). Three things conspire against you:\n\n1. **MV3 service workers die unpredictably** — after ~30 seconds idle, on extension reload, on memory pressure. Anything buffered in SW memory is gone.\n2. **`chrome.storage.local` is not encrypted.** It is readable by other extensions sharing the profile in some setups, and is plainly visible on disk. Live audio chunks are sensitive — especially in a sales/medical/legal context.\n3. **WebSockets flap.** Wi-Fi blips, backend deploys, idle timeouts — every reconnect is a chance to silently drop seconds of audio. STT services that don't get a clean stream produce garbled transcripts.\n\nThe only durable, encrypt-capable storage available to MV3 offscreen documents is **IndexedDB + Web Crypto**. But writing a correct buffer (TTL eviction, sequence numbers, ack-trim, AES-GCM with proper IVs, key rotation) is the kind of code that's easy to get wrong and impossible to make small. So we extracted ours from sales-coach into a library.\n\n## What this solves\n\n- **Encrypted at rest** — every frame's audio payload is AES-GCM-256 encrypted with a non-extractable `CryptoKey` persisted in IndexedDB. The plaintext never touches disk.\n- **Survives SW restart** — frames sit in IndexedDB. After the SW comes back, `init()` rehydrates the seq counter and you keep streaming.\n- **Ack-trimmed** — backend tells you \"I've got everything through seq N\", you call `acknowledge(N)`, and those frames get dropped on the next cleanup pass. Memory + storage stay bounded.\n- **Replay on demand** — on reconnect, hand `replay()` a callback and it forwards every un-acked frame, in seq order, tagged `replay: true` so the backend knows to backfill, not duplicate.\n- **Transport-agnostic** — the buffer never calls `chrome.runtime.sendMessage`, `WebSocket.send`, or anything else. *You* tell it where the frames go via the callback.\n\n---\n\n## Install\n\n```bash\nnpm install @axumquant/mv3-audio-replay-buffer\n```\n\nRequires **Node ≥ 18** for `npm install` / build / test. The library itself runs in browser contexts that expose `indexedDB` and `crypto.subtle` — i.e. modern Chromium-based browsers and MV3 offscreen documents.\n\nPeer dependencies: **none**. The whole library is one TypeScript class plus two helpers; it does not pull in any runtime deps.\n\n---\n\n## Quickstart\n\n```typescript\nimport { AudioReplayBuffer } from \"@axumquant/mv3-audio-replay-buffer\";\n\nconst buffer = new AudioReplayBuffer({ dbName: \"my-app-audio\" });\nawait buffer.init();\n\n// Producer: MediaRecorder gives you a chunk → buffer it AND forward live.\nrecorder.ondataavailable = async (event) => {\n  const data = await blobToBase64(event.data);\n  const remembered = await buffer.rememberFrame({ data, ts: Date.now() });\n  ws.send(JSON.stringify({ type: \"audio_frame\", ...remembered }));\n};\n\n// Consumer ack:\nws.addEventListener(\"message\", (e) => {\n  const msg = JSON.parse(e.data);\n  if (msg.type === \"ack\") void buffer.acknowledge(msg.seq);\n});\n\n// Reconnect: flush un-acked frames to the new socket before resuming live.\nasync function onReconnect(newWs: WebSocket) {\n  await buffer.replay(async (frame) => {\n    newWs.send(JSON.stringify({ type: \"audio_frame\", ...frame }));\n  }, \"ws_reconnect\");\n}\n```\n\nThat's the full lifecycle: `init` → `rememberFrame` per chunk → `acknowledge` on backend confirmation → `replay` on reconnect → `clear` at end of call.\n\n---\n\n## API reference\n\n### `new AudioReplayBuffer(options?: AudioReplayBufferOptions)`\n\n| Option           | Default                  | Notes                                                                      |\n| ---------------- | ------------------------ | -------------------------------------------------------------------------- |\n| `dbName`         | `\"mv3_audio_replay_v1\"`  | IndexedDB database name. Pick a unique name per app.                       |\n| `dbVersion`      | `1`                      | IndexedDB schema version. Bump when changing store names.                  |\n| `frameStoreName` | `\"frames\"`               | Object store for encrypted frame rows.                                     |\n| `keyStoreName`   | `\"keys\"`                 | Object store for the persisted AES key.                                    |\n| `keyId`          | `\"session-audio-buffer\"` | Key ID within `keyStoreName`. Pin per-session if you want key-per-call.    |\n| `ttlMs`          | `60_000`                 | Frames older than this get evicted on the next cleanup pass.               |\n| `maxFrames`      | `240`                    | Hard cap on un-acked frames. Oldest evicted first.                         |\n| `chunkMs`        | `250`                    | Used only to compute `buffered_ms`. Doesn't affect eviction.               |\n| `encrypt`        | `true`                   | If `false`, frames are stored as plaintext JSON. Useful for tests/debug.   |\n\n### `await buffer.init(): Promise<void>`\n\nOpens the IndexedDB and hydrates `nextSeq` from persisted frames. Safe to call repeatedly. Implicitly awaited by every other method, so calling it explicitly is optional but recommended at boot.\n\n### `await buffer.rememberFrame(frame: AudioFrame): Promise<RememberedFrame>`\n\nEncrypts and stores a frame. Assigns `seq = nextSeq + 1` if the caller didn't supply one. Returns the canonical metadata + original `data` + current status, so you can forward live without re-reading.\n\n```typescript\nconst remembered = await buffer.rememberFrame({\n  data: base64,\n  seq: 42,                                  // optional; auto-assigned otherwise\n  ts: Date.now(),                            // optional; defaults to now\n  codec: \"audio/webm;codecs=opus\",           // optional metadata\n  sample_rate: 16000,\n  capture_mode: \"tab\",                       // caller-defined label\n  speaker: \"agent\",\n  call_id: \"call_xyz\",                       // opaque to the buffer\n});\n// remembered = { seq, ts, codec, sample_rate, capture_mode, speaker, call_id,\n//                data, buffered_frames, buffered_ms, audio_seq, last_ack_seq,\n//                ttl_ms, max_frames }\n```\n\n### `await buffer.acknowledge(seq: number): Promise<BufferStatus>`\n\nMark frames with `seq <= ackSeq` as confirmed. They get trimmed on the next cleanup pass. Idempotent and monotonic — passing a lower seq later is a no-op.\n\n### `await buffer.replay(forward, reason?): Promise<ReplayResult>`\n\nSend every un-acked, un-expired frame to your callback in seq order. Each frame is decrypted, tagged `replay: true` and `replay_reason: reason`, then handed to `forward`. Your callback may be async — replay awaits each call so you can rate-limit.\n\n```typescript\ntype ForwardCallback = (frame: ReplayedFrame) => void | Promise<void>;\n\nconst result = await buffer.replay(async (frame) => {\n  await myStream.send(frame);\n}, \"ws_reconnect\");\n// result = { ...status, replayed_frames: 12 }\n```\n\nFrames that fail decryption (wrong key, tampered ciphertext) are silently skipped — replay is best-effort. **Replay does not trim** — frames stay buffered until ack'd or evicted.\n\n### `await buffer.status(): Promise<BufferStatus>`\n\n```typescript\n{\n  buffered_frames: 5,      // un-acked frames currently on disk\n  buffered_ms: 1250,       // = buffered_frames * chunkMs\n  audio_seq: 42,           // highest seq ever assigned\n  last_ack_seq: 37,\n  ttl_ms: 60000,\n  max_frames: 240,\n}\n```\n\nCalling `status()` triggers a cleanup pass — TTL + cap eviction is lazy and runs on every public op.\n\n### `await buffer.clear(): Promise<BufferStatus>`\n\nDrop all frames AND the AES key. Use at end-of-session. The next `rememberFrame()` will lazily regenerate a fresh key.\n\n### `await buffer.rotateKey(): Promise<BufferStatus>`\n\nWipe the AES key and the frame store (existing ciphertext is unreadable after rotation). The next write generates a fresh key.\n\n---\n\n## Encryption\n\n- **Algorithm:** AES-GCM-256.\n- **Key derivation:** none — keys are generated via `crypto.subtle.generateKey({ name: \"AES-GCM\", length: 256 }, false, [\"encrypt\", \"decrypt\"])`. They are **non-extractable** — the raw bytes never leave the browser.\n- **Persistence:** the `CryptoKey` object is stored via IndexedDB's structured clone. Chromium browsers support cloning `CryptoKey`; the key handle survives SW restarts.\n- **IV:** a fresh 12-byte random nonce per encryption (NIST SP 800-38D recommended size). Authentication tag is 128 bits (AES-GCM default).\n- **Rotation:** call `rotateKey()` to wipe and regenerate. Any in-flight ciphertext written before rotation becomes unreadable.\n\n**What's NOT protected:**\n- Frame metadata (codec, sample_rate, capture_mode, speaker, call_id, ts, seq) is stored in plaintext outside the ciphertext so the buffer can sort and trim without decrypting. If you consider metadata sensitive, encrypt it before stuffing it into the `AudioFrame`.\n\n---\n\n## Integration patterns\n\n### A) Deepgram live STT with replay-on-reconnect\n\n```typescript\nconst buffer = new AudioReplayBuffer({ dbName: \"deepgram-audio\" });\nawait buffer.init();\n\nfunction connect(): WebSocket {\n  const ws = new WebSocket(\"wss://api.deepgram.com/v1/listen?...\");\n  ws.binaryType = \"arraybuffer\";\n\n  ws.addEventListener(\"open\", () => {\n    void buffer.replay(async (frame) => {\n      ws.send(base64ToArrayBuffer(frame.data));  // Deepgram wants binary\n    }, \"ws_reconnect\");\n  });\n\n  ws.addEventListener(\"message\", (ev) => {\n    const msg = JSON.parse(String(ev.data));\n    if (msg.is_final && typeof msg.seq === \"number\") {\n      void buffer.acknowledge(msg.seq);          // ack at the final-transcript boundary\n    }\n  });\n\n  ws.addEventListener(\"close\", () => {\n    setTimeout(() => connect(), 1_000);\n  });\n\n  return ws;\n}\n\nlet ws = connect();\nrecorder.ondataavailable = async (e) => {\n  const remembered = await buffer.rememberFrame({ data: await blobToBase64(e.data), ts: Date.now() });\n  if (ws.readyState === WebSocket.OPEN) {\n    ws.send(base64ToArrayBuffer(remembered.data));\n  }\n  // If the socket is down, just buffer — replay will catch it up on next connect.\n};\n```\n\n### B) Twilio Voice media-stream bridge\n\n```typescript\n// Twilio sends/receives base64-encoded mulaw 8kHz frames over a WebSocket.\nconst buffer = new AudioReplayBuffer({\n  dbName: \"twilio-bridge\",\n  chunkMs: 20,           // Twilio's media-stream frame size\n  maxFrames: 1_500,      // 30s of headroom at 20ms/frame\n});\nawait buffer.init();\n\ntwilioWs.on(\"message\", async (raw) => {\n  const msg = JSON.parse(raw.toString());\n  if (msg.event === \"media\") {\n    await buffer.rememberFrame({\n      data: msg.media.payload,\n      seq: Number(msg.media.chunk),\n      ts: Date.now(),\n      codec: \"mulaw\",\n      sample_rate: 8000,\n    });\n  }\n});\n\n// Forward to your STT/agent backend with replay on its socket reconnect.\nagentWs.on(\"open\", () => {\n  void buffer.replay((frame) => {\n    agentWs.send(JSON.stringify({ event: \"media\", chunk: frame.seq, payload: frame.data }));\n  }, \"agent_reconnect\");\n});\n```\n\n### C) Custom WS audio pipeline with end-to-end seq tracking\n\n```typescript\nconst buffer = new AudioReplayBuffer({ dbName: \"custom-stream\" });\nawait buffer.init();\n\n// Producer side\nasync function onChunk(chunk: { base64: string; ts: number }) {\n  const remembered = await buffer.rememberFrame({ data: chunk.base64, ts: chunk.ts });\n  ws.send(JSON.stringify({\n    type: \"audio_frame\",\n    seq: remembered.seq,\n    ts: remembered.ts,\n    data: remembered.data,\n    // Piggyback status so the backend can warn on lag\n    buffered_frames: remembered.buffered_frames,\n    buffered_ms: remembered.buffered_ms,\n  }));\n}\n\n// Consumer ack\nws.addEventListener(\"message\", (e) => {\n  const msg = JSON.parse(e.data);\n  if (msg.type === \"audio_ack\") void buffer.acknowledge(msg.seq);\n});\n```\n\n---\n\n## Performance\n\n- **Write latency:** ~3–10 ms per `rememberFrame()` on a warm Chrome IndexedDB on a desktop SSD. Dominated by the AES-GCM encrypt (sub-ms for typical chunk sizes) and the IDB transaction roundtrip. The library batches nothing — each frame is its own transaction so a SW death mid-write loses at most one frame.\n- **Max realistic throughput:** ~100 frames/sec sustained on commodity hardware (i.e. 10 ms/frame chunks). For higher rates, batch at the producer level before calling `rememberFrame`.\n- **Storage:** with `maxFrames = 240` and typical 250 ms Opus chunks (~6 KB encoded), the buffer caps at ~1.5 MB on disk. AES-GCM adds 28 bytes overhead per record (12-byte IV + 16-byte auth tag, IV stored alongside).\n- **Cleanup cost:** `cleanup()` walks every row to filter by TTL / ack. For `maxFrames = 240` this is negligible; if you raise `maxFrames` to thousands, consider running cleanup less aggressively (the library does it on every public op today).\n\n---\n\n## Pitfalls\n\n- **`IndexedDB blocked` errors.** If two extension contexts open the same `dbName` at different `dbVersion` values, the open with the higher version blocks until the lower-version connection closes. Bumping `dbVersion` while tabs are open will hang. Mitigations: pick a `dbName` carefully up front, or use the version-change hook to close the older connection.\n- **Safari quirks.** Safari has historically thrown `DataCloneError` when structured-cloning `CryptoKey` to IndexedDB. The library targets Chromium-family browsers; on Safari you'd need to switch to `JsonWebKey`-encoded keys (not currently implemented).\n- **Key persistence across SW restarts.** The CryptoKey survives SW restarts via IndexedDB structured clone in Chromium. If the user's profile is corrupted or the IDB is wiped, frames written under the old key are permanently unreadable — `replay()` returns them as skipped (decrypt returns `null`).\n- **Replay does not auto-trim.** Frames stay on disk until ack'd. If your backend forgets to ack, you'll lean on TTL + `maxFrames` for eviction. Set those conservatively for your worst-case retention.\n- **Sequence collisions.** If you pass an explicit `seq` and reuse a value, the second `rememberFrame` overwrites the first row (IndexedDB `put` semantics on the `seq` keypath). Either let the buffer assign seqs, or guarantee monotonicity at the producer.\n- **`encrypt` flips are sticky per `dbName`.** Frames written with `encrypt: true` cannot be read with `encrypt: false` (and vice versa). If you change the mode, `clear()` first.\n- **MV3 offscreen-only.** This library expects a DOM context (`indexedDB` + `crypto.subtle`). The MV3 background service worker has both, but no `MediaRecorder` — wire this from the offscreen document, not directly from the SW.\n\n---\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n\nExtracted from [sales-coach](https://github.com/axumquant/sales-coach)'s offscreen audio pipeline.\n","readmeFilename":"README.md","_rev":"1-a83a359936372a14e3b35647c34d8dd1"}