feat: Add audio device selector and test recording panel

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 <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-22 08:40:44 +08:00
parent 6112799c79
commit c36f4167f2

View File

@@ -139,6 +139,172 @@
border-color: #2196F3; border-color: #2196F3;
box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); 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;
}
</style> </style>
</head> </head>
<body> <body>
@@ -164,6 +330,47 @@
</div> </div>
</div> </div>
<!-- Audio Device Settings Panel -->
<div id="audio-device-panel" class="audio-device-panel">
<div class="audio-device-header" id="audio-device-header">
<h3>音訊設備設定</h3>
<span class="audio-device-toggle"></span>
</div>
<div class="audio-device-body">
<!-- Device Selection Row -->
<div class="audio-device-row">
<span class="audio-device-label">麥克風:</span>
<select id="audio-device-select" class="audio-device-select">
<option value="">載入中...</option>
</select>
<button id="audio-refresh-btn" class="audio-refresh-btn" title="重新整理設備清單">🔄</button>
</div>
<!-- Volume Meter Row -->
<div class="audio-device-row">
<span class="audio-device-label">輸入音量:</span>
<div class="volume-meter-container">
<div class="volume-meter">
<div id="volume-meter-fill" class="volume-meter-fill"></div>
</div>
<span id="volume-meter-text" class="volume-meter-text">0%</span>
</div>
</div>
<!-- Test Controls Row -->
<div class="audio-device-row">
<span class="audio-device-label">收音測試:</span>
<div class="audio-test-controls">
<button id="test-record-btn" class="audio-test-btn record" title="錄製 5 秒測試音訊">
🎤 測試錄音
</button>
<button id="test-play-btn" class="audio-test-btn play" disabled title="播放測試錄音">
▶️ 播放測試
</button>
<span id="audio-status" class="audio-status">準備就緒</span>
</div>
</div>
</div>
</div>
<!-- Dual Panel Layout --> <!-- Dual Panel Layout -->
<div class="dual-panel"> <div class="dual-panel">
<!-- Left Panel: Transcript --> <!-- Left Panel: Transcript -->
@@ -284,6 +491,478 @@
const uploadProgressFill = document.getElementById('upload-progress-fill'); const uploadProgressFill = document.getElementById('upload-progress-fill');
const whisperStatusEl = document.getElementById('whisper-status'); 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 = '<option value="">未偵測到麥克風</option>';
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 = '<option value="">無法存取麥克風</option>';
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 // Update Whisper status display
async function updateWhisperStatus() { async function updateWhisperStatus() {
try { try {
@@ -421,68 +1100,45 @@
return; return;
} }
// Debug: Check if we're in a secure context // Stop volume monitoring during main recording
console.log('isSecureContext:', window.isSecureContext); stopVolumeMonitoring();
// Check for available audio devices first // Get selected device from audio device panel
const devices = await navigator.mediaDevices.enumerateDevices(); const selectedDeviceId = getSelectedAudioDevice();
const audioInputs = devices.filter(d => d.kind === 'audioinput'); console.log('Using selected audio device:', selectedDeviceId);
console.log('Available audio inputs:', audioInputs.length, audioInputs);
if (audioInputs.length === 0) { // Get microphone stream with user-selected device
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
try { try {
if (isAlias(selectedMic.deviceId)) { let constraints;
if (isAliasDeviceId(selectedDeviceId)) {
// For alias deviceIds (default/communications), let the system choose // For alias deviceIds (default/communications), let the system choose
console.log('Using system default (alias detected)'); console.log('Using system default (alias detected)');
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); constraints = { audio: true };
} else { } else if (selectedDeviceId) {
// For real deviceIds, try exact first, then ideal as fallback // For real deviceIds, try exact first, then ideal as fallback
try { constraints = { audio: { deviceId: { exact: selectedDeviceId } } };
mediaStream = await navigator.mediaDevices.getUserMedia({ } else {
audio: { deviceId: { exact: selectedMic.deviceId } } // No device selected, use default
}); constraints = { audio: true };
} catch (exactErr) { }
try {
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (exactErr) {
if (selectedDeviceId && !isAliasDeviceId(selectedDeviceId)) {
console.warn('Exact device ID failed, trying ideal:', exactErr); console.warn('Exact device ID failed, trying ideal:', exactErr);
mediaStream = await navigator.mediaDevices.getUserMedia({ 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) { } catch (err) {
console.error('getUserMedia failed:', err.name, err.message); console.error('getUserMedia failed:', err.name, err.message);
// Restart volume monitoring on error
startVolumeMonitoring();
throw err; // Let outer catch handle the error message throw err; // Let outer catch handle the error message
} }
@@ -499,13 +1155,13 @@
} catch (error) { } catch (error) {
console.error('Start recording error:', error); console.error('Start recording error:', error);
let errorMsg = 'Error starting recording: ' + error.message; let errorMsg = '無法開始錄音: ' + error.message;
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError') {
errorMsg = 'Microphone access denied. Please grant permission and try again.'; errorMsg = '麥克風權限被拒絕,請在系統設定中允許存取麥克風。';
} else if (error.name === 'NotFoundError') { } else if (error.name === 'NotFoundError') {
errorMsg = 'No microphone found. Please connect a microphone and try again.'; errorMsg = '未偵測到麥克風,請連接麥克風後重試。';
} else if (error.name === 'NotReadableError') { } else if (error.name === 'NotReadableError') {
errorMsg = 'Microphone is in use by another application. Please close other apps using the microphone.'; errorMsg = '麥克風正被其他應用程式使用,請關閉其他使用麥克風的程式後重試。';
} }
alert(errorMsg); alert(errorMsg);
await cleanupRecording(); await cleanupRecording();
@@ -640,6 +1296,11 @@
recordBtn.classList.add('btn-danger'); recordBtn.classList.add('btn-danger');
streamingStatusEl.classList.add('hidden'); streamingStatusEl.classList.add('hidden');
processingIndicatorEl.classList.add('hidden'); processingIndicatorEl.classList.add('hidden');
// Restart volume monitoring after recording ends
if (!audioDevicePanel.classList.contains('collapsed')) {
startVolumeMonitoring();
}
} }
// === Audio File Upload === // === Audio File Upload ===