feat(web): voice input — record, check the recognised text, send
CI / ruff · pytest (pull_request) Successful in 2m24s

Microphone button in the chat composer and in free-text steps. The browser
records 16 kHz mono WAV (no server-side ffmpeg), /me/{token}/stt transcribes,
the text lands in the field for the patient to check and edit — never sent
automatically, so safety and scrub() apply on send. Hidden when the server has
voice input off or the browser lacks a microphone API. nginx: 4 MB body limit.

Refs #117

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 11:45:36 +03:00
co-authored by Claude Opus 5.5
parent 63cc5372be
commit e20da1f21c
9 changed files with 431 additions and 1 deletions
+6
View File
@@ -32,6 +32,12 @@ const paths = {
arrowLeft: <path d="M19 12H5m6-6l-6 6 6 6" />,
close: <path d="M6 6l12 12M18 6L6 18" />,
check: <path d="M5 12.5l4.5 4.5L19 7.5" />,
mic: (
<>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21" />
</>
),
x: <path d="M7 7l10 10M17 7L7 17" />,
info: (
<>
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState } from "react";
import { ApiError, api } from "../api/client";
import { ru } from "../content/ru";
import { useSession } from "../session/SessionContext";
import { MAX_SECONDS, Recorder, voiceSupported } from "../voice/recorder";
import { Icon } from "./Icon";
import { useToast } from "./Toast";
/*
* Voice input next to a free-text field (#117, slide 11: «Записать → Проверить»). The recognised text is put into
* the field for the patient to check and edit — never sent automatically: safety and scrub() run when they send it.
*/
let capability: Promise<boolean> | null = null;
function voiceEnabled(): Promise<boolean> {
capability ??= api
.patientCapabilities()
.then((c) => Boolean(c.voice_input) && voiceSupported())
.catch(() => false);
return capability;
}
const BARS = 14;
type State = "idle" | "recording" | "transcribing";
export function VoiceInput({
disabled,
onText,
compact,
}: {
disabled?: boolean;
onText: (text: string) => void;
/** Chat composer: a round icon button; the recording panel floats above the composer. */
compact?: boolean;
}) {
const { session } = useSession();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [state, setState] = useState<State>("idle");
const [seconds, setSeconds] = useState(0);
const [levels, setLevels] = useState<number[]>(() => Array(BARS).fill(0));
const rec = useRef<Recorder | null>(null);
const timer = useRef<number>();
useEffect(() => {
let alive = true;
void voiceEnabled().then((on) => alive && setEnabled(on));
return () => {
alive = false;
window.clearInterval(timer.current);
rec.current?.cancel();
};
}, []);
if (!enabled || !session) return null;
const start = async () => {
const r = new Recorder();
try {
await r.start((level) => setLevels((prev) => [...prev.slice(1), level]));
} catch {
r.cancel();
toast(ru.voice.noMic);
return;
}
rec.current = r;
setSeconds(0);
setState("recording");
const began = Date.now();
timer.current = window.setInterval(() => {
const s = Math.floor((Date.now() - began) / 1000);
setSeconds(s);
if (s >= MAX_SECONDS) void stop();
}, 250);
};
const reset = () => {
window.clearInterval(timer.current);
rec.current = null;
setLevels(Array(BARS).fill(0));
};
const stop = async () => {
const wav = rec.current?.stop() ?? null;
reset();
if (!wav) {
setState("idle");
return;
}
setState("transcribing");
try {
const text = await api.transcribe(session.token, wav);
onText(text);
toast(ru.voice.check);
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
toast(status === 422 ? ru.voice.noSpeech : status === 413 ? ru.voice.tooLong : ru.voice.failed);
} finally {
setState("idle");
}
};
const cancel = () => {
rec.current?.cancel();
reset();
setState("idle");
};
if (state === "idle" && compact) {
return (
<button
type="button"
className="mic"
disabled={disabled}
onClick={() => void start()}
aria-label={ru.voice.start}
title={ru.voice.start}
>
<Icon name="mic" size={20} />
</button>
);
}
if (state === "idle") {
return (
<button type="button" className="voice-btn" disabled={disabled} onClick={() => void start()}>
<Icon name="mic" size={20} />
{ru.voice.start}
</button>
);
}
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return (
<div className={compact ? "voice voice--float" : "voice"} role="group" aria-label={ru.voice.recording}>
{state === "recording" ? (
<>
<p className="voice__hint">{ru.voice.recording}</p>
<div className="voice__wave" aria-hidden="true">
{levels.map((l, i) => (
<span key={i} style={{ height: `${Math.max(12, Math.round(l * 100))}%` }} />
))}
</div>
<div className="voice__row">
<span className="voice__time" aria-live="off">
{mm}:{ss}
</span>
<button type="button" className="btn btn--ghost voice__cancel" onClick={cancel}>
{ru.voice.cancel}
</button>
<button type="button" className="btn btn--primary voice__stop" onClick={() => void stop()}>
{ru.voice.stop}
</button>
</div>
<p className="voice__privacy">{ru.voice.privacy}</p>
</>
) : (
<p className="voice__hint" aria-live="polite">
{ru.voice.transcribing}
</p>
)}
</div>
);
}