feat(voice): mic button in chat widget with press-and-hold recording
All checks were successful
build-and-release / build (push) Successful in 1m52s

- 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 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-15 14:08:12 +02:00
parent ff2dde1900
commit d8947a8357

View file

@ -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<UsageStatus | null>(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 */}
<button
onPointerDown={startRecording}
onPointerUp={stopRecording}
onPointerLeave={stopRecording}
disabled={isLoading() || isTranscribing()}
aria-label={isRecording() ? "Stop recording" : "Hold to record voice message"}
title={isRecording() ? "Release to send" : "Hold to record"}
style={{
width: "40px",
height: "40px",
"border-radius": "50%",
background: isRecording()
? "#EF4444"
: isTranscribing()
? "#E5E7EB"
: "#F3F4F6",
border: isRecording() ? "2px solid #FCA5A5" : "1px solid #E5E7EB",
cursor: isLoading() || isTranscribing() ? "default" : "pointer",
display: "flex",
"align-items": "center",
"justify-content": "center",
transition: "background 0.15s, border 0.15s",
"flex-shrink": "0",
}}
>
<Show when={isTranscribing()} fallback={
<Show when={isRecording()} fallback={<Mic size={16} color="#6B7280" />}>
<MicOff size={16} color="#fff" />
</Show>
}>
<Loader size={14} color="#9CA3AF" style={{ animation: "spin 1s linear infinite" }} />
</Show>
</button>
<button
onClick={sendMessage}
disabled={isLoading() || !input().trim()}
@ -710,11 +821,30 @@ export function AiChatWidget() {
display: "flex",
"align-items": "center",
"justify-content": "center",
"flex-shrink": "0",
}}
>
<Send size={16} color="#fff" />
</button>
</div>
{/* Recording hint bar */}
<Show when={isRecording()}>
<div style={{
padding: "6px 16px",
background: "#FEF2F2",
"border-top": "1px solid #FECACA",
display: "flex",
"align-items": "center",
gap: "8px",
"font-size": "11px",
color: "#EF4444",
"font-weight": "600",
}}>
<span style={{ width: "8px", height: "8px", "border-radius": "50%", background: "#EF4444", display: "inline-block", animation: "pulse 1s ease-in-out infinite" }} />
Recording release to transcribe
</div>
</Show>
</div>
</Show>
@ -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; }
}
`}</style>
</>
);