flat:AI+
This commit is contained in:
475
hook/useRealtimeRecorder.js
Normal file
475
hook/useRealtimeRecorder.js
Normal file
@@ -0,0 +1,475 @@
|
||||
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
|
||||
// }
|
||||
// }
|
129
hook/useRecorder.js
Normal file
129
hook/useRecorder.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// composables/useRealtimeRecorder.js
|
||||
import {
|
||||
ref
|
||||
} from 'vue'
|
||||
|
||||
export function useRealtimeRecorder(wsUrl) {
|
||||
const isRecording = ref(false)
|
||||
const recognizedText = ref('')
|
||||
|
||||
let audioContext = null
|
||||
let audioWorkletNode = null
|
||||
let sourceNode = null
|
||||
let socket = null
|
||||
|
||||
const startRecording = async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true
|
||||
})
|
||||
|
||||
audioContext = new(window.AudioContext || window.webkitAudioContext)()
|
||||
const processorCode = `
|
||||
class RecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super()
|
||||
this.buffer = []
|
||||
this.inputSampleRate = sampleRate
|
||||
this.targetSampleRate = 16000
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const input = inputs[0][0]
|
||||
if (!input) return true
|
||||
this.buffer.push(...input)
|
||||
const requiredSamples = this.inputSampleRate / 10 // 100ms
|
||||
|
||||
if (this.buffer.length >= requiredSamples) {
|
||||
const resampled = this.downsample(this.buffer, this.inputSampleRate, this.targetSampleRate)
|
||||
const int16Buffer = this.floatTo16BitPCM(resampled)
|
||||
this.port.postMessage(int16Buffer)
|
||||
this.buffer = []
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
downsample(buffer, inRate, outRate) {
|
||||
if (outRate === inRate) return buffer
|
||||
const ratio = inRate / outRate
|
||||
const len = Math.floor(buffer.length / ratio)
|
||||
const result = new Float32Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const start = Math.floor(i * ratio)
|
||||
const end = Math.floor((i + 1) * ratio)
|
||||
let sum = 0
|
||||
for (let j = start; j < end && j < buffer.length; j++) sum += buffer[j]
|
||||
result[i] = sum / (end - start)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
floatTo16BitPCM(input) {
|
||||
const output = new Int16Array(input.length)
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, input[i]))
|
||||
output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF
|
||||
}
|
||||
return output.buffer
|
||||
}
|
||||
}
|
||||
registerProcessor('recorder-processor', RecorderProcessor)
|
||||
`
|
||||
const blob = new Blob([processorCode], {
|
||||
type: 'application/javascript'
|
||||
})
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
await audioContext.audioWorklet.addModule(blobUrl)
|
||||
|
||||
socket = new WebSocket(wsUrl)
|
||||
socket.onmessage = (e) => {
|
||||
recognizedText.value = e.data
|
||||
}
|
||||
|
||||
sourceNode = audioContext.createMediaStreamSource(stream)
|
||||
audioWorkletNode = new AudioWorkletNode(audioContext, 'recorder-processor')
|
||||
|
||||
audioWorkletNode.port.onmessage = (e) => {
|
||||
const audioData = e.data
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(audioData)
|
||||
}
|
||||
}
|
||||
|
||||
sourceNode.connect(audioWorkletNode)
|
||||
audioWorkletNode.connect(audioContext.destination)
|
||||
|
||||
isRecording.value = true
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
sourceNode?.disconnect()
|
||||
audioWorkletNode?.disconnect()
|
||||
audioContext?.close()
|
||||
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send('[end]')
|
||||
socket.close()
|
||||
}
|
||||
|
||||
audioContext = null
|
||||
sourceNode = null
|
||||
audioWorkletNode = null
|
||||
socket = null
|
||||
|
||||
isRecording.value = false
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
stopRecording()
|
||||
recognizedText.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recognizedText,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user