393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
/*
|
||
* noVNC: HTML5 VNC client
|
||
* Audio streaming module for remote desktop audio playback
|
||
*
|
||
* 音频流模块 - 用于播放远程桌面的音频输出
|
||
*
|
||
* 功能:
|
||
* - 通过 WebSocket 接收 Opus 编码的音频数据
|
||
* - 使用 Web Audio API 解码并播放音频
|
||
* - 支持音量控制和连接状态管理
|
||
*/
|
||
|
||
import * as Log from '../core/util/logging.js';
|
||
|
||
// 音频管理器类
|
||
class AudioManager {
|
||
constructor() {
|
||
// WebSocket 连接
|
||
this.ws = null;
|
||
this.connected = false;
|
||
|
||
// Web Audio API 组件
|
||
this.audioContext = null;
|
||
this.gainNode = null;
|
||
this.decoder = null;
|
||
|
||
// 播放调度
|
||
this.nextPlayTime = 0;
|
||
|
||
// 配置
|
||
this.volume = 0.8; // 默认音量 80%
|
||
this.sampleRate = 48000;
|
||
this.channels = 2;
|
||
|
||
// 重试配置
|
||
this.maxRetries = 5; // 最大重试次数
|
||
this.retryCount = 0; // 当前重试次数
|
||
this.retryDelay = 2000; // 重试间隔 (ms)
|
||
this.retryTimer = null; // 重试定时器
|
||
this.lastWsUrl = null; // 上次连接的URL(用于重试)
|
||
this.autoRetryEnabled = true; // 是否启用自动重试
|
||
|
||
// 回调函数
|
||
this.onStatusChange = null;
|
||
this.onMaxRetriesReached = null; // 达到最大重试次数的回调
|
||
|
||
// OpusDecoder 加载状态
|
||
this.opusDecoderReady = false;
|
||
this._checkOpusDecoder();
|
||
}
|
||
|
||
/**
|
||
* 检查 OpusDecoder 是否已加载
|
||
* @private
|
||
*/
|
||
_checkOpusDecoder() {
|
||
// opus-decoder 库使用 UMD 模式,导出为 window["opus-decoder"].OpusDecoder
|
||
const opusDecoderLib = window["opus-decoder"];
|
||
if (opusDecoderLib && opusDecoderLib.OpusDecoder) {
|
||
window.OpusDecoder = opusDecoderLib.OpusDecoder;
|
||
this.opusDecoderReady = true;
|
||
Log.Info('[Audio] OpusDecoder 加载成功');
|
||
} else {
|
||
Log.Warn('[Audio] OpusDecoder 未加载,音频功能可能无法使用');
|
||
this.opusDecoderReady = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 构建音频 WebSocket URL
|
||
* @param {string} baseUrl - 基础 URL (如 http://127.0.0.1:8088)
|
||
* @param {string} projectId - 项目 ID
|
||
* @param {string} userId - 用户 ID (可选,调试模式使用)
|
||
* @param {boolean} debugMode - 是否调试模式
|
||
* @returns {string} WebSocket URL
|
||
*/
|
||
buildWsUrl(baseUrl, projectId, userId = null, debugMode = false) {
|
||
// 将 http/https 转换为 ws/wss
|
||
const wsUrl = baseUrl
|
||
.replace(/^http/, 'ws')
|
||
.replace(/\/+$/, '');
|
||
|
||
// 调试模式:包含 user_id
|
||
// 正式模式:不包含 user_id
|
||
if (debugMode && userId) {
|
||
return `ws://192.168.1.34:8088/computer/audio/${userId}/${projectId}/ws`;
|
||
} else {
|
||
return `${wsUrl}/computer/audio/${projectId}/ws`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 连接音频流
|
||
* @param {string} wsUrl - WebSocket URL
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async connect(wsUrl) {
|
||
if (this.connected) {
|
||
Log.Warn('[Audio] 已经连接,请先断开');
|
||
return;
|
||
}
|
||
|
||
// 检查 OpusDecoder
|
||
if (!this.opusDecoderReady) {
|
||
this._checkOpusDecoder();
|
||
if (!this.opusDecoderReady) {
|
||
Log.Error('[Audio] OpusDecoder 未加载,无法启动音频');
|
||
this._updateStatus('error', 'OpusDecoder 未加载');
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
this._updateStatus('connecting', '音频连接中...');
|
||
Log.Info('[Audio] 连接音频流: ' + wsUrl);
|
||
|
||
// 初始化 Web Audio API
|
||
await this._initAudioContext();
|
||
|
||
// 保存URL用于重试
|
||
this.lastWsUrl = wsUrl;
|
||
|
||
// 连接 WebSocket
|
||
this.ws = new WebSocket(wsUrl);
|
||
this.ws.binaryType = 'arraybuffer';
|
||
|
||
this.ws.onopen = () => {
|
||
Log.Info('[Audio] WebSocket 已连接');
|
||
this.connected = true;
|
||
this.retryCount = 0; // 连接成功,重置重试计数
|
||
this._updateStatus('connected', '音频已连接');
|
||
};
|
||
|
||
this.ws.onmessage = async (event) => {
|
||
try {
|
||
const data = new Uint8Array(event.data);
|
||
// 检查协议头 (0x01 表示 Opus 音频)
|
||
if (data[0] === 0x01) {
|
||
const opusData = data.slice(1);
|
||
await this._playAudioChunk(opusData);
|
||
}
|
||
} catch (err) {
|
||
Log.Error('[Audio] 处理音频数据失败: ' + err);
|
||
}
|
||
};
|
||
|
||
this.ws.onerror = (err) => {
|
||
Log.Error('[Audio] WebSocket 错误: ' + err);
|
||
this._updateStatus('error', '音频连接错误');
|
||
};
|
||
|
||
this.ws.onclose = () => {
|
||
Log.Info('[Audio] WebSocket 已关闭');
|
||
this.connected = false;
|
||
this._cleanup();
|
||
|
||
// 自动重试逻辑
|
||
if (this.autoRetryEnabled && this.retryCount < this.maxRetries) {
|
||
this.retryCount++;
|
||
Log.Info('[Audio] 将在 ' + (this.retryDelay / 1000) + ' 秒后重试 (' + this.retryCount + '/' + this.maxRetries + ')');
|
||
this._updateStatus('connecting', '重试中 (' + this.retryCount + '/' + this.maxRetries + ')');
|
||
|
||
this.retryTimer = setTimeout(() => {
|
||
if (this.lastWsUrl && this.autoRetryEnabled) {
|
||
this.connect(this.lastWsUrl);
|
||
}
|
||
}, this.retryDelay);
|
||
} else if (this.retryCount >= this.maxRetries) {
|
||
Log.Warn('[Audio] 已达到最大重试次数 (' + this.maxRetries + ')');
|
||
this._updateStatus('error', '连接失败,请手动重试');
|
||
if (this.onMaxRetriesReached) {
|
||
this.onMaxRetriesReached();
|
||
}
|
||
} else {
|
||
this._updateStatus('disconnected', '音频已断开');
|
||
}
|
||
};
|
||
|
||
} catch (err) {
|
||
Log.Error('[Audio] 连接失败: ' + err);
|
||
this._updateStatus('error', '音频启动失败');
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 断开音频流
|
||
*/
|
||
disconnect() {
|
||
// 禁用自动重试
|
||
this.autoRetryEnabled = false;
|
||
|
||
// 清除重试定时器
|
||
if (this.retryTimer) {
|
||
clearTimeout(this.retryTimer);
|
||
this.retryTimer = null;
|
||
}
|
||
|
||
if (this.ws) {
|
||
this.ws.close();
|
||
this.ws = null;
|
||
}
|
||
this.connected = false;
|
||
this.retryCount = 0;
|
||
this._cleanup();
|
||
this._updateStatus('disconnected', '音频已停止');
|
||
Log.Info('[Audio] 音频流已断开');
|
||
}
|
||
|
||
/**
|
||
* 设置音量
|
||
* @param {number} volume - 音量值 (0-1)
|
||
*/
|
||
setVolume(volume) {
|
||
this.volume = Math.max(0, Math.min(1, volume));
|
||
if (this.gainNode) {
|
||
this.gainNode.gain.value = this.volume;
|
||
}
|
||
Log.Debug('[Audio] 音量设置为: ' + Math.round(this.volume * 100) + '%');
|
||
}
|
||
|
||
/**
|
||
* 获取当前音量
|
||
* @returns {number} 音量值 (0-1)
|
||
*/
|
||
getVolume() {
|
||
return this.volume;
|
||
}
|
||
|
||
/**
|
||
* 初始化 Web Audio API
|
||
* @private
|
||
*/
|
||
async _initAudioContext() {
|
||
if (!this.audioContext) {
|
||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: this.sampleRate,
|
||
latencyHint: 'interactive'
|
||
});
|
||
|
||
this.gainNode = this.audioContext.createGain();
|
||
this.gainNode.gain.value = this.volume;
|
||
this.gainNode.connect(this.audioContext.destination);
|
||
|
||
this.nextPlayTime = this.audioContext.currentTime + 0.1;
|
||
}
|
||
|
||
// 恢复 AudioContext(浏览器安全策略)
|
||
if (this.audioContext.state === 'suspended') {
|
||
try {
|
||
await this.audioContext.resume();
|
||
Log.Info('[Audio] AudioContext 已恢复');
|
||
} catch (e) {
|
||
Log.Warn('[Audio] AudioContext 自动恢复失败,等待用户交互: ' + e);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 尝试恢复 AudioContext
|
||
*/
|
||
async resume() {
|
||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||
try {
|
||
await this.audioContext.resume();
|
||
Log.Info('[Audio] AudioContext 已恢复');
|
||
} catch (e) {
|
||
Log.Error('[Audio] 恢复失败: ' + e);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 播放音频数据块
|
||
* @param {Uint8Array} opusData - Opus 编码的音频数据
|
||
* @private
|
||
*/
|
||
async _playAudioChunk(opusData) {
|
||
try {
|
||
// 初始化或复用 Opus 解码器
|
||
if (!this.decoder) {
|
||
Log.Debug('[Audio] 初始化 OpusDecoder...');
|
||
this.decoder = new window.OpusDecoder({
|
||
sampleRate: this.sampleRate,
|
||
channels: this.channels
|
||
});
|
||
await this.decoder.ready;
|
||
Log.Debug('[Audio] OpusDecoder 初始化完成');
|
||
}
|
||
|
||
// 解码 Opus 数据
|
||
const decoded = this.decoder.decodeFrame(opusData);
|
||
const channels = decoded.channelData;
|
||
const samplesDecoded = decoded.samplesDecoded;
|
||
|
||
if (!channels || channels.length < 2) {
|
||
Log.Warn('[Audio] 输出声道数据无效');
|
||
return;
|
||
}
|
||
|
||
const leftChannelData = channels[0];
|
||
const rightChannelData = channels[1];
|
||
|
||
// 创建 AudioBuffer
|
||
const audioBuffer = this.audioContext.createBuffer(
|
||
2, samplesDecoded, this.sampleRate
|
||
);
|
||
|
||
// 填充声道数据
|
||
audioBuffer.getChannelData(0).set(leftChannelData);
|
||
audioBuffer.getChannelData(1).set(rightChannelData);
|
||
|
||
// 调度播放
|
||
const source = this.audioContext.createBufferSource();
|
||
source.buffer = audioBuffer;
|
||
source.connect(this.gainNode);
|
||
|
||
// 计算播放时间
|
||
const currentTime = this.audioContext.currentTime;
|
||
if (this.nextPlayTime < currentTime) {
|
||
this.nextPlayTime = currentTime + 0.05; // 50ms 缓冲
|
||
}
|
||
|
||
source.start(this.nextPlayTime);
|
||
|
||
// 计算下一个播放时间点
|
||
const duration = samplesDecoded / this.sampleRate;
|
||
this.nextPlayTime += duration;
|
||
|
||
} catch (err) {
|
||
Log.Error('[Audio] 解码或播放失败: ' + err);
|
||
// 解码失败时重置解码器
|
||
if (this.decoder) {
|
||
try {
|
||
this.decoder.free();
|
||
} catch (e) {
|
||
// 忽略释放错误
|
||
}
|
||
this.decoder = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清理资源
|
||
* @private
|
||
*/
|
||
_cleanup() {
|
||
// 重置播放调度
|
||
this.nextPlayTime = 0;
|
||
|
||
// 释放解码器
|
||
if (this.decoder) {
|
||
try {
|
||
this.decoder.free();
|
||
} catch (e) {
|
||
// 忽略释放错误
|
||
}
|
||
this.decoder = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新状态并触发回调
|
||
* @param {string} status - 状态 (connecting/connected/disconnected/error)
|
||
* @param {string} message - 状态消息
|
||
* @private
|
||
*/
|
||
_updateStatus(status, message) {
|
||
if (this.onStatusChange) {
|
||
this.onStatusChange(status, message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 销毁音频管理器
|
||
*/
|
||
destroy() {
|
||
this.disconnect();
|
||
if (this.audioContext) {
|
||
this.audioContext.close();
|
||
this.audioContext = null;
|
||
}
|
||
this.gainNode = null;
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
const audioManager = new AudioManager();
|
||
export default audioManager;
|
||
|