about summary refs log tree commit diff stats
path: root/html/voice-memos
diff options
context:
space:
mode:
Diffstat (limited to 'html/voice-memos')
-rw-r--r--html/voice-memos/app.js606
-rw-r--r--html/voice-memos/index.html248
2 files changed, 854 insertions, 0 deletions
diff --git a/html/voice-memos/app.js b/html/voice-memos/app.js
new file mode 100644
index 0000000..d21d1fa
--- /dev/null
+++ b/html/voice-memos/app.js
@@ -0,0 +1,606 @@
+/**
+ * @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<Blob>} 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 => `<option value="${device.deviceId}">${device.label || `Microphone ${device.deviceId.slice(0, 5)}`}</option>`)
+            .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();
\ No newline at end of file
diff --git a/html/voice-memos/index.html b/html/voice-memos/index.html
new file mode 100644
index 0000000..af4aaef
--- /dev/null
+++ b/html/voice-memos/index.html
@@ -0,0 +1,248 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="theme-color" content="#ff3b30">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+    <title>Voice Memos</title>
+    <style>
+        :root {
+            --primary-color: #ff3b30;
+            --secondary-color: #34c759;
+            --text-color: #333;
+            --light-gray: #f2f2f7;
+            --medium-gray: #e5e5ea;
+            --dark-gray: #8e8e93;
+            --border-radius: 12px;
+            --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
+        }
+        
+        * {
+            -webkit-tap-highlight-color: transparent;
+            box-sizing: border-box;
+        }
+        
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+            max-width: 800px;
+            margin: 0 auto;
+            padding: 20px;
+            background-color: beige;
+            color: var(--text-color);
+            -webkit-touch-callout: none;
+            -webkit-user-select: none;
+            user-select: none;
+        }
+        
+        .container {
+            background-color: white;
+            padding: 24px;
+            border-radius: var(--border-radius);
+            box-shadow: 0 4px 20px rgba(0,0,0,0.08);
+        }
+        
+        h1 {
+            font-size: 24px;
+            font-weight: 600;
+            margin-top: 0;
+            margin-bottom: 24px;
+            text-align: center;
+        }
+        
+        .input-container {
+            margin-bottom: 24px;
+        }
+        
+        .controls {
+            display: flex;
+            gap: 12px;
+            margin: 24px 0;
+            justify-content: center;
+        }
+        
+        button {
+            padding: 10px 20px;
+            border: none;
+            border-radius: 24px;
+            font-weight: 500;
+            font-size: 14px;
+            cursor: pointer;
+            transition: all 0.2s ease;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            min-width: 100px;
+            touch-action: manipulation;
+        }
+        
+        button:active {
+            transform: scale(0.96);
+        }
+        
+        /* Mobile styles */
+        @media screen and (max-width: 600px) {
+            body {
+                padding: 12px;
+            }
+            
+            .container {
+                padding: 16px;
+                padding-bottom: calc(16px + var(--safe-area-inset-bottom));
+                margin-bottom: env(safe-area-inset-bottom);
+            }
+            
+            .controls {
+                flex-direction: column;
+                gap: 16px;
+                padding: 0;
+            }
+            
+            button {
+                width: 100%;
+                padding: 16px 20px;
+                font-size: 16px;
+                min-width: unset;
+                min-height: 48px;
+            }
+            
+            select {
+                height: 48px;
+                font-size: 16px;
+                padding: 12px 16px;
+            }
+            
+            .waveform-container {
+                height: 100px;
+                margin: 16px 0;
+            }
+            
+            .status {
+                font-size: 15px;
+                padding: 8px 0;
+            }
+        }
+        
+        #startBtn {
+            background-color: var(--primary-color);
+            color: white;
+        }
+        
+        #stopBtn {
+            background-color: var(--dark-gray);
+            color: white;
+        }
+        
+        #playBtn {
+            background-color: var(--secondary-color);
+            color: white;
+        }
+        
+        #saveBtn {
+            background-color: #007AFF;
+            color: white;
+        }
+        
+        button:disabled {
+            background-color: var(--medium-gray) !important;
+            color: var(--dark-gray);
+            cursor: not-allowed;
+            transform: none;
+            box-shadow: none;
+        }
+        
+        select {
+            width: 100%;
+            padding: 12px;
+            border-radius: var(--border-radius);
+            border: 1px solid var(--medium-gray);
+            background-color: var(--light-gray);
+            font-size: 14px;
+            appearance: none;
+            background-repeat: no-repeat;
+            background-position: right 12px top 50%;
+            background-size: 12px auto;
+            touch-action: manipulation;
+        }
+        
+        .waveform-container {
+            position: relative;
+            width: 100%;
+            height: 120px;
+            background-color: var(--light-gray);
+            border-radius: var(--border-radius);
+            margin: 24px 0;
+            overflow: hidden;
+        }
+        
+        #waveform {
+            width: 100%;
+            height: 100%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        
+        .status {
+            text-align: center;
+            margin: 16px 0;
+            font-size: 14px;
+            color: var(--dark-gray);
+            font-weight: 500;
+        }
+        
+        .recording-indicator {
+            display: none;
+            position: absolute;
+            top: 10px;
+            right: 10px;
+            width: 12px;
+            height: 12px;
+            border-radius: 50%;
+            background-color: var(--primary-color);
+            animation: pulse 1.5s infinite;
+        }
+        
+        @keyframes pulse {
+            0% {
+                transform: scale(0.95);
+                box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.7);
+            }
+            
+            70% {
+                transform: scale(1);
+                box-shadow: 0 0 0 10px rgba(255, 59, 48, 0);
+            }
+            
+            100% {
+                transform: scale(0.95);
+                box-shadow: 0 0 0 0 rgba(255, 59, 48, 0);
+            }
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        
+        <div class="input-container">
+            <select id="inputSource"></select>
+        </div>
+        
+        <div class="waveform-container">
+            <div id="waveform"></div>
+            <div class="recording-indicator" id="recordingIndicator"></div>
+        </div>
+        
+        <div class="controls">
+            <button id="startBtn">Record</button>
+            <button id="stopBtn" disabled>Stop</button>
+            <button id="playBtn" disabled>Play</button>
+            <button id="saveBtn" disabled>Save</button>
+        </div>
+        
+        <div id="status" class="status">Select an input source to begin</div>
+    </div>
+    <script src="app.js"></script>
+</body>
+</html>
\ No newline at end of file