diff --git a/frontend/src/components/FloatingTerminal.vue b/frontend/src/components/FloatingTerminal.vue index 538e25c..b82d310 100644 --- a/frontend/src/components/FloatingTerminal.vue +++ b/frontend/src/components/FloatingTerminal.vue @@ -384,7 +384,19 @@ function requestToken() { if (socket && socket.readyState === WebSocket.OPEN) { tokenBuffer = '' waitingForToken.value = true - socket.send(JSON.stringify({ type: 'input', data: 'genera token usando tu mcp\r' })) + + // Send character by character then Enter (same as VoiceFloat) + const text = 'genera token usando tu mcp' + const chars = (text + '\r').split('') + let i = 0 + const typeChar = () => { + if (i < chars.length && socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'input', data: chars[i] })) + i++ + setTimeout(typeChar, 15) + } + } + typeChar() } } diff --git a/frontend/src/components/FloatingVoice.vue b/frontend/src/components/FloatingVoice.vue index 232ae1d..52a2599 100644 --- a/frontend/src/components/FloatingVoice.vue +++ b/frontend/src/components/FloatingVoice.vue @@ -62,6 +62,86 @@ let chunkInterval: number | null = null const CHUNK_INTERVAL_MS = 3000 // Send audio every 3 seconds let mediaStream: MediaStream | null = null +// ============ MICROPHONE SELECTION ============ +const audioDevices = ref([]) +const selectedDeviceId = ref('') +const showMicSelector = ref(false) + +// ============ AUDIO PLAYBACK (DEBUG) ============ +const lastAudioUrl = ref('') +const isPlayingAudio = ref(false) +let audioElement: HTMLAudioElement | null = null + +function playLastAudio() { + if (!lastAudioUrl.value) return + + if (isPlayingAudio.value && audioElement) { + audioElement.pause() + audioElement.currentTime = 0 + isPlayingAudio.value = false + return + } + + audioElement = new Audio(lastAudioUrl.value) + audioElement.onplay = () => { isPlayingAudio.value = true } + audioElement.onended = () => { isPlayingAudio.value = false } + audioElement.onpause = () => { isPlayingAudio.value = false } + audioElement.play().catch(e => { + console.error('[Voice] Failed to play audio:', e) + isPlayingAudio.value = false + }) +} + +function saveAudioForPlayback(blob: Blob) { + // Revoke previous URL to free memory + if (lastAudioUrl.value) { + URL.revokeObjectURL(lastAudioUrl.value) + } + lastAudioUrl.value = URL.createObjectURL(blob) +} + +const currentMicName = computed(() => { + if (!selectedDeviceId.value) return 'Default' + const device = audioDevices.value.find(d => d.deviceId === selectedDeviceId.value) + return device?.label || 'Microphone' +}) + +async function loadAudioDevices() { + try { + // Request permission first to get device labels + const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true }) + tempStream.getTracks().forEach(track => track.stop()) + + const devices = await navigator.mediaDevices.enumerateDevices() + audioDevices.value = devices.filter(d => d.kind === 'audioinput') + + // Set default device if none selected + if (!selectedDeviceId.value && audioDevices.value.length > 0) { + selectedDeviceId.value = audioDevices.value[0]?.deviceId || '' + } + } catch (e) { + console.error('[Voice] Failed to enumerate devices:', e) + } +} + +function selectMicrophone(deviceId: string) { + selectedDeviceId.value = deviceId + showMicSelector.value = false + + // If currently recording, restart with new device + if (isRecording.value) { + stopRecording() + setTimeout(() => startRecording(), 100) + } +} + +function closeMicSelector(e: MouseEvent) { + const target = e.target as HTMLElement + if (!target.closest('.mic-bar') && !target.closest('.mic-dropdown')) { + showMicSelector.value = false + } +} + const displayText = computed(() => { if (interimTranscript.value) { return transcript.value + ' ' + interimTranscript.value @@ -334,7 +414,10 @@ function disconnectWhisperSocket() { async function startWhisperRecording() { try { - mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) + const audioConstraints: MediaTrackConstraints = selectedDeviceId.value + ? { deviceId: { exact: selectedDeviceId.value } } + : {} + mediaStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }) mediaRecorder = new MediaRecorder(mediaStream, { mimeType: 'audio/webm;codecs=opus' @@ -389,6 +472,8 @@ function sendAudioChunk(isFinal: boolean) { if (isFinal) { audioChunks = [] lastTranscriptLength = 0 + // Save audio for playback debugging + saveAudioForPlayback(audioBlob) } const reader = new FileReader() @@ -743,6 +828,9 @@ onMounted(async () => { document.addEventListener('keydown', handleKeyDown, { capture: true }) document.addEventListener('keyup', handleKeyUp, { capture: true }) + // Load available audio devices + await loadAudioDevices() + // Check Whisper status on mount const status = await checkWhisperStatus() @@ -765,10 +853,19 @@ onBeforeUnmount(() => { if (mediaStream) { mediaStream.getTracks().forEach(track => track.stop()) } + // Clean up audio playback + if (audioElement) { + audioElement.pause() + audioElement = null + } + if (lastAudioUrl.value) { + URL.revokeObjectURL(lastAudioUrl.value) + } document.removeEventListener('keydown', handleKeyDown, { capture: true }) document.removeEventListener('keyup', handleKeyUp, { capture: true }) document.removeEventListener('mousemove', onDrag) document.removeEventListener('mouseup', stopDrag) + document.removeEventListener('click', closeMicSelector) if (holdTimeout) clearTimeout(holdTimeout) }) @@ -776,8 +873,11 @@ onBeforeUnmount(() => { watch(isOpen, (open) => { if (open) { connectSocket() + document.addEventListener('click', closeMicSelector) } else { disconnectSocket() + showMicSelector.value = false + document.removeEventListener('click', closeMicSelector) } }) @@ -840,6 +940,46 @@ defineExpose({ + +
+ + + + {{ useWhisper ? currentMicName : 'System Default' }} + + (Web API) +
+ + + +
+
+ + + + {{ device.label || `Microphone ${audioDevices.indexOf(device) + 1}` }} +
+
+ No microphones found +
+
+
+
@@ -879,6 +1019,22 @@ defineExpose({ + +