171 lines
5.9 KiB
JavaScript
171 lines
5.9 KiB
JavaScript
import { WebSocketServer, WebSocket } from 'ws'
|
||
import { createMicrophoneGrpcClient, createSpeakerGrpcClient } from './grpcClient.js'
|
||
|
||
// 设备实际返回 48kHz;上下行统一使用 48kHz,避免设备按固定采样率解释 44.1kHz 数据。
|
||
const SAMPLE_RATE = 48000
|
||
const CHANNELS = 2
|
||
const MAX_BUFFERED_BYTES = SAMPLE_RATE * CHANNELS * 2 * 2
|
||
|
||
function sendJson(socket, payload) {
|
||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload))
|
||
}
|
||
|
||
function errorText(error) {
|
||
return error?.details || error?.message || '机器人音频服务异常'
|
||
}
|
||
|
||
function waitForGrpcReady(client, timeoutMs = 5000) {
|
||
return new Promise((resolve, reject) => {
|
||
client.waitForReady(Date.now() + timeoutMs, (error) => {
|
||
if (error) reject(error)
|
||
else resolve()
|
||
})
|
||
})
|
||
}
|
||
|
||
export function attachAudioWebSocket(httpServer) {
|
||
const wss = new WebSocketServer({ server: httpServer, path: '/audio' })
|
||
|
||
wss.on('connection', (socket) => {
|
||
let initialized = false
|
||
let speakerId = ''
|
||
let microphoneStream
|
||
let speakerStream
|
||
let microphoneClient
|
||
let speakerClient
|
||
let remoteSampleRate = SAMPLE_RATE
|
||
// 设为 0,确保首帧一定向浏览器发送真实声道数。
|
||
let remoteChannels = 0
|
||
let hasLoggedRemoteFormat = false
|
||
let grpcReady = false
|
||
let hasFailed = false
|
||
let closing = false
|
||
|
||
const closeGrpc = () => {
|
||
closing = true
|
||
microphoneStream?.cancel()
|
||
microphoneStream = undefined
|
||
speakerStream?.end()
|
||
speakerStream = undefined
|
||
microphoneClient?.close()
|
||
speakerClient?.close()
|
||
microphoneClient = undefined
|
||
speakerClient = undefined
|
||
}
|
||
|
||
const fail = (error) => {
|
||
if (hasFailed || closing) return
|
||
hasFailed = true
|
||
console.error('[audio] gRPC error:', error)
|
||
sendJson(socket, {
|
||
type: grpcReady ? 'error' : 'connection-failed',
|
||
message: grpcReady
|
||
? errorText(error)
|
||
: `无法连接机器人音频服务:${errorText(error)}`
|
||
})
|
||
closeGrpc()
|
||
}
|
||
|
||
socket.on('message', (data, isBinary) => {
|
||
if (isBinary) {
|
||
if (!initialized || !speakerStream || speakerStream.destroyed) return
|
||
const pcm = Buffer.from(data)
|
||
if (!pcm.length || pcm.length % 2 !== 0) return
|
||
speakerStream.write({
|
||
header: { device_id: speakerId },
|
||
audio: {
|
||
data: pcm,
|
||
sample_rate: SAMPLE_RATE,
|
||
channels: CHANNELS,
|
||
format: 'PCM',
|
||
codec: 'pcm_s16le',
|
||
// nb_samples 表示每个声道的采样帧数,不是所有声道样本总数。
|
||
nb_samples: pcm.length / (2 * CHANNELS)
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
let message
|
||
try {
|
||
message = JSON.parse(data.toString())
|
||
} catch {
|
||
sendJson(socket, { type: 'error', message: '无效的控制消息' })
|
||
return
|
||
}
|
||
|
||
if (message.type === 'stop') {
|
||
closeGrpc()
|
||
socket.close(1000, 'call ended')
|
||
return
|
||
}
|
||
if (message.type !== 'init' || initialized) return
|
||
|
||
const { microphoneId, robotAddress } = message
|
||
speakerId = message.speakerId?.trim()
|
||
if (![microphoneId, speakerId, robotAddress].every((value) => typeof value === 'string' && value.trim())) {
|
||
sendJson(socket, { type: 'error', message: '机器人地址、麦克风 ID 和扬声器 ID 不能为空' })
|
||
socket.close(1008, 'invalid init')
|
||
return
|
||
}
|
||
|
||
initialized = true
|
||
microphoneClient = createMicrophoneGrpcClient(robotAddress.trim())
|
||
speakerClient = createSpeakerGrpcClient(robotAddress.trim())
|
||
|
||
// 只有两条 gRPC 通道都真正可用后,前端才进入“通话中”状态。
|
||
Promise.all([
|
||
waitForGrpcReady(microphoneClient),
|
||
waitForGrpcReady(speakerClient)
|
||
]).then(() => {
|
||
if (hasFailed || socket.readyState !== WebSocket.OPEN) return
|
||
grpcReady = true
|
||
sendJson(socket, { type: 'ready', sampleRate: SAMPLE_RATE, channels: CHANNELS })
|
||
}).catch(fail)
|
||
|
||
// 现有 proto 用一条服务端流和一条客户端流共同组成全双工音频通道。
|
||
microphoneStream = microphoneClient.streamAudio({
|
||
header: { device_id: microphoneId.trim() }
|
||
})
|
||
speakerStream = speakerClient.streamAudio((error, response) => {
|
||
if (error) fail(error)
|
||
else if (response?.header?.success === false) {
|
||
fail(new Error(response.header.error_message || '机器人扬声器拒绝音频流'))
|
||
}
|
||
})
|
||
|
||
microphoneStream.on('data', (frame) => {
|
||
const audio = frame?.audio
|
||
if (!audio?.data?.length || socket.readyState !== WebSocket.OPEN) return
|
||
if (!hasLoggedRemoteFormat) {
|
||
hasLoggedRemoteFormat = true
|
||
}
|
||
const frameSampleRate = Number(audio.sample_rate) || SAMPLE_RATE
|
||
const frameChannels = Number(audio.channels) || 1
|
||
if (frameSampleRate !== remoteSampleRate || frameChannels !== remoteChannels) {
|
||
remoteSampleRate = frameSampleRate
|
||
remoteChannels = frameChannels
|
||
// WebSocket 保证消息顺序,浏览器会先收到格式通知,再收到对应二进制帧。
|
||
sendJson(socket, {
|
||
type: 'audio-format',
|
||
sampleRate: remoteSampleRate,
|
||
channels: remoteChannels
|
||
})
|
||
}
|
||
// 浏览器播放跟不上时丢弃新帧,防止内存持续增长。
|
||
if (socket.bufferedAmount <= MAX_BUFFERED_BYTES) socket.send(audio.data, { binary: true })
|
||
})
|
||
microphoneStream.on('error', (error) => {
|
||
if (error.code !== 1) fail(error) // CANCELLED(1) 是正常清理
|
||
})
|
||
microphoneStream.on('end', () => sendJson(socket, { type: 'remote-ended' }))
|
||
speakerStream.on('error', fail)
|
||
})
|
||
|
||
socket.on('close', closeGrpc)
|
||
socket.on('error', (error) => console.error('[audio] WebSocket error:', error))
|
||
})
|
||
|
||
return wss
|
||
}
|