feat(speech): make prompt input push to talk
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user