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:
@@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -164,6 +330,47 @@
|
||||
</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 -->
|
||||
<div class="dual-panel">
|
||||
<!-- Left Panel: Transcript -->
|
||||
@@ -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 = '<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
|
||||
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 ===
|
||||
|
||||
Reference in New Issue
Block a user