From d8947a835724bd385a72c7de150a8a7e7c8c87c1 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 15 Aug 2026 14:08:12 +0200 Subject: [PATCH] feat(voice): mic button in chat widget with press-and-hold recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Imports onCleanup, Mic, MicOff from solid-js / lucide-solid - [isRecording, isTranscribing] signals track state across the flow - startRecording(): requests getUserMedia, creates MediaRecorder (prefers audio/webm;codecs=opus, falls back to webm then ogg), starts recording - stopRecording(): stops MediaRecorder; onstop handler assembles Blob, POSTs to /api/ai/voice/transcribe as multipart 'audio' field - transcribeAudio(): sends the Blob, populates input() with the transcript, then auto-sends after 600ms so the user sees it before it flies - Mic button: grey at rest → red while recording → spinner while transcribing; uses onPointerDown/Up/Leave for reliable hold UX - Red recording hint bar below input with pulsing dot while active - onCleanup stops any in-progress MediaRecorder on widget unmount Co-Authored-By: Claude Sonnet 4.6 --- src/components/AiChatWidget.tsx | 138 +++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/src/components/AiChatWidget.tsx b/src/components/AiChatWidget.tsx index 2842bef..e8cc5e8 100644 --- a/src/components/AiChatWidget.tsx +++ b/src/components/AiChatWidget.tsx @@ -1,5 +1,5 @@ -import { createSignal, Show, For, onMount } from "solid-js"; -import { MessageCircle, X, Send, Bot, User, Loader } from "lucide-solid"; +import { createSignal, Show, For, onMount, onCleanup } from "solid-js"; +import { MessageCircle, X, Send, Bot, User, Loader, Mic, MicOff } from "lucide-solid"; const API = "/api"; @@ -110,6 +110,82 @@ export function AiChatWidget() { const [conversationId, setConversationId] = createSignal(""); const [usage, setUsage] = createSignal(null); + // ── Voice recording state ───────────────────────────────────────────────── + const [isRecording, setIsRecording] = createSignal(false); + const [isTranscribing, setIsTranscribing] = createSignal(false); + let mediaRecorder: MediaRecorder | null = null; + let recordedChunks: BlobPart[] = []; + + onCleanup(() => { + if (mediaRecorder && mediaRecorder.state !== "inactive") { + mediaRecorder.stop(); + } + }); + + const startRecording = async () => { + if (isRecording() || isTranscribing()) return; + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + recordedChunks = []; + const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") + ? "audio/webm;codecs=opus" + : MediaRecorder.isTypeSupported("audio/webm") + ? "audio/webm" + : "audio/ogg"; + mediaRecorder = new MediaRecorder(stream, { mimeType }); + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) recordedChunks.push(e.data); + }; + mediaRecorder.onstop = async () => { + // Stop all tracks so the mic indicator goes away + stream.getTracks().forEach((t) => t.stop()); + const blob = new Blob(recordedChunks, { type: mimeType }); + if (blob.size < 100) return; // too small — likely empty + await transcribeAudio(blob, mimeType); + }; + mediaRecorder.start(); + setIsRecording(true); + } catch { + // Mic permission denied or not available — fail silently + setIsRecording(false); + } + }; + + const stopRecording = () => { + if (!isRecording() || !mediaRecorder) return; + setIsRecording(false); + mediaRecorder.stop(); + }; + + const transcribeAudio = async (blob: Blob, mimeType: string) => { + setIsTranscribing(true); + try { + const ext = mimeType.includes("ogg") ? "audio.ogg" : "audio.webm"; + const formData = new FormData(); + formData.append("audio", blob, ext); + const res = await fetch(`${API}/ai/voice/transcribe`, { + method: "POST", + headers: { + Authorization: `Bearer ${sessionStorage.getItem("nxtgauge_access_token") || ""}`, + }, + credentials: "include", + body: formData, + }); + if (!res.ok) return; + const data = await res.json(); + const text: string = data.transcript || ""; + if (text.trim()) { + setInput(text.trim()); + // Auto-send after a short pause so the user can see the text first + setTimeout(() => sendMessage(), 600); + } + } catch { + // Transcription failed — leave input as-is + } finally { + setIsTranscribing(false); + } + }; + onMount(() => { const hasToken = typeof window !== "undefined" && !!sessionStorage.getItem("nxtgauge_access_token"); if (hasToken) fetchUsage(); @@ -696,6 +772,41 @@ export function AiChatWidget() { outline: "none", }} /> + {/* Mic button — hold to record, release to transcribe */} + + + + {/* Recording hint bar */} + +
+ + Recording… release to transcribe +
+
@@ -723,6 +853,10 @@ export function AiChatWidget() { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } `} );