Workflow-App/Workflow/components/VoiceField.tsx

295 lines
12 KiB
TypeScript
Raw Normal View History

// components/VoiceField.tsx
// Drop-in widget that wraps a textarea with:
// • push-to-talk mic button (live transcription via Web Speech API)
// • speak-back 🔊 button (manual narration of the current text)
// • speak-back mute toggle (persisted to localStorage)
// • modal symmetry: tracks whether the user is currently in "voice" or
// "text" mode and notifies the parent via onInputModeChange so a sibling
// agent-reply component can auto-speak its reply when the user spoke
//
// Designed to slot into any Ysterbaard wizard form. Controlled component
// — value + onChange behave like a normal <textarea>.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSpeech } from "@/lib/voice/useSpeech";
import { useVoiceInput } from "@/lib/voice/useVoiceInput";
/** How the most recent user input was produced. */
export type InputMode = "voice" | "text";
interface Props {
/** Current text value (controlled). */
value: string;
/** Change handler — receives the full new value. */
onChange: (next: string) => void;
/** Optional id for label association. */
id?: string;
/** Placeholder text. */
placeholder?: string;
/** Optional label rendered above the textarea. */
label?: string;
/** Number of visible rows. */
rows?: number;
/** BCP-47 lang for both STT and TTS. Defaults to en-ZA. */
lang?: string;
/** Optional secondary hint rendered next to the label. */
hint?: string;
/** Whether to render the speak-back 🔊 button. Default true. */
enableSpeakBack?: boolean;
/** Show a small "speaking…" indicator. Default true. */
showStatus?: boolean;
/** Extra className on the wrapping <div>. */
className?: string;
/**
* Fired whenever the input mode flips (voice ↔ text). Lets a parent
* agent-reply component know whether to auto-speak its reply.
*/
onInputModeChange?: (mode: InputMode) => void;
}
const SPEAK_BACK_STORAGE_KEY = "wf:speakBackEnabled";
/**
* VoiceField — a textarea with push-to-talk transcription and optional
* text-to-speech readout. Modal symmetry is preserved: when the user
* uses voice, callers can react via onInputModeChange("voice") and
* auto-speak their reply; when the user types, the same callback fires
* with "text" and the caller stays silent.
*
* Accessibility:
* • Mic button has aria-label that toggles between "Start voice input"
* and "Stop voice input"
* • Speaker button has aria-pressed reflecting its on/off state
* • Live regions announce errors politely
*/
export default function VoiceField({
value,
onChange,
id,
placeholder,
label,
hint,
rows = 3,
lang = "en-ZA",
enableSpeakBack = true,
showStatus = true,
className = "",
onInputModeChange
}: Props) {
const voice = useVoiceInput({ lang, append: true });
const speech = useSpeech({ lang });
// Persisted on/off for the speak-back feature.
const [speakBackEnabled, setSpeakBackEnabled] = useState<boolean>(true);
useEffect(() => {
try {
const stored = window.localStorage.getItem(SPEAK_BACK_STORAGE_KEY);
if (stored !== null) {
setSpeakBackEnabled(stored === "1");
}
} catch {
/* SSR / private mode — ignore */
}
}, []);
// Track the *current* mode — voice flips true on mic start, text flips
// true on the first keystroke after a voice session. We don't fire the
// callback on every transient state; we fire when the mode *settles*
// (when voice.stop is called, or when typing follows silence).
const [inputMode, setInputMode] = useState<InputMode>("text");
const lastNotifiedModeRef = useRef<InputMode>("text");
// When the user starts listening, flip to voice and notify.
useEffect(() => {
if (voice.listening && lastNotifiedModeRef.current !== "voice") {
lastNotifiedModeRef.current = "voice";
setInputMode("voice");
onInputModeChange?.("voice");
}
}, [voice.listening, onInputModeChange]);
// When the user starts typing (and we're not currently listening),
// flip back to text and notify.
const handleChange = useCallback(
(next: string) => {
if (!voice.listening && lastNotifiedModeRef.current !== "text") {
lastNotifiedModeRef.current = "text";
setInputMode("text");
onInputModeChange?.("text");
}
onChange(next);
},
[voice.listening, onChange, onInputModeChange]
);
const toggleSpeakBack = useCallback(() => {
setSpeakBackEnabled((prev) => {
const next = !prev;
try {
window.localStorage.setItem(SPEAK_BACK_STORAGE_KEY, next ? "1" : "0");
} catch {
/* ignore */
}
if (!next) speech.cancel();
return next;
});
}, [speech]);
// Compose live text = persisted text + interim. Interim is *displayed*
// but not yet committed to onChange — when the user pauses, onresult
// fires with isFinal and onChange gets called.
const liveValue = value + (voice.interim ? (value && !value.endsWith(" ") ? " " : "") + voice.interim : "");
// Commit the interim/final transcript into the controlled value on demand,
// e.g. when the user clicks "use transcript" or when the form is submitted.
const applyVoiceTranscript = useCallback(() => {
const next = (value ? value.trim() + " " : "") + voice.transcript.trim();
onChange(next.trim());
voice.reset();
}, [value, voice, onChange]);
// Manual replay — what 🔊 does. The parent decides whether to auto-fire
// this when inputMode === "voice" by wiring speak() into its agent reply.
const replay = useCallback(() => {
if (value.trim()) speech.speak(value);
}, [speech, value]);
return (
<div className={className} data-od-input-mode={inputMode}>
{label && (
<label htmlFor={id} className="mb-1 block text-sm font-medium text-neutral-700">
{label}
{hint && <span className="ml-2 text-xs font-normal text-neutral-500">{hint}</span>}
{inputMode === "voice" && (
<span className="ml-2 inline-flex items-center gap-1 rounded-full bg-rose-100 px-2 py-0.5 text-xs font-medium text-rose-700">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" /> voice mode
</span>
)}
</label>
)}
<div className="relative">
<textarea
id={id}
value={liveValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
rows={rows}
className="w-full resize-y rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-24 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-1 focus:ring-rose-500"
aria-describedby={id ? `${id}-status` : undefined}
/>
{/* Inline buttons: mic + speaker + mute */}
<div className="absolute bottom-2 right-2 flex items-center gap-1">
{voice.supported && (
<button
type="button"
onClick={voice.listening ? voice.stop : voice.start}
aria-label={voice.listening ? "Stop voice input" : "Start voice input"}
title={voice.listening ? "Stop voice input" : "Start voice input"}
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
voice.listening
? "bg-rose-500 text-white animate-pulse"
: "bg-neutral-100 text-neutral-700 hover:bg-neutral-200"
}`}
>
{/* mic icon */}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M19 10a7 7 0 0 1-14 0" />
<line x1="12" y1="17" x2="12" y2="22" />
</svg>
</button>
)}
{enableSpeakBack && speech.supported && value.trim().length > 0 && (
<button
type="button"
onClick={replay}
aria-label={speech.speaking ? "Stop speaking" : "Speak this text aloud"}
title={speech.speaking ? "Stop speaking" : "Speak this text aloud"}
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
speech.speaking
? "bg-neutral-900 text-white"
: "bg-neutral-100 text-neutral-700 hover:bg-neutral-200"
}`}
>
{/* speaker icon */}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</button>
)}
{enableSpeakBack && (
<button
type="button"
onClick={toggleSpeakBack}
aria-pressed={speakBackEnabled}
aria-label={speakBackEnabled ? "Mute speak-back" : "Unmute speak-back"}
title={speakBackEnabled ? "Speak-back on" : "Speak-back off"}
className={`flex h-8 w-8 items-center justify-center rounded-full transition ${
speakBackEnabled
? "bg-emerald-100 text-emerald-700"
: "bg-neutral-100 text-neutral-400"
}`}
>
{speakBackEnabled ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4V5z" />
<line x1="22" y1="9" x2="16" y2="15" />
<line x1="16" y1="9" x2="22" y2="15" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 5 6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
)}
</button>
)}
</div>
</div>
{showStatus && (
<div id={id ? `${id}-status` : undefined} className="mt-1 text-xs text-neutral-500" aria-live="polite">
{voice.listening && <span className="text-rose-600">● listening — speak now…</span>}
{!voice.listening && voice.transcript && (
<span>
captured: <span className="italic">&ldquo;{voice.transcript}&rdquo;</span>{" "}
<button
type="button"
onClick={applyVoiceTranscript}
className="ml-1 text-rose-600 hover:underline"
>
apply to field
</button>
<button type="button" onClick={voice.reset} className="ml-2 text-neutral-500 hover:underline">
discard
</button>
</span>
)}
{!voice.listening && speech.speaking && <span className="ml-2 text-neutral-700">🔊 speaking…</span>}
{voice.error && (
<span className="ml-2 text-amber-600">voice input error: {voice.error}</span>
)}
{speech.error && (
<span className="ml-2 text-amber-600">speech error: {speech.error}</span>
)}
{!voice.supported && (
<span className="ml-2 text-neutral-400">
(voice input not supported in this browser)
</span>
)}
</div>
)}
</div>
);
}