UNPKG

plumcake

Version:

Production-ready audio recording library with crash recovery, pause/resume, and real-time visualization

695 lines (553 loc) 20.8 kB
# 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.