<el-button
@click="toggleRecording"
:class="{ recording: isRecording }"
:disabled="isProcessing || !wasmInitialized"
>
{{ isRecording ? "停止录音" : wasmInitialized ? "开始录音" : "加载编码器..." }}
</el-button>
正在加载Opus编码器 (WASM)...
正在录音... 实时发送Opus帧
最后发送: {{ lastSentSize }}B (帧{{ frameCount }})
编码延迟: {{ encodingLatency }}ms
{{ errorMessage }}
</template>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, onUnmounted, inject } from 'vue';
const props = defineProps({
formData: {
type: Object,
default: () => ({})
}
});
// 注入WebSocket相关方法
const {
isConnected,
send,
addTip
}: any = inject('testProvide') || {};
const { formData } = props;
// 核心状态管理
const isRecording = ref(false);
const isProcessing = ref(false);
const audioContext = ref<AudioContext | null>(null);
const mediaStream = ref<MediaStream | null>(null);
const scriptProcessor = ref<any>(null);
const currentAudioUrl = ref('');
const errorMessage = ref('');
const lastSentSize = ref(0);
const frameCount = ref(0);
const encodingLatency = ref(0);
const frameBuffer = ref<Float32Array>(new Float32Array(0));
// WASM Opus编码器状态
const opusModuleRef = ref<any>(null);
const opusEncoderRef = ref<any>(null);
const wasmInitialized = ref(false);
// 音频参数配置
const SAMPLE_RATE = 16000; // 采样率 16000Hz
const CHANNELS = 1; // 单声道
const SAMPLES_PER_FRAME = 960; // 60ms帧 = 960样本 (固定)
const PCM_BYTE_LENGTH = 1920; // 960样本 * 2字节 = 1920字节
// 通信数据
const startData = reactive({
session_id: formData.session_id || '',
type: 'listen',
state: 'start',
mode: 'manual',
format: 'opus'
});
const stopData = reactive({
session_id: formData.session_id || '',
type: 'listen',
state: 'stop',
});
// 初始化WASM Opus编码器
const initOpusEncoder = async () => {
try {
// 1. 加载Opus WASM模块
const response = await fetch('./opus_encoder.wasm');
if (!response.ok) throw new Error(`WASM加载失败: HTTP ${response.status}`);
const wasmBinary = await response.arrayBuffer();
// 2. 实例化WASM模块
const importObject = {
env: {
memory: new WebAssembly.Memory({ initial: 256 }),
emscripten_notify_memory_growth: () => {}
}
};
const { instance } = await WebAssembly.instantiate(wasmBinary, importObject);
// 3. 创建编码器实例
const errorPtr = instance.exports._malloc(4);
const encoder = instance.exports.opus_encoder_create(
SAMPLE_RATE, // 16000 Hz
CHANNELS, // 1 声道
2048, // OPUS_APPLICATION_VOIP
errorPtr
);
// 检查编码器是否创建成功
const errorCode = new Uint32Array(instance.exports.memory.buffer, errorPtr, 1)[0];
if (errorCode !== 0) throw new Error(`Opus创建失败: 错误码 ${errorCode}`);
// 4. 配置编码参数
instance.exports.opus_encoder_ctl(encoder, 4002, 16000); // SET_BITRATE(16kbps)
instance.exports.opus_encoder_ctl(encoder, 4010, 5); // SET_COMPLEXITY
instance.exports.opus_encoder_ctl(encoder, 4024, 0); // SET_VBR (0=CBR)
// 5. 保存引用
opusModuleRef.value = instance.exports;
opusEncoderRef.value = encoder;
wasmInitialized.value = true;
console.log("Opus编码器初始化成功");
addTip?.("Opus编码器已加载", "success");
} catch (error: any) {
console.error("Opus初始化失败:", error);
errorMessage.value = `Opus加载失败: ${error.message}`;
addTip?.("无法加载Opus编码器", "error");
}
};
// 核心编码函数:PCM转Opus (匹配图片中格式)
const encodePcmToOpus = (pcmData: Int16Array) => {
if (!opusEncoderRef.value || !opusModuleRef.value) return null;
try {
// 1. 分配输入内存 (PCM数据)
const pcmPtr = opusModuleRef.value._malloc(pcmData.length * 2);
const pcmHeap = new Int16Array(
opusModuleRef.value.memory.buffer,
pcmPtr,
pcmData.length
);
pcmHeap.set(pcmData);
// 2. 分配输出内存 (Opus数据)
const maxPacketSize = 4000;
const opusPtr = opusModuleRef.value._malloc(maxPacketSize);
// 3. 执行编码 (固定960样本帧)
const encodedSize = opusModuleRef.value.opus_encode(
opusEncoderRef.value,
pcmPtr,
SAMPLES_PER_FRAME,
opusPtr,
maxPacketSize
);
// 4. 检查编码结果
if (encodedSize <= 0) {
throw new Error(`编码失败: 返回码 ${encodedSize}`);
}
// 5. 提取结果数据 (严格匹配图片格式)
const opusFrame = new Uint8Array(
opusModuleRef.value.memory.buffer,
opusPtr,
encodedSize
);
// 6. 创建完整数据结构 (包含buffer属性)
const opusData = {
buffer: opusFrame.buffer,
data: opusFrame,
length: opusFrame.length,
byteLength: opusFrame.byteLength,
__proto__: Uint8Array.prototype
};
// 7. 打印调试信息 (与图片格式一致)
console.log("opusData*****", opusData.data);
console.log("buffer:", opusData.buffer);
console.log("byteLength:", opusData.buffer.byteLength);
// 8. 清理内存
opusModuleRef.value._free(pcmPtr);
opusModuleRef.value._free(opusPtr);
return opusData;
} catch (error) {
console.error("Opus编码错误:", error);
return null;
}
};
// 开始录音
const startRecording = async () => {
if (isRecording.value || isProcessing.value || !wasmInitialized.value) return;
isProcessing.value = true;
errorMessage.value = '';
try {
// 发送开始信号
send(startData);
// 请求麦克风权限
mediaStream.value = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: CHANNELS,
echoCancellation: true,
noiseSuppression: true
}
});
// 初始化音频处理
audioContext.value = new (window.AudioContext || (window as any).webkitAudioContext)({
sampleRate: SAMPLE_RATE,
latencyHint: 'interactive'
});
// 创建处理器 (缓冲区4096样本)
const source = audioContext.value.createMediaStreamSource(mediaStream.value);
scriptProcessor.value = audioContext.value.createScriptProcessor(4096, 1, 1);
// PCM处理回调
scriptProcessor.value.onaudioprocess = (event: any) => {
if (!isRecording.value || !wasmInitialized.value) return;
const startTime = performance.now();
const inputData = event.inputBuffer.getChannelData(0);
// 添加到帧缓冲区
const newBuffer = new Float32Array(frameBuffer.value.length + inputData.length);
newBuffer.set(frameBuffer.value);
newBuffer.set(inputData, frameBuffer.value.length);
frameBuffer.value = newBuffer;
// 处理完整帧 (每次960样本)
while (frameBuffer.value.length >= SAMPLES_PER_FRAME) {
const frameData = frameBuffer.value.slice(0, SAMPLES_PER_FRAME);
frameBuffer.value = frameBuffer.value.slice(SAMPLES_PER_FRAME);
// 1. 将Float32转为Int16 PCM (原始样本数960)
const pcmInt16 = new Int16Array(SAMPLES_PER_FRAME);
for (let i = 0; i < SAMPLES_PER_FRAME; i++) {
pcmInt16[i] = Math.max(-32768, Math.min(32767, frameData[i] * 32767));
}
// 2. 编码为Opus格式 (生成Uint8Array)
const startEncodeTime = performance.now();
const opusData = encodePcmToOpus(pcmInt16);
encodingLatency.value = Math.round(performance.now() - startEncodeTime);
// 3. 发送Opus数据 (PCM字节数1920 -> Opus长度约1952)
if (opusData && opusData.length > 0 && isConnected?.value && isRecording.value) {
send(opusData.data, 2);
lastSentSize.value = opusData.length;
frameCount.value++;
}
}
// 记录处理延迟
processingLatency.value = Math.round(performance.now() - startTime);
};
// 连接音频节点
source.connect(scriptProcessor.value);
scriptProcessor.value.connect(audioContext.value.destination);
// 创建MediaRecorder用于本地播放
const mediaRecorder = new MediaRecorder(mediaStream.value, {
mimeType: 'audio/webm;codecs=opus',
audioBitsPerSecond: 16000
});
const audioChunks: Blob[] = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm;codecs=opus' });
currentAudioUrl.value = URL.createObjectURL(audioBlob);
};
mediaRecorder.start(100);
isRecording.value = true;
addTip?.(`开始录音,采样率: ${SAMPLE_RATE}Hz`, 'success');
} catch (err: any) {
console.error("录音启动失败:", err);
errorMessage.value = err.message || "录音初始化失败";
isRecording.value = false;
} finally {
isProcessing.value = false;
}
};
// 停止录音
const stopRecording = async () => {
if (!isRecording.value || isProcessing.value) return;
isProcessing.value = true;
try {
// 停止音频处理
if (scriptProcessor.value) {
scriptProcessor.value.disconnect();
scriptProcessor.value = null;
}
// 停止媒体流
if (mediaStream.value) {
mediaStream.value.getTracks().forEach(track => track.stop());
mediaStream.value = null;
}
// 关闭音频上下文
if (audioContext.value) {
await audioContext.value.close();
audioContext.value = null;
}
// 发送停止信号
send(stopData);
isRecording.value = false;
frameBuffer.value = new Float32Array(0); // 清空缓冲区
addTip?.("录音已停止", "success");
} catch (err) {
console.error("停止录音失败:", err);
errorMessage.value = "停止录音时发生错误";
} finally {
isProcessing.value = false;
}
};
// 切换录音状态
const toggleRecording = () => {
isRecording.value ? stopRecording() : startRecording();
};
// 初始化Opus编码器
onMounted(() => {
initOpusEncoder();
});
// 清理资源
onUnmounted(() => {
stopRecording();
// 释放Opus编码器资源
if (opusEncoderRef.value && opusModuleRef.value) {
opusModuleRef.value.opus_encoder_destroy(opusEncoderRef.value);
}
if (currentAudioUrl.value) {
URL.revokeObjectURL(currentAudioUrl.value);
}
});
</script>
<style lang="scss" scoped>
.voice-recorder {
max-width: 800px;
margin: 0 auto;
padding: 20px;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
.voice-top {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.el-button {
padding: 14px 28px;
font-size: 16px;
font-weight: 500;
border-radius: 30px;
transition: all 0.3s ease;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
&.recording {
background: #ff4444;
animation: pulse 1.5s infinite;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
&:disabled {
background: #cccccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
.encoder-status {
margin: 15px 0;
padding: 15px;
background: #f8fcff;
border-radius: 8px;
text-align: center;
border: 1px solid #e0f0ff;
color: #2c3e50;
font-size: 14px;
}
.recording-status {
margin: 15px 0;
padding: 12px;
background: #fff0f0;
border-radius: 8px;
color: #ff4444;
text-align: center;
font-size: 14px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 12px;
span {
padding: 4px 8px;
background: rgba(255, 68, 68, 0.1);
border-radius: 4px;
white-space: nowrap;
}
}
.error-message {
margin: 15px 0;
padding: 12px;
color: #ff4444;
text-align: center;
font-size: 14px;
border-radius: 8px;
background: #fff8f8;
border: 1px solid #ffcccc;
font-weight: 500;
}
.audio-player {
margin-top: 25px;
text-align: center;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #eee;
audio {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
}
}
@keyframes pulse {
0% {
background-color: #db4437;
box-shadow: 0 0 0 0 rgba(219, 68, 55, 0.7);
}
50% {
background-color: #ff6659;
box-shadow: 0 0 0 10px rgba(219, 68, 55, 0);
}
100% {
background-color: #db4437;
box-shadow: 0 0 0 0 rgba(219, 68, 55, 0);
}
}
</style>能帮我找到一个能用的wasm文件吗