Skip to content

basepurpose/plumcake

Repository files navigation

Plumcake

Audio recording built on one principle: a recording in progress should survive the things that normally destroy it, including a crash, a refresh, a thrown exception, or a full disk.

npm install plumcake

Why

A voice recording is often the only copy of something that cannot be reproduced. Think of a twenty-minute interview, a dictated chapter, or a thought caught before it was gone. The words were spoken once, and if the recording is lost, they are lost. This is a different category of data from a form field or a cached image. It deserves to be treated as sacred.

Most recorders do not treat it that way. They hold audio in memory and write a single file when the user presses stop. That design has exactly one failure mode, and it is total: anything that interrupts the session before stop, whether a tab crash, a navigation, an out-of-memory kill, or a thrown error, loses everything captured so far. There is no partial result, no safety net. For a short clip that is a nuisance. For real work it is the kind of loss that makes people stop trusting the tool.

Plumcake exists to make that loss structurally impossible. A recording is durable from the first second: audio is persisted incrementally as it is captured, not buffered until the end, so whatever was recorded before an interruption is still there afterward and can be reconstructed. A recording is only lost when someone deliberately cancels or deletes it. The library does this one thing and does it carefully. It keeps the raw audio safe and recoverable, and leaves transcription, upload, and storage policy to the application built on top of it.

Platform support

The principle is platform-agnostic; the current release targets the browser, where audio is persisted to IndexedDB. Support for other runtimes (React Native and beyond) is on the roadmap, and the durability model and core API are designed to carry across them.

Durability model

The guarantees worth knowing before you depend on it (described here as implemented in the browser, over IndexedDB):

  • Incremental writes. Each chunk is persisted as it arrives (default every 5s, configurable). Writes are idempotent: a retried write of the same chunk overwrites rather than duplicates.
  • Writes retry on transient failure. IndexedDB write failures are retried with exponential backoff and jitter. Quota-exceeded is treated as terminal, not transient, and surfaced immediately as a StorageError rather than retried into a tight loop.
  • Chunks are never dropped silently. If a write keeps failing, the chunk stays in an in-memory queue and the failure is surfaced through onError. The queue drains in the background as conditions recover, and stopRecording flushes the final chunk and waits for the queue to drain before resolving.
  • Crash recovery. Recordings interrupted without a clean stop are detectable on next load via getRecoveredRecordings() and can be completed or discarded explicitly.

Errors are a tagged union, so handling is exhaustive at the type level:

type RecorderError =
  | { _tag: 'PermissionDenied'; message: string }
  | { _tag: 'NetworkError';     message: string; retryable: boolean }
  | { _tag: 'StorageError';     message: string }
  | { _tag: 'EncodingError';    message: string }
  | { _tag: 'UnknownError';     message: string }

There are no runtime dependencies. React is an optional peer dependency, required only if you use the hooks.

Usage

Core API

import { createRecorder } from 'plumcake'

const recorder = await createRecorder()

recorder.onError((error) => {
  if (error._tag === 'StorageError') {
    // a chunk write is failing; recording continues, queue is retrying
  }
})

await recorder.startRecording()
// ... user speaks ...
const recording = await recorder.stopRecording() // resolves after the final chunk is flushed
const blob = await recorder.exportAsBlob(recording.id)

React

import { useAudioRecorder } from 'plumcake'

function Recorder() {
  const { startRecording, stopRecording, pauseRecording, resumeRecording,
          isRecording, recording, error } = useAudioRecorder()

  return (
    <div>
      {error && <p role="alert">{error.message}</p>}
      <button onClick={startRecording} disabled={isRecording}>Record</button>
      <button onClick={stopRecording} disabled={!isRecording}>Stop</button>
      {recording && <span>{Math.round(recording.duration / 1000)}s</span>}
    </div>
  )
}

Recovery on load

Check for interrupted recordings when the app starts and let the user decide what to do with them.

import { useRecoveryManager } from 'plumcake'

function RecoveryPrompt() {
  const { recoveredRecordings, handleRecovered, discardRecovered, hasRecovered } =
    useRecoveryManager()

  if (!hasRecovered) return null

  return recoveredRecordings.map((rec) => (
    <div key={rec.id}>
      <span>{new Date(rec.startTime).toLocaleString()} · {Math.round(rec.duration / 1000)}s</span>
      <button onClick={() => handleRecovered(rec, async (blob) => { /* persist it */ })}>Keep</button>
      <button onClick={() => discardRecovered(rec)}>Discard</button>
    </div>
  ))
}

Configuration

const recorder = await createRecorder({
  quality: 'high',     // 'low' (32kbps) | 'medium' (128kbps, default) | 'high' (320kbps)
  format: 'webm',      // 'webm' (default) | 'wav'
  chunkSize: 5000,     // write interval in ms; lower means more frequent durability checkpoints
  onRecordingComplete: async (recording) => {
    // called after a clean stop
  },
})

chunkSize is the central durability/overhead tradeoff. A smaller interval narrows the window of audio that can be lost in a crash at the cost of more frequent writes.

API surface

Core. createRecorder(config?) returns an AudioRecorder with:

  • Recording control: startRecording, stopRecording, pauseRecording, resumeRecording, cancelRecording. Pause/resume track paused time so reported duration excludes it.
  • Export: exportAsBlob, exportAsArrayBuffer, exportAsBase64, exportAsFile.
  • Management: getAllRecordings, getCurrentRecording, deleteRecording, updateRecording.
  • Recovery: getRecoveredRecordings, completeRecoveredRecording, getActualDuration, markRecordingCompleted.
  • Visualization: startVisualization, stopVisualization, getVisualizationData, onVisualizationUpdate (frequency, waveform, volume).
  • Events: onRecordingStart, onRecordingStop, onRecordingPause, onRecordingResume, onChunkSaved, onError.

React hooks. useAudioRecorder (full state + actions), useRecoveryManager (recovery flow), useSimpleRecorder (record-then-upload), useAudioVisualization.

The library is headless: it ships hooks and the core recorder, not UI. Reference component implementations live in the repository's examples/ directory.

Requirements

The current release runs in a modern browser with MediaRecorder, getUserMedia, and IndexedDB, served from a secure context (HTTPS or localhost). Fully typed; ships .d.ts.

Testing

npm test            # unit suite (jsdom, mocked storage and media)
npm run test:browser # integration suite in real Chromium via Playwright

The unit suite covers the error model, retry/backoff, the never-drop chunk queue, the storage layer, and the React hooks. The browser suite exercises real getUserMedia, MediaRecorder, AudioContext, and IndexedDB.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages