Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

119 lines
3.8 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch, apiUrl } from "@/lib/api";
export type RecorderState = "idle" | "recording" | "transcribing";
/**
* Microphone capture → backend transcription. Records via MediaRecorder, posts
* the clip to ``/api/v1/voice/stt`` (which uses the admin-configured STT
* provider), and hands the transcript back through ``onTranscript``.
*/
export function useVoiceRecorder(onTranscript: (text: string) => void) {
const [state, setState] = useState<RecorderState>("idle");
const [error, setError] = useState<string | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const onTranscriptRef = useRef(onTranscript);
onTranscriptRef.current = onTranscript;
const releaseStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, []);
const start = useCallback(async () => {
if (state !== "idle") return;
setError(null);
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === "undefined"
) {
setError("Recording is not supported in this browser.");
return;
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError("Microphone permission denied.");
return;
}
streamRef.current = stream;
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) chunksRef.current.push(event.data);
};
recorder.onstop = async () => {
const mimeType = recorder.mimeType || "audio/webm";
releaseStream();
const blob = new Blob(chunksRef.current, { type: mimeType });
chunksRef.current = [];
if (!blob.size) {
setState("idle");
return;
}
setState("transcribing");
try {
const ext = mimeType.includes("ogg")
? "ogg"
: mimeType.includes("mp4")
? "mp4"
: "webm";
const form = new FormData();
form.append("file", blob, `recording.${ext}`);
const resp = await apiFetch(apiUrl("/api/v1/voice/stt"), {
method: "POST",
body: form,
});
if (!resp.ok) {
const detail = (await resp.json().catch(() => null)) as {
detail?: string;
} | null;
throw new Error(
detail?.detail || `Transcription failed (HTTP ${resp.status}).`,
);
}
const data = (await resp.json()) as { text?: string };
const text = (data.text || "").trim();
if (text) onTranscriptRef.current(text);
} catch (err) {
setError(err instanceof Error ? err.message : "Transcription failed.");
} finally {
setState("idle");
}
};
recorder.start();
recorderRef.current = recorder;
setState("recording");
}, [releaseStream, state]);
const stop = useCallback(() => {
const recorder = recorderRef.current;
if (recorder && recorder.state !== "inactive") {
recorder.stop(); // fires onstop → transcribe
}
}, []);
const toggle = useCallback(() => {
if (state === "recording") stop();
else if (state === "idle") void start();
}, [start, state, stop]);
// Stop the mic if the component unmounts mid-recording.
useEffect(() => {
return () => {
const recorder = recorderRef.current;
if (recorder && recorder.state !== "inactive") recorder.stop();
streamRef.current?.getTracks().forEach((track) => track.stop());
};
}, []);
return { state, error, toggle, start, stop };
}