/** * @typedef {Object} AudioState * @property {MediaRecorder} mediaRecorder - The MediaRecorder instance * @property {AudioContext} audioContext - The Web Audio API context * @property {AnalyserNode} analyser - The analyser node for waveform visualization * @property {Array} audioChunks - Array of audio data chunks * @property {boolean} isRecording - Recording state flag * @property {number} countdown - Countdown value before recording starts * @property {boolean} isPlaying - Playback state flag * @property {number} recordingStartTime - Timestamp when recording started * @property {string} lastError - Last error message * @property {string} mimeType - MIME type for recording */ /** * @type {AudioState} */ const initialState = { mediaRecorder: null, audioContext: null, analyser: null, audioChunks: [], isRecording: false, countdown: 0, isPlaying: false, recordingStartTime: 0, lastError: '', mimeType: null }; /** * Constants for local storage */ const STORAGE_KEYS = { LAST_DEVICE_ID: 'voice_memo_last_device_id' }; /** * DOM Elements */ const elements = { inputSource: document.getElementById('inputSource'), startBtn: document.getElementById('startBtn'), stopBtn: document.getElementById('stopBtn'), playBtn: document.getElementById('playBtn'), saveBtn: document.getElementById('saveBtn'), waveform: document.getElementById('waveform'), status: document.getElementById('status') }; /** * @returns {Object} State management functions */ const createStateManager = () => { let state = { ...initialState }; return { getState: () => ({ ...state }), setState: (newState) => { state = { ...state, ...newState }; updateUI(); } }; }; const stateManager = createStateManager(); /** * Formats elapsed time to MM:SS * @param {number} seconds - Time in seconds * @returns {string} Formatted time string */ const formatTime = (seconds) => { const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; }; const updateUI = () => { const { isRecording, audioChunks, countdown, isPlaying, recordingStartTime, lastError } = stateManager.getState(); // Update button states elements.startBtn.disabled = isRecording || countdown > 0; elements.stopBtn.disabled = !isRecording; elements.playBtn.disabled = audioChunks.length === 0 || isRecording || isPlaying; elements.saveBtn.disabled = audioChunks.length === 0 || isRecording; // Update recording indicator const recordingIndicator = document.getElementById('recordingIndicator'); if (recordingIndicator) { recordingIndicator.style.display = isRecording ? 'block' : 'none'; } // Update status message let statusMessage = ''; if (lastError) { statusMessage = `Error: ${lastError}`; } else if (countdown > 0) { statusMessage = `Get ready! Recording starts in ${countdown}...`; } else if (isRecording) { const elapsedTime = formatTime((Date.now() - recordingStartTime) / 1000); statusMessage = `Recording in progress... (${elapsedTime})`; } else if (isPlaying) { statusMessage = 'Playing recording...'; } else if (audioChunks.length > 0) { statusMessage = 'Recording complete. You can play it back or save it.'; } else if (!elements.inputSource.value) { statusMessage = 'Please allow microphone access and select an input source'; } else { statusMessage = 'Ready to record. Click "Record" to begin.'; } elements.status.textContent = statusMessage; }; /** * Saves the selected device ID to local storage * Retains the last selected audio input device ID * @param {string} deviceId - The device ID to save */ const saveSelectedDevice = (deviceId) => { try { localStorage.setItem(STORAGE_KEYS.LAST_DEVICE_ID, deviceId); } catch (error) { console.warn('Could not save device preference:', error); } }; /** * Gets the last selected device ID from local storage * so that the user doesn't have to select the same device every time * @returns {string|null} The last selected device ID or null if not found */ const getLastSelectedDevice = () => { try { return localStorage.getItem(STORAGE_KEYS.LAST_DEVICE_ID); } catch (error) { console.warn('Could not retrieve device preference:', error); return null; } }; const populateInputSources = async () => { try { // First, request microphone permission to get device labels await navigator.mediaDevices.getUserMedia({ audio: true }); const devices = await navigator.mediaDevices.enumerateDevices(); const audioInputs = devices.filter(device => device.kind === 'audioinput'); if (audioInputs.length === 0) { elements.status.textContent = 'No audio input devices found'; return; } elements.inputSource.innerHTML = audioInputs .map(device => ``) .join(''); // Try to restore the last selected device const lastDeviceId = getLastSelectedDevice(); if (lastDeviceId) { // Check if the last used device still exists const deviceExists = audioInputs.some(device => device.deviceId === lastDeviceId); if (deviceExists) { elements.inputSource.value = lastDeviceId; } } elements.status.textContent = 'Ready to record. Click "Start Recording" to begin.'; } catch (error) { console.error('Error getting audio devices:', error); elements.status.textContent = 'Please allow microphone access to continue'; } }; /** * Creates and configures the audio context and analyzer * @returns {Promise<{audioContext: AudioContext, analyser: AnalyserNode}>} */ const createAudioContext = async () => { const audioContext = new (window.AudioContext || window.webkitAudioContext)(); const analyser = audioContext.createAnalyser(); analyser.fftSize = 2048; return { audioContext, analyser }; }; /** * Sets up the waveform visualization * @param {AnalyserNode} analyser - The analyzer node */ const setupWaveformVisualization = (analyser) => { const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const canvas = document.createElement('canvas'); const canvasCtx = canvas.getContext('2d'); const recordingIndicator = document.getElementById('recordingIndicator'); // Show recording indicator recordingIndicator.style.display = 'block'; elements.waveform.innerHTML = ''; elements.waveform.appendChild(canvas); const draw = () => { if (!stateManager.getState().isRecording) { recordingIndicator.style.display = 'none'; return; } requestAnimationFrame(draw); analyser.getByteTimeDomainData(dataArray); canvas.width = elements.waveform.clientWidth; canvas.height = elements.waveform.clientHeight; // Clear the canvas with a gradient background const gradient = canvasCtx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, '#f8f8f8'); gradient.addColorStop(1, '#f2f2f7'); canvasCtx.fillStyle = gradient; canvasCtx.fillRect(0, 0, canvas.width, canvas.height); // Draw center line canvasCtx.beginPath(); canvasCtx.strokeStyle = '#e5e5ea'; canvasCtx.lineWidth = 1; canvasCtx.moveTo(0, canvas.height / 2); canvasCtx.lineTo(canvas.width, canvas.height / 2); canvasCtx.stroke(); // Draw waveform canvasCtx.beginPath(); canvasCtx.lineWidth = 2; canvasCtx.strokeStyle = '#ff3b30'; const sliceWidth = canvas.width / bufferLength; let x = 0; const smoothedData = []; const smoothingFactor = 0.2; for (let i = 0; i < bufferLength; i++) { const raw = dataArray[i] / 128.0 - 1.0; if (i > 0) { smoothedData.push(smoothedData[i-1] * smoothingFactor + raw * (1 - smoothingFactor)); } else { smoothedData.push(raw); } } for (let i = 0; i < bufferLength; i++) { const v = smoothedData[i]; const y = (v * canvas.height / 4) + canvas.height / 2; if (i === 0) { canvasCtx.moveTo(x, y); } else { canvasCtx.lineTo(x, y); } x += sliceWidth; } canvasCtx.stroke(); // Draw a reflection of the waveform canvasCtx.beginPath(); canvasCtx.strokeStyle = 'rgba(255, 59, 48, 0.3)'; x = 0; for (let i = 0; i < bufferLength; i++) { const v = -smoothedData[i]; // Mirror the waveform const y = (v * canvas.height / 4) + canvas.height / 2; if (i === 0) { canvasCtx.moveTo(x, y); } else { canvasCtx.lineTo(x, y); } x += sliceWidth; } canvasCtx.stroke(); }; draw(); }; /** * Determines the best supported MIME type for the current browser * @returns {string} The best supported MIME type or null if none found */ const getSupportedMimeType = () => { const types = [ 'audio/webm', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/mpeg' ]; // Add codec options for better compatibility const typesWithCodecs = [ 'audio/webm;codecs=opus', 'audio/webm;codecs=pcm', 'audio/mp4;codecs=mp4a.40.2' ]; // Combine all types to check const allTypes = [...typesWithCodecs, ...types]; for (const type of allTypes) { if (MediaRecorder.isTypeSupported(type)) { console.log(`Browser supports recording with MIME type: ${type}`); return type; } } console.warn('No supported MIME types found for MediaRecorder'); return null; }; /** * Starts the recording process after a countdown */ const startRecording = async () => { try { stateManager.setState({ countdown: 3, lastError: '' }); // Countdown loop for (let i = 3; i > 0; i--) { await new Promise(resolve => setTimeout(resolve, 1000)); stateManager.setState({ countdown: i - 1 }); } const deviceId = elements.inputSource.value; const constraints = { audio: deviceId ? { deviceId: { exact: deviceId } } : true }; const stream = await navigator.mediaDevices.getUserMedia(constraints); const { audioContext, analyser } = await createAudioContext(); const source = audioContext.createMediaStreamSource(stream); source.connect(analyser); // Get supported MIME type const mimeType = getSupportedMimeType(); // Create MediaRecorder with options if mimeType is supported const options = mimeType ? { mimeType } : undefined; const mediaRecorder = new MediaRecorder(stream, options); const audioChunks = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); stateManager.setState({ audioChunks }); } }; mediaRecorder.onstop = () => { const finalChunks = [...audioChunks]; stateManager.setState({ audioChunks: finalChunks, isRecording: false, countdown: 0 }); }; // Start recording with 1 second timeslices to ensure we get data chunks // FIXME: experiment with different chunk sizes mediaRecorder.start(1000); stateManager.setState({ mediaRecorder, audioContext, analyser, audioChunks, isRecording: true, countdown: 0, recordingStartTime: Date.now(), mimeType: mediaRecorder.mimeType }); setupWaveformVisualization(analyser); } catch (error) { console.error('Error starting recording:', error); stateManager.setState({ countdown: 0, lastError: 'Failed to start recording. Please check microphone permissions.' }); } }; /** * Stops the recording and prepares for playback */ const stopRecording = () => { const { mediaRecorder } = stateManager.getState(); if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); mediaRecorder.stream.getTracks().forEach(track => track.stop()); } }; /** * Plays back the recorded audio */ const playRecording = () => { const { audioChunks, mimeType } = stateManager.getState(); // Use the detected MIME type or fallback to a generic audio type const blobType = mimeType || 'audio/webm'; const audioBlob = new Blob(audioChunks, { type: blobType }); const audioUrl = URL.createObjectURL(audioBlob); const audio = new Audio(audioUrl); stateManager.setState({ isPlaying: true }); // Add error handling before playing const playPromise = audio.play(); if (playPromise !== undefined) { playPromise .then(() => { console.log('Audio playback started successfully'); }) .catch(error => { console.error('Error playing audio:', error); URL.revokeObjectURL(audioUrl); stateManager.setState({ isPlaying: false, lastError: `Playback error: ${error.message || 'Could not play recording in this browser'}` }); // Try alternative playback method for Safari if (error.name === 'NotSupportedError') { tryAlternativePlayback(audioBlob); } }); } audio.onended = () => { URL.revokeObjectURL(audioUrl); stateManager.setState({ isPlaying: false }); }; }; /** * Attempts alternative playback methods for Safari * @param {Blob} audioBlob - The audio blob to play */ const tryAlternativePlayback = async (audioBlob) => { try { // Create a new audio context const audioContext = new (window.AudioContext || window.webkitAudioContext)(); // Convert blob to array buffer const arrayBuffer = await audioBlob.arrayBuffer(); // Decode the audio data const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); // Create a buffer source const source = audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(audioContext.destination); // Play the audio source.start(0); stateManager.setState({ isPlaying: true }); // Handle playback completion source.onended = () => { stateManager.setState({ isPlaying: false }); }; console.log('Using alternative playback method for Safari'); } catch (error) { console.error('Alternative playback failed:', error); stateManager.setState({ isPlaying: false, lastError: 'Could not play recording in this browser. Try saving and playing externally.' }); } }; /** * Saves the recording as an audio file with proper metadata */ const saveRecording = async () => { const { audioChunks, mimeType, recordingStartTime } = stateManager.getState(); // Use the detected MIME type or fallback to a generic audio type const blobType = mimeType || 'audio/webm'; const audioBlob = new Blob(audioChunks, { type: blobType }); // Calculate recording duration const recordingDuration = recordingStartTime ? Math.floor((Date.now() - recordingStartTime) / 1000) : 0; // Get device information const deviceId = elements.inputSource.value; const deviceLabel = Array.from(elements.inputSource.options) .find(option => option.value === deviceId)?.text || 'Unknown Device'; // Format current date for filename const now = new Date(); const formattedDate = now.toISOString().replace(/[:.]/g, '-'); // Determine file extension based on MIME type let fileExtension = 'webm'; if (mimeType) { if (mimeType.includes('mp4')) fileExtension = 'mp4'; else if (mimeType.includes('mp3') || mimeType.includes('mpeg')) fileExtension = 'mp3'; else if (mimeType.includes('ogg')) fileExtension = 'ogg'; else if (mimeType.includes('wav')) fileExtension = 'wav'; } // For formats that support metadata through the Web Audio API if (fileExtension === 'wav' || fileExtension === 'mp3') { try { // Create a new audio context const audioContext = new (window.AudioContext || window.webkitAudioContext)(); // Convert blob to array buffer const arrayBuffer = await audioBlob.arrayBuffer(); // Decode the audio data const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); // Create metadata object const metadata = { title: `Voice Memo ${formattedDate}`, artist: 'Audio Gremlins', album: 'Voice Recordings', date: now.toISOString(), device: deviceLabel, duration: recordingDuration, sampleRate: audioBuffer.sampleRate, numberOfChannels: audioBuffer.numberOfChannels }; console.log('Audio metadata:', metadata); // Note: Web browsers don't provide direct API for writing metadata to audio files // We're logging the metadata and including what we can in the filename // For a complete solution, server-side processing or a dedicated library would be needed } catch (error) { console.warn('Could not process audio metadata:', error); } } // Include some metadata in the filename since browser APIs don't allow direct metadata embedding const filename = `voice-memo_${formattedDate}_${deviceLabel.replace(/[^a-z0-9]/gi, '-')}_${recordingDuration}s.${fileExtension}`; // Create download link const audioUrl = URL.createObjectURL(audioBlob); const a = document.createElement('a'); a.href = audioUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(audioUrl); // Log metadata for debugging console.log('Saved recording with filename:', filename); console.log('Recording details:', { timestamp: now.toISOString(), duration: recordingDuration + 's', device: deviceLabel, mimeType: blobType, fileSize: Math.round(audioBlob.size / 1024) + 'KB' }); }; // Event Listeners elements.startBtn.addEventListener('click', startRecording); elements.stopBtn.addEventListener('click', stopRecording); elements.playBtn.addEventListener('click', playRecording); elements.saveBtn.addEventListener('click', saveRecording); elements.inputSource.addEventListener('change', (e) => saveSelectedDevice(e.target.value)); // Initialize populateInputSources();