UNPKG

claritykit-svelte

Version:

A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility

329 lines (328 loc) 14.1 kB
import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; export const AudioRecordingExtension = Extension.create({ name: 'audioRecording', addOptions() { return { maxDuration: 300, // 5 minutes audioFormat: 'webm', sampleRate: 44100, audioBitsPerSecond: 128000, enableWaveform: true, enableTranscription: false, }; }, addStorage() { return { recordingState: { isRecording: false, isPaused: false, duration: 0, mediaRecorder: null, audioStream: null, audioChunks: [], waveformData: [], }, }; }, addCommands() { return { startRecording: () => async ({ editor }) => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { sampleRate: this.options.sampleRate, channelCount: 1, echoCancellation: true, noiseSuppression: true, }, }); const mediaRecorder = new MediaRecorder(stream, { mimeType: `audio/${this.options.audioFormat}`, audioBitsPerSecond: this.options.audioBitsPerSecond, }); const storage = this.storage.recordingState; storage.audioStream = stream; storage.mediaRecorder = mediaRecorder; storage.audioChunks = []; storage.waveformData = []; storage.duration = 0; storage.isRecording = true; storage.isPaused = false; // Set up waveform analysis if enabled let audioContext = null; let analyser = null; let dataArray = null; if (this.options.enableWaveform) { audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(stream); analyser = audioContext.createAnalyser(); analyser.fftSize = 256; source.connect(analyser); dataArray = new Uint8Array(analyser.frequencyBinCount); } // Start duration timer const startTime = Date.now(); const durationTimer = setInterval(() => { storage.duration = (Date.now() - startTime) / 1000; // Update waveform data if (analyser && dataArray) { analyser.getByteFrequencyData(dataArray); const average = Array.from(dataArray).reduce((a, b) => a + b) / dataArray.length; storage.waveformData.push(average); } // Check max duration if (storage.duration >= this.options.maxDuration) { editor.commands.stopRecording(); } // Update UI this.updateRecordingUI(); }, 100); // Handle data available mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { storage.audioChunks.push(event.data); } }; // Handle recording stop mediaRecorder.onstop = async () => { clearInterval(durationTimer); if (audioContext) { audioContext.close(); } stream.getTracks().forEach(track => track.stop()); const audioBlob = new Blob(storage.audioChunks, { type: `audio/${this.options.audioFormat}`, }); this.options.onRecordingStop?.(audioBlob, storage.duration); // Upload audio if handler provided if (this.options.onAudioUpload) { try { const uploadResult = await this.options.onAudioUpload(audioBlob); let transcription; if (this.options.enableTranscription && this.options.transcriptionService) { try { transcription = await this.options.transcriptionService(audioBlob); } catch (error) { console.warn('Transcription failed:', error); } } editor.commands.insertRecordedAudio({ ...uploadResult, duration: storage.duration, transcription, waveform: storage.waveformData, }); } catch (error) { console.error('Audio upload failed:', error); this.options.onRecordingError?.(error); } } // Reset state storage.isRecording = false; storage.isPaused = false; storage.mediaRecorder = null; storage.audioStream = null; storage.audioChunks = []; storage.waveformData = []; storage.duration = 0; this.updateRecordingUI(); }; // Start recording mediaRecorder.start(100); // Collect data every 100ms this.options.onRecordingStart?.(); this.updateRecordingUI(); return true; } catch (error) { console.error('Failed to start recording:', error); this.options.onRecordingError?.(error); return false; } }, stopRecording: () => ({ editor }) => { const storage = this.storage.recordingState; if (storage.mediaRecorder && storage.isRecording) { storage.mediaRecorder.stop(); return true; } return false; }, pauseRecording: () => ({ editor }) => { const storage = this.storage.recordingState; if (storage.mediaRecorder && storage.isRecording && !storage.isPaused) { storage.mediaRecorder.pause(); storage.isPaused = true; this.updateRecordingUI(); return true; } return false; }, resumeRecording: () => ({ editor }) => { const storage = this.storage.recordingState; if (storage.mediaRecorder && storage.isRecording && storage.isPaused) { storage.mediaRecorder.resume(); storage.isPaused = false; this.updateRecordingUI(); return true; } return false; }, insertRecordedAudio: (audioData) => ({ commands }) => { const waveformSvg = this.generateWaveformSvg(audioData.waveform || []); return commands.insertContent(` <div class="audio-recording-block" data-audio-id="${audioData.id}"> <div class="audio-player"> <audio controls preload="metadata" data-duration="${audioData.duration}"> <source src="${audioData.url}" type="audio/${this.options.audioFormat}"> Your browser does not support the audio element. </audio> ${this.options.enableWaveform && audioData.waveform ? ` <div class="audio-waveform"> ${waveformSvg} </div> ` : ''} </div> <div class="audio-info"> <span class="audio-duration">${this.formatDuration(audioData.duration)}</span> <span class="audio-format">${this.options.audioFormat.toUpperCase()}</span> </div> ${audioData.transcription ? ` <div class="audio-transcription"> <div class="transcription-header"> <span class="transcription-label">Transcription:</span> <button class="transcription-toggle" onclick="this.parentElement.nextElementSibling.classList.toggle('collapsed')"> 👁️ </button> </div> <div class="transcription-text">${audioData.transcription}</div> </div> ` : ''} </div> `); }, }; }, addKeyboardShortcuts() { return { 'Mod-Shift-r': () => { const storage = this.storage.recordingState; if (storage.isRecording) { return this.editor.commands.stopRecording(); } else { return this.editor.commands.startRecording(); } }, }; }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('audioRecording'), view: () => { return { update: () => { this.updateRecordingUI(); }, destroy: () => { // Clean up any ongoing recording const storage = this.storage.recordingState; if (storage.isRecording) { this.editor.commands.stopRecording(); } }, }; }, }), ]; }, // Helper methods updateRecordingUI() { const storage = this.storage.recordingState; // Find or create recording indicator let indicator = document.querySelector('.audio-recording-indicator'); if (storage.isRecording) { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'audio-recording-indicator'; document.body.appendChild(indicator); } const minutes = Math.floor(storage.duration / 60); const seconds = Math.floor(storage.duration % 60); const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}`; indicator.innerHTML = ` <div class="recording-content"> <div class="recording-status"> <div class="recording-dot ${storage.isPaused ? 'paused' : 'active'}"></div> <span class="recording-text"> ${storage.isPaused ? 'Paused' : 'Recording'} ${timeString} </span> </div> <div class="recording-controls"> <button class="recording-btn pause-btn" onclick="window.audioRecordingExtension?.${storage.isPaused ? 'resume' : 'pause'}Recording()"> ${storage.isPaused ? '▶️' : '⏸️'} </button> <button class="recording-btn stop-btn" onclick="window.audioRecordingExtension?.stopRecording()"> ⏹️ </button> </div> ${this.options.enableWaveform ? ` <div class="recording-waveform"> ${this.generateMiniWaveform(storage.waveformData.slice(-20))} </div> ` : ''} </div> `; // Expose methods to global scope for button handlers window.audioRecordingExtension = { pauseRecording: () => this.editor.commands.pauseRecording(), resumeRecording: () => this.editor.commands.resumeRecording(), stopRecording: () => this.editor.commands.stopRecording(), }; } else if (indicator) { indicator.remove(); delete window.audioRecordingExtension; } }, generateWaveformSvg(waveformData) { if (!waveformData.length) return ''; const width = 300; const height = 60; const barWidth = width / waveformData.length; const bars = waveformData .map((value, index) => { const barHeight = (value / 255) * height; const x = index * barWidth; const y = (height - barHeight) / 2; return `<rect x="${x}" y="${y}" width="${barWidth - 1}" height="${barHeight}" fill="currentColor" opacity="0.7" />`; }) .join(''); return ` <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" class="waveform-svg"> ${bars} </svg> `; }, generateMiniWaveform(waveformData) { if (!waveformData.length) return ''; const bars = waveformData .map((value, index) => { const height = Math.max(2, (value / 255) * 20); return `<div class="waveform-bar" style="height: ${height}px;"></div>`; }) .join(''); return `<div class="mini-waveform">${bars}</div>`; }, formatDuration(seconds) { const minutes = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${minutes}:${secs.toString().padStart(2, '0')}`; }, }); export default AudioRecordingExtension;