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
JavaScript
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;