feat(speech): make prompt input push to talk

This commit is contained in:
Shantur Rathore
2026-03-24 22:42:27 +00:00
parent 4e576829b7
commit bf07904789
3 changed files with 90 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Square } from "lucide-solid" import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
import UnifiedPicker from "./unified-picker" import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
@@ -425,6 +425,32 @@ export default function PromptInput(props: PromptInputProps) {
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return ( return (
<div class="prompt-input-container"> <div class="prompt-input-container">
<div <div
@@ -570,25 +596,43 @@ export default function PromptInput(props: PromptInputProps) {
<button <button
type="button" type="button"
class={`prompt-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`} class={`prompt-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onClick={() => void voiceInput.toggleRecording()} onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())} disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()} aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()} title={voiceInput.buttonTitle()}
> >
<Show <Show
when={voiceInput.isTranscribing()} when={voiceInput.isRecording()}
fallback={ fallback={
<Show when={voiceInput.isRecording()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}> <Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Square class="h-4 w-4" aria-hidden="true" /> <Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show> </Show>
} }
> >
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" /> <span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show> </Show>
</button> </button>
<Show when={voiceInput.isRecording()}>
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show>
</Show> </Show>
<button <button
type="button" type="button"

View File

@@ -56,18 +56,7 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
return return
} }
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing") return await startRecording()
try {
await startRecording()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
} }
function stopRecording() { function stopRecording() {
@@ -86,6 +75,8 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
} }
async function startRecording() { async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) { if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), { showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"), title: t("promptInput.voiceInput.error.title"),
@@ -94,26 +85,35 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
return return
} }
recordedChunks = [] try {
shouldTranscribe = true recordedChunks = []
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }) shouldTranscribe = true
mediaRecorder = createRecorder(mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = createRecorder(mediaStream)
mediaRecorder.addEventListener("dataavailable", (event) => { mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) { if (event.data.size > 0) {
recordedChunks.push(event.data) recordedChunks.push(event.data)
} }
}) })
mediaRecorder.addEventListener("stop", () => { mediaRecorder.addEventListener("stop", () => {
void finalizeRecording() void finalizeRecording()
}) })
recordingStartedAt = Date.now() recordingStartedAt = Date.now()
setElapsedMs(0) setElapsedMs(0)
setState("recording") setState("recording")
startTimer() startTimer()
mediaRecorder.start() mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
} }
async function finalizeRecording() { async function finalizeRecording() {
@@ -209,6 +209,8 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
state, state,
elapsedMs, elapsedMs,
canUseVoiceInput, canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording, toggleRecording,
cancelRecording, cancelRecording,
isRecording: () => state() === "recording", isRecording: () => state() === "recording",

View File

@@ -171,7 +171,8 @@
} }
.prompt-voice-button { .prompt-voice-button {
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0; @apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
min-width: 2.5rem;
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base)); background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -187,6 +188,7 @@
} }
.prompt-voice-button.is-recording { .prompt-voice-button.is-recording {
min-width: 3.5rem;
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%); background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
color: var(--button-danger-text, var(--text-inverted, #ffffff)); color: var(--button-danger-text, var(--text-inverted, #ffffff));
} }
@@ -197,8 +199,10 @@
.prompt-voice-timer { .prompt-voice-timer {
font-size: 0.68rem; font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1; line-height: 1;
color: var(--text-muted); color: currentColor;
} }
.stop-button:hover:not(:disabled) { .stop-button:hover:not(:disabled) {