164 lines
4.7 KiB
TypeScript
164 lines
4.7 KiB
TypeScript
import { useWebSocketStore } from "@/services";
|
||
import { convertToPCM16 } from "@/utils";
|
||
import { useChatStore } from "./chat_store";
|
||
|
||
export const useAsrStore = defineStore("asr", () => {
|
||
// 是否正在录音
|
||
const isRecording = ref(false);
|
||
// 识别结果消息列表
|
||
const messages = ref<string[]>([]);
|
||
// 音频相关对象
|
||
let audioContext: AudioContext | null = null;
|
||
let mediaStreamSource: MediaStreamAudioSourceNode | null = null;
|
||
let workletNode: AudioWorkletNode | null = null;
|
||
|
||
const webSocketStore = useWebSocketStore();
|
||
const router = useRouter();
|
||
|
||
/**
|
||
* 发送消息到 WebSocket
|
||
* @param data 字符串或二进制数据
|
||
*/
|
||
const sendMessage = (data: string | Uint8Array) => {
|
||
// 仅在连接已建立时发送
|
||
if (webSocketStore.connected) {
|
||
if (typeof data === "string") {
|
||
webSocketStore.send(data);
|
||
} else {
|
||
webSocketStore.websocket?.send(data);
|
||
}
|
||
}
|
||
};
|
||
|
||
// AudioWorklet 处理器代码,作为字符串
|
||
const audioProcessorCode = `
|
||
class AudioProcessor extends AudioWorkletProcessor {
|
||
process(inputs, outputs, parameters) {
|
||
const input = inputs[0]
|
||
if (input.length > 0) {
|
||
const inputChannel = input[0]
|
||
// 发送音频数据到主线程
|
||
this.port.postMessage({
|
||
type: 'audiodata',
|
||
data: inputChannel
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
registerProcessor('audio-processor', AudioProcessor)
|
||
`;
|
||
|
||
/**
|
||
* 开始录音
|
||
*/
|
||
const startRecording = async () => {
|
||
if (isRecording.value) return;
|
||
messages.value = [];
|
||
// 确保 WebSocket 已连接
|
||
if (!webSocketStore.connected) {
|
||
webSocketStore.connect();
|
||
// 等待连接建立
|
||
await new Promise<void>((resolve) => {
|
||
const check = () => {
|
||
if (webSocketStore.connected) resolve();
|
||
else setTimeout(check, 100);
|
||
};
|
||
check();
|
||
});
|
||
}
|
||
try {
|
||
// 获取麦克风音频流
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
// 创建音频上下文,采样率16kHz
|
||
audioContext = new (window.AudioContext ||
|
||
(window as any).webkitAudioContext)({
|
||
sampleRate: 16000
|
||
});
|
||
// 用Blob方式创建AudioWorklet模块的URL
|
||
const blob = new Blob([audioProcessorCode], {
|
||
type: "application/javascript"
|
||
});
|
||
const processorUrl = URL.createObjectURL(blob);
|
||
// 加载AudioWorklet模块
|
||
await audioContext.audioWorklet.addModule(processorUrl);
|
||
// 释放URL对象防止内存泄漏
|
||
URL.revokeObjectURL(processorUrl);
|
||
|
||
// 创建音频源节点
|
||
mediaStreamSource = audioContext.createMediaStreamSource(stream);
|
||
|
||
// 创建AudioWorkletNode
|
||
workletNode = new AudioWorkletNode(audioContext, "audio-processor", {
|
||
numberOfInputs: 1,
|
||
numberOfOutputs: 1,
|
||
channelCount: 1
|
||
});
|
||
|
||
// 监听来自AudioWorklet的音频数据
|
||
workletNode.port.onmessage = (event) => {
|
||
if (event.data.type === "audiodata") {
|
||
// 转换为16位PCM格式
|
||
const pcmData = convertToPCM16(event.data.data);
|
||
// 发送PCM数据到WebSocket
|
||
sendMessage(pcmData);
|
||
}
|
||
};
|
||
// 连接音频节点
|
||
mediaStreamSource.connect(workletNode);
|
||
workletNode.connect(audioContext.destination);
|
||
isRecording.value = true;
|
||
} catch (err) {
|
||
// 麦克风权限失败或AudioWorklet加载失败
|
||
console.error("需要麦克风权限才能录音", err);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 停止录音
|
||
*/
|
||
const stopRecording = () => {
|
||
if (!isRecording.value) return;
|
||
|
||
const messageId = `voice_${Date.now()}`;
|
||
// 通知后端录音结束
|
||
const msg: Record<string, any> = { type: "asr_end" };
|
||
if (router.currentRoute.value.path === "/voice") {
|
||
msg.messageId = messageId;
|
||
msg.voiceConversation = true;
|
||
msg.speaker = useChatStore().speakerInfo?.speaker_id;
|
||
}
|
||
sendMessage(JSON.stringify(msg));
|
||
|
||
// 停止所有音轨
|
||
if (mediaStreamSource?.mediaStream) {
|
||
const tracks = mediaStreamSource.mediaStream.getTracks();
|
||
tracks.forEach((track) => track.stop());
|
||
}
|
||
|
||
// 断开音频节点
|
||
workletNode?.disconnect();
|
||
mediaStreamSource?.disconnect();
|
||
setTimeout(() => {
|
||
// TODO: 临时写法,这里的更新状态需要调整
|
||
// 确保在停止录音后延迟更新状态,因为要等待LLM请求
|
||
isRecording.value = false;
|
||
}, 300);
|
||
|
||
// 释放音频资源
|
||
audioContext?.close().then(() => {
|
||
audioContext = null;
|
||
mediaStreamSource = null;
|
||
workletNode = null;
|
||
});
|
||
};
|
||
|
||
return {
|
||
isRecording,
|
||
messages,
|
||
startRecording,
|
||
stopRecording,
|
||
sendMessage
|
||
};
|
||
});
|