Files
qiming/qiming-noVNC/app/audio.js
2026-06-01 13:15:17 +08:00

393 lines
12 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.
/*
* 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;