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 { ArrowBigUp, ArrowBigDown, Loader2, Mic, Square } from "lucide-solid"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
@@ -425,6 +425,32 @@ export default function PromptInput(props: PromptInputProps) {
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 (
<div class="prompt-input-container">
<div
@@ -570,25 +596,43 @@ export default function PromptInput(props: PromptInputProps) {
<button
type="button"
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())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isTranscribing()}
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isRecording()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Square class="h-4 w-4" aria-hidden="true" />
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show>
</button>
<Show when={voiceInput.isRecording()}>
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show>
</Show>
<button
type="button"

View File

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

View File

@@ -171,7 +171,8 @@
}
.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));
color: var(--text-secondary);
}
@@ -187,6 +188,7 @@
}
.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%);
color: var(--button-danger-text, var(--text-inverted, #ffffff));
}
@@ -197,8 +199,10 @@
.prompt-voice-timer {
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1;
color: var(--text-muted);
color: currentColor;
}
.stop-button:hover:not(:disabled) {