From c36f4167f216efccc696171598d046294484cbb5 Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 22 Dec 2025 08:40:44 +0800 Subject: [PATCH] feat: Add audio device selector and test recording panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new collapsible panel in meeting-detail page for audio device management with the following features: - Device dropdown selector showing all available microphones - Real-time volume meter using Web Audio API AnalyserNode - Test recording function (5 seconds max with countdown) - Test playback function to verify recording quality - Device hot-plug detection - Preference persistence in localStorage - Traditional Chinese localization The main recording function now uses the user-selected device instead of auto-selecting, giving users full control over which microphone to use for meeting transcription. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- client/src/pages/meeting-detail.html | 767 +++++++++++++++++++++++++-- 1 file changed, 714 insertions(+), 53 deletions(-) diff --git a/client/src/pages/meeting-detail.html b/client/src/pages/meeting-detail.html index 3253d2d..421ccd4 100644 --- a/client/src/pages/meeting-detail.html +++ b/client/src/pages/meeting-detail.html @@ -139,6 +139,172 @@ border-color: #2196F3; box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); } + /* Audio Device Settings Panel */ + .audio-device-panel { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + margin-bottom: 15px; + overflow: hidden; + } + .audio-device-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + background: #e9ecef; + cursor: pointer; + user-select: none; + } + .audio-device-header:hover { + background: #dee2e6; + } + .audio-device-header h3 { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #495057; + } + .audio-device-toggle { + font-size: 12px; + color: #6c757d; + transition: transform 0.2s; + } + .audio-device-panel.collapsed .audio-device-toggle { + transform: rotate(-90deg); + } + .audio-device-panel.collapsed .audio-device-body { + display: none; + } + .audio-device-body { + padding: 15px; + } + .audio-device-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + } + .audio-device-row:last-child { + margin-bottom: 0; + } + .audio-device-label { + font-size: 13px; + color: #495057; + min-width: 70px; + } + .audio-device-select { + flex: 1; + padding: 8px 12px; + border: 1px solid #ced4da; + border-radius: 4px; + font-size: 13px; + background: white; + } + .audio-device-select:focus { + outline: none; + border-color: #2196F3; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); + } + .audio-refresh-btn { + padding: 8px; + border: 1px solid #ced4da; + border-radius: 4px; + background: white; + cursor: pointer; + font-size: 14px; + } + .audio-refresh-btn:hover { + background: #e9ecef; + } + /* Volume Meter */ + .volume-meter-container { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + } + .volume-meter { + flex: 1; + height: 20px; + background: #e9ecef; + border-radius: 10px; + overflow: hidden; + position: relative; + } + .volume-meter-fill { + height: 100%; + width: 0%; + background: linear-gradient(to right, #28a745, #ffc107, #dc3545); + transition: width 0.05s ease-out; + border-radius: 10px; + } + .volume-meter-text { + font-size: 12px; + color: #6c757d; + min-width: 40px; + text-align: right; + } + /* Test Controls */ + .audio-test-controls { + display: flex; + align-items: center; + gap: 10px; + } + .audio-test-btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.2s; + } + .audio-test-btn.record { + background: #dc3545; + color: white; + } + .audio-test-btn.record:hover:not(:disabled) { + background: #c82333; + } + .audio-test-btn.record.recording { + background: #6c757d; + } + .audio-test-btn.play { + background: #28a745; + color: white; + } + .audio-test-btn.play:hover:not(:disabled) { + background: #218838; + } + .audio-test-btn.play.playing { + background: #6c757d; + } + .audio-test-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .audio-status { + font-size: 12px; + color: #6c757d; + margin-left: auto; + } + .audio-status.success { + color: #28a745; + } + .audio-status.error { + color: #dc3545; + } + .audio-status.recording { + color: #dc3545; + } + .no-input-hint { + font-size: 11px; + color: #dc3545; + margin-top: 4px; + } @@ -164,6 +330,47 @@ + +
+
+

音訊設備設定

