← snapshot
7286 bytes
"use client";
import { useEffect, useRef, useState } from "react";
import { StatementTextarea, statementStatus } from "./statement-textarea";
/**
* Speak it, or type it — either way, it's your verbatim account, yours to
* edit before it's sealed.
*
* Defaults to recording via the browser's Web Speech API
* (`SpeechRecognition` / `webkitSpeechRecognition`): live interim results
* are shown dimmed, finalized text accumulates into the same editable
* transcript. Nothing about the data model changes — the transcript is a
* plain string, validated exactly like a typed statement, and the human can
* edit it before submitting. A "Type it instead" toggle reaches the
* unchanged textarea directly, and browsers without speech recognition
* (Safari, Firefox) land there automatically, with one honest line saying
* why — not a mic button that quietly does nothing.
*/
export function VoiceStatementInput({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) {
const [supported, setSupported] = useState<boolean | null>(null);
const [typing, setTyping] = useState(false);
const [recording, setRecording] = useState(false);
const [interim, setInterim] = useState("");
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
const baseRef = useRef(value);
useEffect(() => {
const Ctor =
(window as WindowWithSpeech).SpeechRecognition ??
(window as WindowWithSpeech).webkitSpeechRecognition;
setSupported(Boolean(Ctor));
}, []);
function start() {
const Ctor =
(window as WindowWithSpeech).SpeechRecognition ??
(window as WindowWithSpeech).webkitSpeechRecognition;
if (!Ctor) return;
setError(null);
baseRef.current = value;
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "en-US";
recognition.onresult = (event: SpeechRecognitionEventLike) => {
let finalText = "";
let interimText = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
const transcript = result[0]?.transcript ?? "";
if (result.isFinal) finalText += transcript;
else interimText += transcript;
}
if (finalText) {
const joined = [baseRef.current, finalText.trim()].filter(Boolean).join(" ");
baseRef.current = joined;
onChange(joined);
}
setInterim(interimText);
};
recognition.onerror = (event: { error: string }) => {
const reason =
event.error === "not-allowed" || event.error === "permission-denied"
? "Microphone access was denied."
: event.error === "no-speech"
? "No speech was detected."
: event.error === "network"
? "A network error interrupted recognition."
: `Recognition stopped (${event.error}).`;
setError(reason);
setRecording(false);
};
recognition.onend = () => {
setRecording(false);
setInterim("");
};
recognitionRef.current = recognition;
recognition.start();
setRecording(true);
}
function stop() {
recognitionRef.current?.stop();
}
useEffect(
() => () => {
recognitionRef.current?.stop();
},
[],
);
if (supported === null) {
// Feature-detecting on the client only; render nothing that would flash.
return <div className="jac-voice" style={{ minHeight: 96 }} />;
}
if (!supported || typing) {
return (
<div>
<StatementTextarea value={value} onChange={onChange} />
{!supported ? (
<p className="jac-hint" style={{ marginTop: 8 }}>
This browser doesn't expose speech recognition — Chrome and
Edge do, Safari and Firefox don't yet. Typing works exactly the
same either way.
</p>
) : (
<button
type="button"
className="jac-type-instead"
style={{ marginTop: 8 }}
onClick={() => setTyping(false)}
>
Speak it instead
</button>
)}
</div>
);
}
const status = statementStatus(value);
const hasText = value.trim().length > 0;
return (
<div className="jac-voice">
<div className="jac-voice-row">
<button
type="button"
className="jac-mic-btn"
data-recording={recording}
onClick={recording ? stop : start}
aria-label={recording ? "Stop recording" : "Start recording"}
>
{recording ? "■" : "🎙"}
</button>
<span className="jac-voice-status">
{recording
? "Listening — speak your statement, then stop when you're done."
: hasText
? "Recorded. Review below, or record again to add more."
: "Speak it, or type it — either way, it's your verbatim account, yours to edit before it's sealed."}
</span>
</div>
{error ? <div className="jac-hint jac-hint--error">{error}</div> : null}
<div
className="jac-voice-transcript"
data-empty={!hasText && !interim}
aria-live="polite"
>
{hasText || interim ? (
<>
{value}
{interim ? <span className="jac-voice-interim"> {interim}</span> : null}
</>
) : (
"your transcript will appear here"
)}
</div>
{hasText ? (
<div className="jac-hint" style={{ display: "flex", justifyContent: "space-between", marginTop: 10 }}>
<span className={status.valid ? undefined : "jac-hint--error"}>
{status.valid ? "editable before you sign" : status.reason}
</span>
<span className={`jac-counter${status.trimmedLength > 2000 ? " jac-counter--over" : ""}`}>
{status.trimmedLength} / 2000
</span>
</div>
) : null}
<button
type="button"
className="jac-type-instead"
style={{ marginTop: 12 }}
onClick={() => {
stop();
setTyping(true);
}}
>
Type it instead
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Minimal Web Speech API surface — no @types/dom-speech-recognition dependency.
// ---------------------------------------------------------------------------
interface SpeechRecognitionAlternativeLike {
transcript: string;
}
interface SpeechRecognitionResultLike {
isFinal: boolean;
[index: number]: SpeechRecognitionAlternativeLike;
}
interface SpeechRecognitionEventLike {
resultIndex: number;
results: ArrayLike<SpeechRecognitionResultLike>;
}
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: ((event: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
}
interface WindowWithSpeech {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
}