Files
ks-app-employment-service/hook/useRealtimeRecorder.js
史典卓 0216f6053a flat:AI+
2025-03-28 15:19:42 +08:00

475 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ref,
onUnmounted
} from 'vue';
export function useAudioRecorder(wsUrl) {
// 状态变量
const isRecording = ref(false);
const isStopping = ref(false);
const isSocketConnected = ref(false);
const recordingDuration = ref(0);
const bufferPressure = ref(0); // 缓冲区压力 (0-100)
const currentInterval = ref(0); // 当前发送间隔
// 音频相关
const audioContext = ref(null);
const mediaStream = ref(null);
const workletNode = ref(null);
// 网络相关
const socket = ref(null);
const audioBuffer = ref([]);
const bufferInterval = ref(null);
// 配置常量
const SAMPLE_RATE = 16000;
const BASE_INTERVAL_MS = 300; // 基础发送间隔
const MIN_INTERVAL_MS = 100; // 最小发送间隔
const MAX_BUFFER_SIZE = 20; // 最大缓冲区块数
const PRESSURE_THRESHOLD = 0.7; // 加快发送的阈值 (70%)
// AudioWorklet处理器代码
const workletProcessorCode = `
class AudioProcessor extends AudioWorkletProcessor {
constructor(options) {
super();
this.sampleRate = options.processorOptions.sampleRate;
this.samplesPerChunk = Math.floor(this.sampleRate * 0.1); // 每100ms的样本数
this.buffer = new Int16Array(this.samplesPerChunk);
this.index = 0;
}
process(inputs) {
const input = inputs[0];
if (input.length > 0) {
const inputChannel = input[0];
for (let i = 0; i < inputChannel.length; i++) {
// 转换为16位PCM
this.buffer[this.index++] = Math.max(-32768, Math.min(32767, inputChannel[i] * 32767));
// 当缓冲区满时发送
if (this.index >= this.samplesPerChunk) {
this.port.postMessage({
audioData: this.buffer.buffer,
timestamp: Date.now()
}, [this.buffer.buffer]);
// 创建新缓冲区
this.buffer = new Int16Array(this.samplesPerChunk);
this.index = 0;
}
}
}
return true;
}
}
registerProcessor('audio-processor', AudioProcessor);
`;
// 初始化WebSocket连接
const initSocket = (wsUrl) => {
return new Promise((resolve, reject) => {
socket.value = new WebSocket(wsUrl);
socket.value.onopen = () => {
isSocketConnected.value = true;
console.log('WebSocket连接已建立');
resolve();
};
socket.value.onerror = (error) => {
console.error('WebSocket连接错误:', error);
reject(error);
};
socket.value.onclose = (event) => {
console.log(`WebSocket连接关闭代码: ${event.code}, 原因: ${event.reason}`);
isSocketConnected.value = false;
console.log('WebSocket连接已关闭');
};
});
};
// 计算动态发送间隔
const calculateDynamicInterval = () => {
const pressureFactor = bufferPressure.value / 100;
// 压力越大,间隔越小(发送越快)
return Math.max(
MIN_INTERVAL_MS,
BASE_INTERVAL_MS - (pressureFactor * (BASE_INTERVAL_MS - MIN_INTERVAL_MS))
);
};
// 发送缓冲的音频数据
const sendBufferedAudio = () => {
if (audioBuffer.value.length === 0 || !socket.value || socket.value.readyState !== WebSocket.OPEN) {
return;
}
try {
// 将缓冲区大小限制为8000字节 (小于8192)
const MAX_CHUNK_SIZE = 8000 / 2; // 16位 = 2字节所以4000个样本
let samplesToSend = [];
let totalSamples = 0;
// 收集不超过限制的样本
while (audioBuffer.value.length > 0 && totalSamples < MAX_CHUNK_SIZE) {
const buffer = audioBuffer.value[0];
const samples = new Int16Array(buffer);
const remainingSpace = MAX_CHUNK_SIZE - totalSamples;
if (samples.length <= remainingSpace) {
samplesToSend.push(samples);
totalSamples += samples.length;
audioBuffer.value.shift();
} else {
// 只取部分样本
samplesToSend.push(samples.slice(0, remainingSpace));
audioBuffer.value[0] = samples.slice(remainingSpace).buffer;
totalSamples = MAX_CHUNK_SIZE;
}
}
// 合并样本并发送
if (totalSamples > 0) {
const combined = new Int16Array(totalSamples);
let offset = 0;
samplesToSend.forEach(chunk => {
combined.set(chunk, offset);
offset += chunk.length;
});
socket.value.send(combined.buffer);
}
} catch (error) {
console.error('发送音频数据时出错:', error);
}
};
// 开始录音
const startRecording = async () => {
if (isRecording.value) return;
try {
// 重置状态
recordingDuration.value = 0;
audioBuffer.value = [];
bufferPressure.value = 0;
currentInterval.value = BASE_INTERVAL_MS;
console.log('正在初始化WebSocket连接...');
// 初始化WebSocket
await initSocket(wsUrl);
console.log('正在获取音频设备权限...');
// 获取音频流
mediaStream.value = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: 1,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false
},
video: false
});
console.log('正在初始化音频上下文...');
// 创建音频上下文
audioContext.value = new(window.AudioContext || window.webkitAudioContext)({
sampleRate: SAMPLE_RATE,
latencyHint: 'interactive'
});
// 注册AudioWorklet
const blob = new Blob([workletProcessorCode], {
type: 'application/javascript'
});
const workletUrl = URL.createObjectURL(blob);
await audioContext.value.audioWorklet.addModule(workletUrl);
URL.revokeObjectURL(workletUrl);
// 创建AudioWorkletNode
workletNode.value = new AudioWorkletNode(audioContext.value, 'audio-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
processorOptions: {
sampleRate: SAMPLE_RATE
}
});
// 处理音频数据
workletNode.value.port.onmessage = (e) => {
if (e.data.audioData instanceof ArrayBuffer) {
audioBuffer.value.push(e.data.audioData);
// 当缓冲区压力超过阈值时立即尝试发送
if (audioBuffer.value.length / MAX_BUFFER_SIZE > PRESSURE_THRESHOLD) {
sendBufferedAudio();
}
}
};
// 连接音频节点
const source = audioContext.value.createMediaStreamSource(mediaStream.value);
source.connect(workletNode.value);
workletNode.value.connect(audioContext.value.destination);
// 启动定时发送
bufferInterval.value = setInterval(sendBufferedAudio, currentInterval.value);
console.log('录音初始化完成,开始录制');
// 更新状态
isRecording.value = true;
console.log(`开始录音,采样率: ${audioContext.value.sampleRate}Hz`);
} catch (error) {
console.error('启动录音失败:', error);
cleanup();
throw error;
}
};
// 停止录音并保存
const stopRecording = async () => {
if (!isRecording.value || isStopping.value) return;
isStopping.value = true;
try {
// 停止定时器
if (bufferInterval.value) {
clearInterval(bufferInterval.value);
bufferInterval.value = null;
}
// 发送剩余音频数据
if (audioBuffer.value.length > 0) {
console.log(`正在发送剩余 ${audioBuffer.value.length} 个音频块...`);
sendBufferedAudio();
}
// 发送结束标记
if (socket.value?.readyState === WebSocket.OPEN) {
console.log('发送结束标记...');
socket.value.send(JSON.stringify({
action: 'end',
duration: recordingDuration.value
}));
// 等待数据发送完成
await new Promise((resolve) => {
if (socket.value.bufferedAmount === 0) {
resolve();
} else {
console.log(`等待 ${socket.value.bufferedAmount} 字节数据发送...`);
const timer = setInterval(() => {
if (socket.value.bufferedAmount === 0) {
clearInterval(timer);
resolve();
}
}, 50);
}
});
// 关闭连接
console.log('正在关闭WebSocket连接...');
socket.value.close();
}
cleanup();
console.log('录音已停止并保存');
} catch (error) {
console.error('停止录音时出错:', error);
throw error;
} finally {
isStopping.value = false;
}
};
// 取消录音
const cancelRecording = async () => {
if (!isRecording.value || isStopping.value) return;
isStopping.value = true;
try {
// 停止定时器
if (bufferInterval.value) {
clearInterval(bufferInterval.value);
bufferInterval.value = null;
}
// 发送取消标记
if (socket.value?.readyState === WebSocket.OPEN) {
console.log('发送结束标记...');
socket.value.send(JSON.stringify({
action: 'cancel'
}));
socket.value.close();
}
console.log('清理资源...');
cleanup();
console.log('录音已成功停止');
console.log('录音已取消');
} catch (error) {
console.error('取消录音时出错:', error);
throw error;
} finally {
isStopping.value = false;
}
};
// 清理资源
const cleanup = () => {
// 清除定时器
if (bufferInterval.value) {
clearInterval(bufferInterval.value);
bufferInterval.value = null;
}
// 关闭音频流
if (mediaStream.value) {
console.log('正在停止媒体流...');
mediaStream.value.getTracks().forEach(track => track.stop());
mediaStream.value = null;
}
// 断开音频节点
if (workletNode.value) {
workletNode.value.disconnect();
workletNode.value.port.onmessage = null;
workletNode.value = null;
}
// 关闭音频上下文
if (audioContext.value) {
if (audioContext.value.state !== 'closed') {
audioContext.value.close().catch(e => {
console.warn('关闭AudioContext时出错:', e);
});
}
audioContext.value = null;
}
// 清空缓冲区
audioBuffer.value = [];
bufferPressure.value = 0;
// 重置状态
isRecording.value = false;
isSocketConnected.value = false;
};
// 组件卸载时自动清理
onUnmounted(() => {
if (isRecording.value) {
cancelRecording();
}
});
return {
isRecording,
isStopping,
isSocketConnected,
recordingDuration,
bufferPressure,
currentInterval,
startRecording,
stopRecording,
cancelRecording
};
}
// import {
// ref
// } from 'vue'
// export function useRealtimeRecorder(wsUrl) {
// const isRecording = ref(false)
// const mediaRecorder = ref(null)
// const socket = ref(null)
// const recognizedText = ref('')
// const startRecording = async () => {
// if (!navigator.mediaDevices?.getUserMedia) {
// uni.showToast({
// title: '当前环境不支持录音',
// icon: 'none'
// })
// return
// }
// recognizedText.value = ''
// const stream = await navigator.mediaDevices.getUserMedia({
// audio: {
// sampleRate: 16000,
// channelCount: 1,
// echoCancellation: false,
// noiseSuppression: false,
// autoGainControl: false
// },
// video: false
// })
// socket.value = new WebSocket(wsUrl)
// socket.value.onopen = () => {
// console.log('[WebSocket] 连接已建立')
// }
// socket.value.onmessage = (event) => {
// recognizedText.value = JSON.parse(event.data).text
// }
// const recorder = new MediaRecorder(stream, {
// mimeType: 'audio/webm;codecs=opus',
// audioBitsPerSecond: 16000,
// })
// recorder.ondataavailable = (e) => {
// if (e.data.size > 0 && socket.value?.readyState === WebSocket.OPEN) {
// socket.value.send(e.data)
// }
// }
// recorder.start(300) // 每 300ms 发送一段数据
// mediaRecorder.value = recorder
// isRecording.value = true
// }
// const stopRecording = () => {
// mediaRecorder.value?.stop()
// mediaRecorder.value = null
// isRecording.value = false
// if (socket.value?.readyState === WebSocket.OPEN) {
// socket.value.send('[end]')
// socket.value.close()
// }
// }
// const cancelRecording = () => {
// mediaRecorder.value?.stop()
// mediaRecorder.value = null
// isRecording.value = false
// recognizedText.value = ''
// if (socket.value?.readyState === WebSocket.OPEN) {
// socket.value.send('[cancel]')
// socket.value.close()
// }
// }
// return {
// isRecording,
// recognizedText,
// startRecording,
// stopRecording,
// cancelRecording
// }
// }