193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
export type VoiceRecordingState = "idle" | "recording" | "paused";
|
|
|
|
type UseVoiceRecordingOptions = {
|
|
/** 最大录音时长(秒),默认 60 秒 */
|
|
maxDuration?: number;
|
|
};
|
|
|
|
export type UseVoiceRecordingReturn = {
|
|
state: VoiceRecordingState;
|
|
waveform: number[];
|
|
duration: number;
|
|
error: string | null;
|
|
start: () => Promise<void>;
|
|
stop: () => Promise<Blob | null>;
|
|
cancel: () => void;
|
|
reset: () => void;
|
|
};
|
|
|
|
/**
|
|
* 语音录制 Hook
|
|
* 使用 MediaRecorder API 录制音频,AnalyserNode 获取实时波形数据
|
|
*/
|
|
export function useVoiceRecording(options: UseVoiceRecordingOptions = {}): UseVoiceRecordingReturn {
|
|
const { maxDuration = 60 } = options;
|
|
const [state, setState] = useState<VoiceRecordingState>("idle");
|
|
const [waveform, setWaveform] = useState<number[]>([]);
|
|
const [duration, setDuration] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const animationFrameRef = useRef<number | null>(null);
|
|
const timerRef = useRef<number | null>(null);
|
|
const startTimeRef = useRef<number>(0);
|
|
|
|
// 清理资源
|
|
const cleanup = useCallback(() => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
animationFrameRef.current = null;
|
|
}
|
|
if (timerRef.current) {
|
|
window.clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
if (streamRef.current) {
|
|
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
streamRef.current = null;
|
|
}
|
|
if (audioContextRef.current) {
|
|
void audioContextRef.current.close();
|
|
audioContextRef.current = null;
|
|
}
|
|
analyserRef.current = null;
|
|
mediaRecorderRef.current = null;
|
|
}, []);
|
|
|
|
// 更新波形数据
|
|
const updateWaveform = useCallback(() => {
|
|
const analyser = analyserRef.current;
|
|
if (!analyser) return;
|
|
|
|
const bufferLength = analyser.frequencyBinCount;
|
|
const dataArray = new Uint8Array(bufferLength);
|
|
analyser.getByteFrequencyData(dataArray);
|
|
|
|
// 取 32 个频段的平均值,用于波形显示
|
|
const step = Math.floor(bufferLength / 32);
|
|
const values: number[] = [];
|
|
for (let i = 0; i < 32; i++) {
|
|
let sum = 0;
|
|
for (let j = 0; j < step; j++) {
|
|
sum += dataArray[i * step + j];
|
|
}
|
|
values.push(sum / step / 255);
|
|
}
|
|
setWaveform(values);
|
|
|
|
animationFrameRef.current = requestAnimationFrame(updateWaveform);
|
|
}, []);
|
|
|
|
// 开始录制
|
|
const start = useCallback(async () => {
|
|
setError(null);
|
|
setWaveform([]);
|
|
setDuration(0);
|
|
chunksRef.current = [];
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
streamRef.current = stream;
|
|
|
|
const audioContext = new AudioContext();
|
|
audioContextRef.current = audioContext;
|
|
|
|
const source = audioContext.createMediaStreamSource(stream);
|
|
const analyser = audioContext.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
source.connect(analyser);
|
|
analyserRef.current = analyser;
|
|
|
|
const mediaRecorder = new MediaRecorder(stream);
|
|
mediaRecorderRef.current = mediaRecorder;
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
chunksRef.current.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.start(100);
|
|
startTimeRef.current = Date.now();
|
|
setState("recording");
|
|
|
|
// 启动波形更新
|
|
animationFrameRef.current = requestAnimationFrame(updateWaveform);
|
|
|
|
// 启动计时器
|
|
timerRef.current = window.setInterval(() => {
|
|
const elapsed = (Date.now() - startTimeRef.current) / 1000;
|
|
setDuration(elapsed);
|
|
if (elapsed >= maxDuration) {
|
|
// 达到最大时长,自动停止
|
|
mediaRecorder.stop();
|
|
}
|
|
}, 100);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "无法访问麦克风";
|
|
setError(message);
|
|
setState("idle");
|
|
cleanup();
|
|
}
|
|
|
|
}, [cleanup, maxDuration, updateWaveform]);
|
|
|
|
// 停止录制
|
|
const stop = useCallback((): Promise<Blob | null> => {
|
|
return new Promise((resolve) => {
|
|
const mediaRecorder = mediaRecorderRef.current;
|
|
if (!mediaRecorder || mediaRecorder.state === "inactive") {
|
|
cleanup();
|
|
setState("idle");
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
mediaRecorder.onstop = () => {
|
|
const blob = chunksRef.current.length > 0 ? new Blob(chunksRef.current, { type: "audio/webm" }) : null;
|
|
cleanup();
|
|
setState("idle");
|
|
resolve(blob);
|
|
};
|
|
|
|
mediaRecorder.stop();
|
|
});
|
|
}, [cleanup]);
|
|
|
|
// 取消录制
|
|
const cancel = useCallback(() => {
|
|
chunksRef.current = [];
|
|
cleanup();
|
|
setState("idle");
|
|
setWaveform([]);
|
|
setDuration(0);
|
|
}, [cleanup]);
|
|
|
|
// 重置
|
|
const reset = useCallback(() => {
|
|
cancel();
|
|
setError(null);
|
|
}, [cancel]);
|
|
|
|
// 组件卸载时清理
|
|
useEffect(() => {
|
|
return () => cleanup();
|
|
}, [cleanup]);
|
|
|
|
return {
|
|
state,
|
|
waveform,
|
|
duration,
|
|
error,
|
|
start,
|
|
stop,
|
|
cancel,
|
|
reset,
|
|
};
|
|
} |