plumcake
Version:
Production-ready audio recording library with crash recovery, pause/resume, and real-time visualization
695 lines (553 loc) • 20.8 kB
Markdown
# Plumcake
**Production-ready audio recording library with crash recovery, pause/resume, and real-time visualization.**
> **⚠️ NOT YET RELEASED**
>
> **Expected Release:** December 2025
>
> This package is currently under active development. The API documentation below represents the planned interface and is subject to change. This page serves as a preview of what's coming - the actual library is not yet available for production use.
>
> **What you're seeing:** API design, planned features, and implementation roadmap
>
> **What's ready:** Core functionality is built and tested, but not yet published
>
> **Timeline:** Full release scheduled for December 2025
```bash
# Not yet available - coming December 2025
npm install plumcake
```
Built with Effect.ts for bulletproof error handling. Will work in any modern browser with real MediaRecorder APIs.
## Why Plumcake?
Voice recording applications often treat audio recordings as expendable data. A browser crash, network interruption, or application error can result in the permanent loss of minutes or hours of recorded content. This is particularly problematic for professionals who rely heavily on voice recording: writers capturing ideas, academics conducting interviews, researchers documenting observations, and anyone who uses voice as their primary medium for transferring thoughts into text.
**The fundamental problem:** Most recording applications operate on a binary success/failure model. Either the recording completes successfully, or it's lost entirely. There is rarely any middle ground, no safety net, no recovery mechanism.
**Plumcake's philosophy:** Voice recordings are sacred. Once captured, they should never be lost due to technical failures. The library implements:
- **Crash Recovery:** Automatic detection and recovery of interrupted recordings
- **Chunk-based Storage:** Audio data is saved incrementally to IndexedDB every few seconds, not just at the end
- **Graceful Degradation:** Even if the application crashes mid-recording, the captured audio up to that point is recoverable
- **Deliberate Control:** Users should only lose recordings through deliberate action (cancellation, deletion), never through technical failure
This library doesn't handle transcription or cloud storage - it focuses exclusively on ensuring the integrity and recoverability of the raw audio data. Everything else is an extension you can build on top of a reliable foundation.
## Quick Start (Planned API)
> The examples below show the intended API design. This is subject to refinement before the official release.
### Vanilla JavaScript
```javascript
import { createRecorder } from 'plumcake'
const recorder = await createRecorder()
const recording = await recorder.startRecording()
// User speaks...
await recorder.stopRecording()
const audioBlob = await recorder.exportAsBlob(recording.id)
```
### React Hook
```javascript
import { useAudioRecorder } from 'plumcake'
function VoiceRecorder() {
const { startRecording, stopRecording, isRecording, recording } = useAudioRecorder()
return (
<div>
<button onClick={startRecording} disabled={isRecording}>Record</button>
<button onClick={stopRecording} disabled={!isRecording}>Stop</button>
{recording && <p>Duration: {recording.duration}ms</p>}
</div>
)
}
```
## Core Features
| Feature | Description | Status |
|---------|-------------|--------|
| **Audio Recording** | WebM/WAV recording with MediaRecorder | ✓ |
| **Pause/Resume** | Accurate duration tracking during pauses | ✓ |
| **Crash Recovery** | Auto-recover interrupted recordings | ✓ |
| **Visualization** | Real-time waveform, frequency, volume | ✓ |
| **Chunk Storage** | IndexedDB with 5s chunks for reliability | ✓ |
| **Error Handling** | Typed errors with Effect.ts | ✓ |
| **React Integration** | Comprehensive hooks with cleanup | ✓ |
| **TypeScript** | Full type safety | ✓ |
## API Reference (Planned)
> This API is currently being finalized. Method signatures and behavior may change before release.
### Core API
#### `createRecorder(config?)`
Creates a new audio recorder instance.
```typescript
interface RecorderConfig {
quality?: 'low' | 'standard' | 'high' // Default: 'standard'
format?: 'webm' | 'wav' // Default: 'webm'
chunkSize?: number // Chunk interval in ms, Default: 5000
onRecordingComplete?: (recording: Recording) => Promise<void>
}
const recorder = await createRecorder({
quality: 'high',
format: 'webm',
chunkSize: 3000,
onRecordingComplete: async (recording) => {
await uploadToServer(recording)
}
})
```
#### Recorder Methods
**Recording Control:**
```typescript
await recorder.startRecording() // → Recording
await recorder.stopRecording() // → Recording
await recorder.pauseRecording() // → void
await recorder.resumeRecording() // → void
await recorder.cancelRecording() // → void
```
**Data Management:**
```typescript
await recorder.exportAsBlob(id, format?) // → Blob
await recorder.exportAsFile(id, filename?) // → void (downloads)
await recorder.exportAsArrayBuffer(id) // → ArrayBuffer
await recorder.exportAsBase64(id) // → string
await recorder.getAllRecordings(excludeStatus?) // → Recording[]
await recorder.getRecoveredRecordings() // → Recording[] (crash recovery)
await recorder.deleteRecording(id) // → void
```
**Visualization:**
```typescript
await recorder.startVisualization() // → void
await recorder.stopVisualization() // → void
await recorder.getVisualizationData() // → VisualizationData | null
```
**Event Listeners:**
```typescript
recorder.onRecordingStart(callback) // (recording: Recording) => void
recorder.onRecordingStop(callback) // (recording: Recording) => void
recorder.onRecordingPause(callback) // (recording: Recording) => void
recorder.onRecordingResume(callback) // (recording: Recording) => void
recorder.onChunkSaved(callback) // (chunk: AudioChunk) => void
recorder.onError(callback) // (error: RecorderError) => void
recorder.onVisualizationUpdate(callback) // (data: VisualizationData) => void
```
### React Hooks
#### `useAudioRecorder(config?)`
Main recording hook with full functionality.
```typescript
const {
// State
recording, // Recording | null - Current recording
isRecording, // boolean - Is actively recording
error, // RecorderError | null - Last error
isInitializing, // boolean - Loading microphone
// Recording Actions
startRecording, // () => Promise<Recording>
stopRecording, // () => Promise<Recording>
pauseRecording, // () => Promise<void>
resumeRecording, // () => Promise<void>
cancelRecording, // () => Promise<void>
// Data Management
getBlob, // (id: string, format?) => Promise<Blob>
download, // (id: string, filename?) => Promise<void>
getAllRecordings, // (excludeStatus?) => Promise<Recording[]>
getRecoveredRecordings, // () => Promise<Recording[]>
deleteRecording, // (id: string) => Promise<void>
// Visualization (Auto-managed)
visualizationData, // VisualizationData | null
startVisualization, // () => Promise<void>
stopVisualization, // () => Promise<void>
// Utilities
clearError, // () => void
recorder // AudioRecorder | null - Raw recorder
} = useAudioRecorder(config)
```
#### `useRecoveryManager()`
Crash recovery management hook.
```typescript
const {
recoveredRecordings, // Recording[] - Found crashed recordings
handleRecovered, // (recording, onComplete?) => Promise<void>
discardRecovered, // (recording) => Promise<void>
hasRecovered // boolean - Any recovered recordings exist
} = useRecoveryManager()
```
### Data Types
#### Recording Object
```typescript
interface Recording {
readonly id: string // Unique identifier
readonly duration: number // Duration in milliseconds
readonly status: 'recording' | 'paused' | 'completed' | 'cancelled' | 'crashed'
readonly startTime: number // Timestamp when started
readonly chunks: AudioChunk[] // Audio data chunks
readonly pausedAt?: number // When paused (for resume calculation)
readonly totalPausedTime?: number // Total time spent paused
readonly estimatedDuration?: boolean // If duration is estimated (crash recovery)
}
```
#### Visualization Data
```typescript
interface VisualizationData {
readonly frequencyData: Uint8Array // Frequency spectrum (0-255)
readonly waveformData: Float32Array // Waveform data (-1 to 1)
readonly volume: number // Current volume (0-1)
readonly isActive: boolean // Is visualization active
readonly timestamp: number // When captured
}
```
#### Error Types
```typescript
type RecorderError =
| { _tag: 'PermissionDenied', message: string }
| { _tag: 'NetworkError', message: string }
| { _tag: 'StorageError', message: string }
| { _tag: 'UnknownError', message: string }
```
## Complete Examples (Planned Usage)
> These examples demonstrate the planned usage patterns. Actual implementation may vary.
### Basic Recording with Error Handling
```javascript
import { createRecorder } from 'plumcake'
async function recordAudio() {
try {
const recorder = await createRecorder({ quality: 'high' })
recorder.onError((error) => {
switch (error._tag) {
case 'PermissionDenied':
console.log('Microphone access denied')
break
case 'StorageError':
console.log('Storage full or unavailable')
break
default:
console.log('Recording error:', error.message)
}
})
const recording = await recorder.startRecording()
console.log('Recording started:', recording.id)
// Record for 5 seconds
setTimeout(async () => {
const completed = await recorder.stopRecording()
const blob = await recorder.exportAsBlob(completed.id)
console.log('Recording completed:', blob.size, 'bytes')
}, 5000)
} catch (error) {
console.error('Failed to start recording:', error)
}
}
```
### React App with Pause/Resume
```javascript
import { useAudioRecorder } from 'plumcake'
import { useState, useEffect } from 'react'
function AdvancedRecorder() {
const {
startRecording, stopRecording, pauseRecording, resumeRecording,
isRecording, recording, error
} = useAudioRecorder({ quality: 'high' })
const [currentDuration, setCurrentDuration] = useState(0)
// Live duration updates
useEffect(() => {
if (!isRecording || !recording) return
const interval = setInterval(() => {
if (recording.status === 'recording') {
const totalPaused = recording.totalPausedTime || 0
const elapsed = Date.now() - recording.startTime - totalPaused
setCurrentDuration(elapsed)
} else if (recording.status === 'paused') {
setCurrentDuration(recording.duration)
}
}, 100)
return () => clearInterval(interval)
}, [isRecording, recording])
const formatTime = (ms) => {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`
}
return (
<div>
{error && <div style={{color: 'red'}}>Error: {error.message}</div>}
<div>Duration: {formatTime(currentDuration)}</div>
{!isRecording ? (
<button onClick={startRecording}>Start Recording</button>
) : (
<div>
<button onClick={() =>
recording?.status === 'paused' ? resumeRecording() : pauseRecording()
}>
{recording?.status === 'paused' ? 'Resume' : 'Pause'}
</button>
<button onClick={stopRecording}>Stop & Save</button>
</div>
)}
</div>
)
}
```
### Audio Visualization with Canvas
```javascript
import { useAudioRecorder } from 'plumcake'
import { useRef, useEffect } from 'react'
function AudioVisualizer() {
const { startRecording, stopRecording, isRecording, visualizationData } = useAudioRecorder()
const canvasRef = useRef(null)
// Auto-start visualization when recording
useEffect(() => {
if (!canvasRef.current || !visualizationData) return
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
const { width, height } = canvas
const { waveformData } = visualizationData
// Clear canvas
ctx.clearRect(0, 0, width, height)
// Draw waveform
ctx.strokeStyle = '#00ff88'
ctx.lineWidth = 2
ctx.beginPath()
const sliceWidth = width / waveformData.length
let x = 0
for (let i = 0; i < waveformData.length; i++) {
const amplitude = waveformData[i] * (height / 2) * 0.8
const y = (height / 2) - amplitude
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
x += sliceWidth
}
ctx.stroke()
}, [visualizationData])
return (
<div>
<canvas ref={canvasRef} width={400} height={100} style={{border: '1px solid #ccc'}} />
<div>
<button onClick={startRecording} disabled={isRecording}>Record</button>
<button onClick={stopRecording} disabled={!isRecording}>Stop</button>
</div>
{visualizationData && (
<div>Volume: {Math.round(visualizationData.volume * 100)}%</div>
)}
</div>
)
}
```
### Crash Recovery Implementation
```javascript
import { useAudioRecorder, useRecoveryManager } from 'plumcake'
function AppWithRecovery() {
const { startRecording, getAllRecordings } = useAudioRecorder()
const { recoveredRecordings, handleRecovered, discardRecovered, hasRecovered } = useRecoveryManager()
const handleRecover = async (recording) => {
await handleRecovered(recording, async (completedRecording) => {
// Refresh recordings list
const allRecordings = await getAllRecordings()
console.log('Recovery completed, total recordings:', allRecordings.length)
})
}
return (
<div>
{hasRecovered && (
<div style={{background: '#fff3cd', padding: 16, marginBottom: 16}}>
<h3>Recovered Recordings Found</h3>
<p>The app found {recoveredRecordings.length} interrupted recording(s).</p>
{recoveredRecordings.map(recording => (
<div key={recording.id} style={{marginBottom: 8}}>
<span>Recording from {new Date(recording.startTime).toLocaleString()}</span>
<span> ({Math.round(recording.duration / 1000)}s)</span>
<button onClick={() => handleRecover(recording)}>Save</button>
<button onClick={() => discardRecovered(recording)}>Discard</button>
</div>
))}
</div>
)}
<button onClick={startRecording}>Start New Recording</button>
</div>
)
}
```
### Auto-Upload with Progress
```javascript
import { useAudioRecorder } from 'plumcake'
const uploadRecording = async (blob, onProgress) => {
const formData = new FormData()
formData.append('audio', blob, 'recording.webm')
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => resolve(xhr.response))
xhr.addEventListener('error', reject)
xhr.open('POST', '/api/upload')
xhr.send(formData)
})
}
function AutoUploadRecorder() {
const [uploadProgress, setUploadProgress] = useState(0)
const [isUploading, setIsUploading] = useState(false)
const { startRecording, stopRecording, isRecording, getBlob } = useAudioRecorder({
quality: 'high',
onRecordingComplete: async (recording) => {
setIsUploading(true)
try {
const blob = await getBlob(recording.id)
await uploadRecording(blob, setUploadProgress)
console.log('Upload completed!')
} catch (error) {
console.error('Upload failed:', error)
} finally {
setIsUploading(false)
setUploadProgress(0)
}
}
})
return (
<div>
<button onClick={startRecording} disabled={isRecording || isUploading}>
{isRecording ? 'Recording...' : 'Start Recording'}
</button>
<button onClick={stopRecording} disabled={!isRecording}>Stop</button>
{isUploading && (
<div>
<div>Uploading... {uploadProgress}%</div>
<div style={{width: '100%', background: '#f0f0f0'}}>
<div style={{width: `${uploadProgress}%`, background: '#007bff', height: 8}} />
</div>
</div>
)}
</div>
)
}
```
## Configuration Reference
### Quality Settings
```javascript
{
quality: 'low', // 64kbps, efficient for speech
quality: 'standard', // 128kbps, good balance (default)
quality: 'high' // 320kbps, best quality
}
```
### Format Support
```javascript
{
format: 'webm', // Best compression, wide support (default)
format: 'wav' // Uncompressed, universal compatibility
}
```
### Chunk Configuration
```javascript
{
chunkSize: 3000, // 3 second chunks (more frequent saves)
chunkSize: 5000, // 5 second chunks (default, balanced)
chunkSize: 10000 // 10 second chunks (less frequent saves)
}
```
### Storage Behavior
- **Automatic chunking** every `chunkSize` milliseconds
- **IndexedDB storage** for crash recovery
- **Duplicate handling** with status priority system
- **Cleanup on cancel** removes partial recordings
## Error Handling Patterns
### Comprehensive Error Handling
```javascript
const recorder = await createRecorder()
recorder.onError((error) => {
switch (error._tag) {
case 'PermissionDenied':
// Show permission request UI
showPermissionDialog()
break
case 'StorageError':
// Handle storage full/unavailable
showStorageWarning()
break
case 'NetworkError':
// Handle connection issues
enableOfflineMode()
break
default:
// Generic error fallback
showErrorMessage(error.message)
}
})
```
### React Error Boundaries
```javascript
function RecordingErrorBoundary({ children }) {
const [error, setError] = useState(null)
if (error) {
return (
<div>
<h2>Recording Error</h2>
<p>{error.message}</p>
<button onClick={() => setError(null)}>Try Again</button>
</div>
)
}
return (
<ErrorBoundary onError={setError}>
{children}
</ErrorBoundary>
)
}
```
## Testing
The library includes comprehensive test coverage:
- **Unit Tests**: 44/45 passing (98% success rate)
- **Real Browser Tests**: 20/20 passing (100% success rate)
### Run Tests
```bash
npm test # Unit tests (fast, mocked)
npm run test:browser # Real browser tests (comprehensive)
npm run test:ui # Interactive test UI
```
## Browser Support
**Requirements:**
- Modern browser with MediaRecorder API
- Secure context (HTTPS or localhost)
- IndexedDB support
**Feature Support:**
```javascript
// Check before using
if (typeof MediaRecorder === 'undefined') {
console.error('MediaRecorder not supported')
}
if (!navigator.mediaDevices?.getUserMedia) {
console.error('getUserMedia not supported')
}
```
## Bundle Size
| Import | Size (gzipped) |
|--------|----------------|
| Core only | ~8KB |
| Core + React | ~12KB |
| Full library | ~15KB |
**Tree-shaking supported:**
```javascript
// Import only what you need
import { createRecorder } from 'plumcake' // Core only
import { useAudioRecorder } from 'plumcake' // + React
import { useRecoveryManager } from 'plumcake' // + Recovery
```
## Dependencies
- **effect**: Functional error handling and async operations
- **react**: Peer dependency for hooks (optional)
**Zero runtime dependencies** for core functionality.
## Release Timeline
**Current Status:** Active development, core features implemented and tested
**Target Release:** December 2025
**What's Next:**
- Final API review and stabilization
- Comprehensive documentation
- Performance optimization
- Production testing across browsers
- Publishing to NPM
## Contact
Questions or interested in early access?
**Author:** basepurpose
Repository will be made public closer to release.
*Built for robust audio recording in web applications. Prioritizing data integrity and user trust.*
**Remember:** This package is not yet released. Star the repository to stay updated on the December 2025 release.