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;
|
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
|
||||||
|
constraints = { audio: { deviceId: { exact: selectedDeviceId } } };
|
||||||
|
} else {
|
||||||
|
// No device selected, use default
|
||||||
|
constraints = { audio: true };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
audio: { deviceId: { exact: selectedMic.deviceId } }
|
|
||||||
});
|
|
||||||
} catch (exactErr) {
|
} 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 ===
|
||||||
|
|||||||
Reference in New Issue
Block a user