+ +
+
+ +
+ 麥克風: + + +
+ +
+ 輸入音量: +
+
+
+
+ 0% +
+
+ +
+ 收音測試: +
+ + + 準備就緒 +
+
+
+
+
@@ -284,6 +491,478 @@ const uploadProgressFill = document.getElementById('upload-progress-fill'); const whisperStatusEl = document.getElementById('whisper-status'); + // Audio Device Settings Elements + const audioDevicePanel = document.getElementById('audio-device-panel'); + const audioDeviceHeader = document.getElementById('audio-device-header'); + const audioDeviceSelect = document.getElementById('audio-device-select'); + const audioRefreshBtn = document.getElementById('audio-refresh-btn'); + const volumeMeterFill = document.getElementById('volume-meter-fill'); + const volumeMeterText = document.getElementById('volume-meter-text'); + const testRecordBtn = document.getElementById('test-record-btn'); + const testPlayBtn = document.getElementById('test-play-btn'); + const audioStatusEl = document.getElementById('audio-status'); + + // Audio Device State + const audioDeviceState = { + availableDevices: [], + selectedDeviceId: null, + isMonitoring: false, + monitoringStream: null, + monitoringContext: null, + monitoringAnalyser: null, + animationFrameId: null, + testRecordingBlob: null, + testState: 'idle', // 'idle' | 'recording' | 'playing' + testMediaRecorder: null, + testAudioElement: null, + testCountdown: 0 + }; + + // ======================================== + // Audio Device Management Functions + // ======================================== + + // Check if deviceId is an alias (not a real device ID) + function isAliasDeviceId(id) { + return id === 'default' || id === 'communications' || !id; + } + + // Enumerate audio devices and populate dropdown + async function enumerateAudioDevices() { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const audioInputs = devices.filter(d => d.kind === 'audioinput'); + + // Filter out virtual devices like Stereo Mix + const realDevices = audioInputs.filter(d => + !d.label.includes('立體聲混音') && + !d.label.toLowerCase().includes('stereo mix') + ); + + audioDeviceState.availableDevices = realDevices; + + // Populate dropdown + audioDeviceSelect.innerHTML = ''; + + if (realDevices.length === 0) { + audioDeviceSelect.innerHTML = ''; + setAudioStatus('未偵測到麥克風', 'error'); + return; + } + + realDevices.forEach((device, index) => { + const option = document.createElement('option'); + option.value = device.deviceId; + + // Create friendly label + let label = device.label || `麥克風 ${index + 1}`; + if (device.deviceId === 'default') { + label = `🔹 ${label} (系統預設)`; + } else if (device.deviceId === 'communications') { + label = `📞 ${label} (通訊裝置)`; + } + option.textContent = label; + audioDeviceSelect.appendChild(option); + }); + + // Try to restore saved preference + const savedDeviceId = localStorage.getItem('audioDevice.selectedId'); + const savedDevice = realDevices.find(d => d.deviceId === savedDeviceId); + + if (savedDevice) { + audioDeviceSelect.value = savedDeviceId; + audioDeviceState.selectedDeviceId = savedDeviceId; + } else { + // Prefer non-alias device + const preferredDevice = realDevices.find(d => !isAliasDeviceId(d.deviceId)) || realDevices[0]; + if (preferredDevice) { + audioDeviceSelect.value = preferredDevice.deviceId; + audioDeviceState.selectedDeviceId = preferredDevice.deviceId; + } + } + + console.log('Audio devices enumerated:', realDevices.length, realDevices); + setAudioStatus('準備就緒', 'success'); + + // Start volume monitoring with selected device + await startVolumeMonitoring(); + + } catch (error) { + console.error('Failed to enumerate audio devices:', error); + audioDeviceSelect.innerHTML = ''; + setAudioStatus('無法存取麥克風: ' + error.message, 'error'); + } + } + + // Select audio device + async function selectAudioDevice(deviceId) { + audioDeviceState.selectedDeviceId = deviceId; + + // Save preference + if (deviceId) { + localStorage.setItem('audioDevice.selectedId', deviceId); + const device = audioDeviceState.availableDevices.find(d => d.deviceId === deviceId); + if (device) { + localStorage.setItem('audioDevice.lastUsedLabel', device.label); + } + } + + // Restart volume monitoring with new device + await startVolumeMonitoring(); + + console.log('Selected audio device:', deviceId); + } + + // Start volume monitoring + async function startVolumeMonitoring() { + // Stop existing monitoring + stopVolumeMonitoring(); + + const deviceId = audioDeviceState.selectedDeviceId; + if (!deviceId && audioDeviceState.availableDevices.length === 0) { + return; + } + + try { + // Get audio stream + let constraints; + if (isAliasDeviceId(deviceId)) { + constraints = { audio: true }; + } else { + constraints = { audio: { deviceId: { exact: deviceId } } }; + } + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + audioDeviceState.monitoringStream = stream; + + // Create audio context and analyser + const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.3; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + audioDeviceState.monitoringContext = audioContext; + audioDeviceState.monitoringAnalyser = analyser; + audioDeviceState.isMonitoring = true; + + // Start animation loop for volume meter + updateVolumeMeter(); + + setAudioStatus('正在監聽...', 'success'); + + } catch (error) { + console.error('Failed to start volume monitoring:', error); + + if (error.name === 'NotAllowedError') { + setAudioStatus('麥克風權限被拒絕', 'error'); + } else if (error.name === 'NotReadableError') { + setAudioStatus('麥克風被其他應用程式佔用', 'error'); + } else { + setAudioStatus('無法存取麥克風', 'error'); + } + } + } + + // Stop volume monitoring + function stopVolumeMonitoring() { + if (audioDeviceState.animationFrameId) { + cancelAnimationFrame(audioDeviceState.animationFrameId); + audioDeviceState.animationFrameId = null; + } + + if (audioDeviceState.monitoringStream) { + audioDeviceState.monitoringStream.getTracks().forEach(track => track.stop()); + audioDeviceState.monitoringStream = null; + } + + if (audioDeviceState.monitoringContext) { + audioDeviceState.monitoringContext.close(); + audioDeviceState.monitoringContext = null; + } + + audioDeviceState.monitoringAnalyser = null; + audioDeviceState.isMonitoring = false; + + volumeMeterFill.style.width = '0%'; + volumeMeterText.textContent = '0%'; + } + + // Update volume meter (animation loop) + function updateVolumeMeter() { + if (!audioDeviceState.isMonitoring || !audioDeviceState.monitoringAnalyser) { + return; + } + + const analyser = audioDeviceState.monitoringAnalyser; + const dataArray = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(dataArray); + + // Calculate RMS (root mean square) for more accurate volume + let sum = 0; + for (let i = 0; i < dataArray.length; i++) { + sum += dataArray[i] * dataArray[i]; + } + const rms = Math.sqrt(sum / dataArray.length); + + // Normalize to 0-100 range + const level = Math.min(100, Math.round((rms / 128) * 100)); + + volumeMeterFill.style.width = level + '%'; + volumeMeterText.textContent = level + '%'; + + // Continue animation loop + audioDeviceState.animationFrameId = requestAnimationFrame(updateVolumeMeter); + } + + // Set audio status message + function setAudioStatus(message, type = '') { + audioStatusEl.textContent = message; + audioStatusEl.className = 'audio-status'; + if (type) { + audioStatusEl.classList.add(type); + } + } + + // Start test recording (5 seconds) + async function startTestRecording() { + if (audioDeviceState.testState !== 'idle') return; + + const deviceId = audioDeviceState.selectedDeviceId; + + try { + // Get audio stream + let constraints; + if (isAliasDeviceId(deviceId)) { + constraints = { audio: true }; + } else { + constraints = { audio: { deviceId: { exact: deviceId } } }; + } + + const stream = await navigator.mediaDevices.getUserMedia(constraints); + + // Create MediaRecorder + const mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm;codecs=opus' + }); + + const chunks = []; + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + mediaRecorder.onstop = () => { + // Create blob from chunks + audioDeviceState.testRecordingBlob = new Blob(chunks, { type: 'audio/webm' }); + stream.getTracks().forEach(track => track.stop()); + + // Update UI + audioDeviceState.testState = 'idle'; + testRecordBtn.textContent = '🎤 測試錄音'; + testRecordBtn.classList.remove('recording'); + testRecordBtn.disabled = false; + testPlayBtn.disabled = false; + setAudioStatus('錄音完成,可播放測試', 'success'); + + // Restart volume monitoring + startVolumeMonitoring(); + }; + + // Stop volume monitoring during test recording + stopVolumeMonitoring(); + + // Start recording + audioDeviceState.testMediaRecorder = mediaRecorder; + audioDeviceState.testState = 'recording'; + audioDeviceState.testCountdown = 5; + mediaRecorder.start(100); + + // Update UI + testRecordBtn.classList.add('recording'); + testPlayBtn.disabled = true; + updateTestRecordingCountdown(); + + // Auto-stop after 5 seconds + setTimeout(() => { + if (audioDeviceState.testState === 'recording') { + stopTestRecording(); + } + }, 5000); + + } catch (error) { + console.error('Failed to start test recording:', error); + + if (error.name === 'NotAllowedError') { + setAudioStatus('麥克風權限被拒絕', 'error'); + } else if (error.name === 'NotReadableError') { + setAudioStatus('麥克風被其他應用程式佔用', 'error'); + } else { + setAudioStatus('無法開始錄音: ' + error.message, 'error'); + } + + audioDeviceState.testState = 'idle'; + } + } + + // Update countdown during test recording + function updateTestRecordingCountdown() { + if (audioDeviceState.testState !== 'recording') return; + + testRecordBtn.textContent = `⏹️ 錄音中... ${audioDeviceState.testCountdown}s`; + setAudioStatus(`錄音中... ${audioDeviceState.testCountdown} 秒`, 'recording'); + + if (audioDeviceState.testCountdown > 0) { + audioDeviceState.testCountdown--; + setTimeout(updateTestRecordingCountdown, 1000); + } + } + + // Stop test recording + function stopTestRecording() { + if (audioDeviceState.testState !== 'recording') return; + + if (audioDeviceState.testMediaRecorder && audioDeviceState.testMediaRecorder.state !== 'inactive') { + audioDeviceState.testMediaRecorder.stop(); + } + } + + // Play test recording + function playTestRecording() { + if (!audioDeviceState.testRecordingBlob || audioDeviceState.testState !== 'idle') return; + + // Create audio element + const blobUrl = URL.createObjectURL(audioDeviceState.testRecordingBlob); + const audio = new Audio(blobUrl); + audioDeviceState.testAudioElement = audio; + + audio.onplay = () => { + audioDeviceState.testState = 'playing'; + testPlayBtn.textContent = '⏹️ 停止播放'; + testPlayBtn.classList.add('playing'); + testRecordBtn.disabled = true; + setAudioStatus('播放中...', 'success'); + }; + + audio.onended = () => { + audioDeviceState.testState = 'idle'; + testPlayBtn.textContent = '▶️ 播放測試'; + testPlayBtn.classList.remove('playing'); + testRecordBtn.disabled = false; + setAudioStatus('播放完成', 'success'); + URL.revokeObjectURL(blobUrl); + audioDeviceState.testAudioElement = null; + }; + + audio.onerror = () => { + audioDeviceState.testState = 'idle'; + testPlayBtn.textContent = '▶️ 播放測試'; + testPlayBtn.classList.remove('playing'); + testRecordBtn.disabled = false; + setAudioStatus('播放失敗', 'error'); + URL.revokeObjectURL(blobUrl); + audioDeviceState.testAudioElement = null; + }; + + audio.play(); + } + + // Stop test playback + function stopTestPlayback() { + if (audioDeviceState.testAudioElement) { + audioDeviceState.testAudioElement.pause(); + audioDeviceState.testAudioElement.currentTime = 0; + + // Trigger onended manually + audioDeviceState.testState = 'idle'; + testPlayBtn.textContent = '▶️ 播放測試'; + testPlayBtn.classList.remove('playing'); + testRecordBtn.disabled = false; + setAudioStatus('準備就緒', 'success'); + + if (audioDeviceState.testAudioElement.src) { + URL.revokeObjectURL(audioDeviceState.testAudioElement.src); + } + audioDeviceState.testAudioElement = null; + } + } + + // Toggle panel collapse + function toggleAudioDevicePanel() { + audioDevicePanel.classList.toggle('collapsed'); + const isCollapsed = audioDevicePanel.classList.contains('collapsed'); + localStorage.setItem('audioDevice.panelCollapsed', isCollapsed); + + if (isCollapsed) { + stopVolumeMonitoring(); + } else { + startVolumeMonitoring(); + } + } + + // Initialize audio device panel + async function initAudioDevicePanel() { + // Restore panel collapse state + const isCollapsed = localStorage.getItem('audioDevice.panelCollapsed') === 'true'; + if (isCollapsed) { + audioDevicePanel.classList.add('collapsed'); + } + + // Event listeners + audioDeviceHeader.addEventListener('click', toggleAudioDevicePanel); + + audioDeviceSelect.addEventListener('change', (e) => { + selectAudioDevice(e.target.value); + }); + + audioRefreshBtn.addEventListener('click', async () => { + setAudioStatus('重新整理中...', ''); + await enumerateAudioDevices(); + }); + + testRecordBtn.addEventListener('click', () => { + if (audioDeviceState.testState === 'idle') { + startTestRecording(); + } else if (audioDeviceState.testState === 'recording') { + stopTestRecording(); + } + }); + + testPlayBtn.addEventListener('click', () => { + if (audioDeviceState.testState === 'idle') { + playTestRecording(); + } else if (audioDeviceState.testState === 'playing') { + stopTestPlayback(); + } + }); + + // Listen for device changes (hot-plug) + navigator.mediaDevices.addEventListener('devicechange', () => { + console.log('Audio devices changed'); + enumerateAudioDevices(); + }); + + // Initial enumeration (only if panel is not collapsed) + if (!isCollapsed) { + await enumerateAudioDevices(); + } + } + + // Get selected device for main recording + function getSelectedAudioDevice() { + return audioDeviceState.selectedDeviceId; + } + + // Initialize audio device panel on page load + initAudioDevicePanel(); + + // ======================================== + // End Audio Device Management + // ======================================== + // Update Whisper status display async function updateWhisperStatus() { try { @@ -421,68 +1100,45 @@ return; } - // Debug: Check if we're in a secure context - console.log('isSecureContext:', window.isSecureContext); + // Stop volume monitoring during main recording + stopVolumeMonitoring(); - // Check for available audio devices first - const devices = await navigator.mediaDevices.enumerateDevices(); - const audioInputs = devices.filter(d => d.kind === 'audioinput'); - console.log('Available audio inputs:', audioInputs.length, audioInputs); + // Get selected device from audio device panel + const selectedDeviceId = getSelectedAudioDevice(); + console.log('Using selected audio device:', selectedDeviceId); - if (audioInputs.length === 0) { - alert('No microphone found. Please connect a microphone and try again.'); - return; - } - - // Helper to identify alias deviceIds (not real device IDs) - const isAlias = (id) => id === 'default' || id === 'communications'; - - // Filter out Stereo Mix (立體聲混音) - it's not a real microphone - const realMicrophones = audioInputs.filter(d => - !d.label.includes('立體聲混音') && - !d.label.toLowerCase().includes('stereo mix') - ); - - // Prefer actual device IDs (long strings), not aliases like 'default' or 'communications' - // First try to find a real microphone with actual deviceId - let selectedMic = realMicrophones.find(d => - !isAlias(d.deviceId) && ( - d.label.includes('麥克風') || - d.label.toLowerCase().includes('microphone') - ) - ) || realMicrophones.find(d => !isAlias(d.deviceId)) || realMicrophones[0]; - - console.log('Real microphones found:', realMicrophones.length); - console.log('Selected microphone:', selectedMic); - console.log('Selected deviceId is alias:', selectedMic ? isAlias(selectedMic.deviceId) : 'N/A'); - - if (!selectedMic) { - alert('No real microphone found. Only Stereo Mix detected. Please connect a microphone.'); - return; - } - - // Get microphone stream with proper constraints handling + // Get microphone stream with user-selected device try { - if (isAlias(selectedMic.deviceId)) { + let constraints; + if (isAliasDeviceId(selectedDeviceId)) { // For alias deviceIds (default/communications), let the system choose console.log('Using system default (alias detected)'); - mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } else { + constraints = { audio: true }; + } else if (selectedDeviceId) { // For real deviceIds, try exact first, then ideal as fallback - try { - mediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { deviceId: { exact: selectedMic.deviceId } } - }); - } catch (exactErr) { + constraints = { audio: { deviceId: { exact: selectedDeviceId } } }; + } else { + // No device selected, use default + constraints = { audio: true }; + } + + try { + mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (exactErr) { + if (selectedDeviceId && !isAliasDeviceId(selectedDeviceId)) { console.warn('Exact device ID failed, trying ideal:', exactErr); mediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { deviceId: { ideal: selectedMic.deviceId } } + audio: { deviceId: { ideal: selectedDeviceId } } }); + } else { + throw exactErr; } } - console.log('Successfully connected to:', selectedMic.label); + console.log('Successfully connected to microphone'); } catch (err) { console.error('getUserMedia failed:', err.name, err.message); + // Restart volume monitoring on error + startVolumeMonitoring(); throw err; // Let outer catch handle the error message } @@ -499,13 +1155,13 @@ } catch (error) { console.error('Start recording error:', error); - let errorMsg = 'Error starting recording: ' + error.message; + let errorMsg = '無法開始錄音: ' + error.message; if (error.name === 'NotAllowedError') { - errorMsg = 'Microphone access denied. Please grant permission and try again.'; + errorMsg = '麥克風權限被拒絕,請在系統設定中允許存取麥克風。'; } else if (error.name === 'NotFoundError') { - errorMsg = 'No microphone found. Please connect a microphone and try again.'; + errorMsg = '未偵測到麥克風,請連接麥克風後重試。'; } else if (error.name === 'NotReadableError') { - errorMsg = 'Microphone is in use by another application. Please close other apps using the microphone.'; + errorMsg = '麥克風正被其他應用程式使用,請關閉其他使用麥克風的程式後重試。'; } alert(errorMsg); await cleanupRecording(); @@ -640,6 +1296,11 @@ recordBtn.classList.add('btn-danger'); streamingStatusEl.classList.add('hidden'); processingIndicatorEl.classList.add('hidden'); + + // Restart volume monitoring after recording ends + if (!audioDevicePanel.classList.contains('collapsed')) { + startVolumeMonitoring(); + } } // === Audio File Upload ===