Complete browser-based remote desktop client built from protocol analysis: - Protobuf message definitions (75+ message types, 10 enums) - NaCl encryption (Ed25519 + Curve25519 ECDH + XSalsa20-Poly1305) - WebSocket signaling (hbbs) + relay (hbbr) connection lifecycle - VP8/VP9/AV1 video decoding via ogvjs WASM + yuv-canvas WebGL - Opus audio decoding via libopus WASM + Web Audio API - Full mouse/keyboard input forwarding to protobuf events - Connection dialog, status bar, toolbar, log panel UI Also tracked web_deps (codec libraries) in rustdesk-as-ref/
261 lines
7.0 KiB
JavaScript
261 lines
7.0 KiB
JavaScript
/**
|
|
* RustDesk Standalone Web Client - Audio Decoder
|
|
*
|
|
* Handles Opus audio decoding using libopus WASM and playback
|
|
* through the Web Audio API.
|
|
*/
|
|
|
|
const RDAudio = (() => {
|
|
let _audioContext = null;
|
|
let _opusDecoder = null;
|
|
let _sampleRate = 48000;
|
|
let _channels = 2;
|
|
let _gainNode = null;
|
|
let _isMuted = false;
|
|
let _volume = 1.0;
|
|
let _isEnabled = true;
|
|
let _nextPlayTime = 0;
|
|
let _initialized = false;
|
|
|
|
/**
|
|
* Initialize audio subsystem
|
|
*/
|
|
async function init() {
|
|
if (_initialized) return true;
|
|
|
|
try {
|
|
_audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
sampleRate: _sampleRate,
|
|
latencyHint: 'interactive'
|
|
});
|
|
|
|
_gainNode = _audioContext.createGain();
|
|
_gainNode.connect(_audioContext.destination);
|
|
_gainNode.gain.value = _volume;
|
|
|
|
// Initialize libopus decoder
|
|
await initOpusDecoder();
|
|
|
|
_initialized = true;
|
|
console.log('[RDAudio] Initialized. Sample rate:', _sampleRate, 'Channels:', _channels);
|
|
return true;
|
|
} catch (e) {
|
|
console.error('[RDAudio] Initialization failed:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the Opus decoder from libopus WASM
|
|
*/
|
|
async function initOpusDecoder() {
|
|
if (typeof OpusDecoder === 'undefined') {
|
|
console.warn('[RDAudio] OpusDecoder not available, audio disabled');
|
|
_isEnabled = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
_opusDecoder = new OpusDecoder();
|
|
// Initialize with sample rate and channels
|
|
const initResult = await _opusDecoder.init(_sampleRate, _channels);
|
|
if (initResult !== 0) {
|
|
console.error('[RDAudio] Opus decoder init failed:', initResult);
|
|
_opusDecoder = null;
|
|
_isEnabled = false;
|
|
return;
|
|
}
|
|
console.log('[RDAudio] Opus decoder initialized');
|
|
} catch (e) {
|
|
console.warn('[RDAudio] Opus decoder creation failed:', e);
|
|
_opusDecoder = null;
|
|
_isEnabled = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming AudioFrame from the session
|
|
* @param {Object} audioFrame - Decoded protobuf AudioFrame
|
|
*/
|
|
function handleAudioFrame(audioFrame) {
|
|
if (!_isEnabled || !_audioContext) return;
|
|
if (_audioContext.state === 'suspended') {
|
|
_audioContext.resume();
|
|
}
|
|
|
|
if (!audioFrame.data || audioFrame.data.byteLength === 0) return;
|
|
|
|
try {
|
|
if (_opusDecoder) {
|
|
decodeAndPlayOpus(audioFrame.data);
|
|
} else {
|
|
// Fallback: try to play raw PCM data
|
|
playPCMData(audioFrame.data);
|
|
}
|
|
} catch (e) {
|
|
console.error('[RDAudio] Audio decode error:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode Opus data and play through Web Audio API
|
|
*/
|
|
function decodeAndPlayOpus(opusData) {
|
|
if (!_opusDecoder || !_audioContext) return;
|
|
|
|
const inputData = new Uint8Array(opusData.buffer || opusData);
|
|
|
|
// Decode Opus to PCM
|
|
const pcmBuffer = _opusDecoder.decode(inputData);
|
|
|
|
if (pcmBuffer && pcmBuffer.length > 0) {
|
|
playPCMData(pcmBuffer);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play PCM data through Web Audio API
|
|
* @param {Float32Array|Uint8Array} pcmData - PCM audio data
|
|
*/
|
|
function playPCMData(pcmData) {
|
|
if (!_audioContext || _isMuted) return;
|
|
|
|
let float32Data;
|
|
|
|
if (pcmData instanceof Float32Array) {
|
|
float32Data = pcmData;
|
|
} else {
|
|
// Convert bytes to Float32
|
|
const dataView = new DataView(pcmData.buffer || pcmData);
|
|
float32Data = new Float32Array(dataView.byteLength / 4);
|
|
for (let i = 0; i < float32Data.length; i++) {
|
|
float32Data[i] = dataView.getFloat32(i * 4, true); // Little-endian
|
|
}
|
|
}
|
|
|
|
const frameCount = float32Data.length / _channels;
|
|
if (frameCount === 0) return;
|
|
|
|
// Create AudioBuffer
|
|
const audioBuffer = _audioContext.createBuffer(_channels, frameCount, _sampleRate);
|
|
|
|
for (let ch = 0; ch < _channels; ch++) {
|
|
const channelData = audioBuffer.getChannelData(ch);
|
|
for (let i = 0; i < frameCount; i++) {
|
|
channelData[i] = float32Data[i * _channels + ch];
|
|
}
|
|
}
|
|
|
|
// Schedule playback
|
|
schedulePlayback(audioBuffer);
|
|
}
|
|
|
|
/**
|
|
* Schedule audio buffer playback with gapless transition
|
|
*/
|
|
function schedulePlayback(audioBuffer) {
|
|
const currentTime = _audioContext.currentTime;
|
|
const startTime = Math.max(currentTime, _nextPlayTime);
|
|
|
|
const source = _audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
source.connect(_gainNode);
|
|
|
|
source.onended = () => {
|
|
source.disconnect();
|
|
};
|
|
|
|
source.start(startTime);
|
|
_nextPlayTime = startTime + audioBuffer.duration;
|
|
}
|
|
|
|
/**
|
|
* Set audio format from server's AudioFormat message
|
|
*/
|
|
function setFormat(sampleRate, channels) {
|
|
if (sampleRate > 0) _sampleRate = sampleRate;
|
|
if (channels > 0) _channels = channels;
|
|
console.log('[RDAudio] Format updated:', _sampleRate, 'Hz,', _channels, 'channels');
|
|
}
|
|
|
|
/**
|
|
* Set volume (0.0 to 1.0)
|
|
*/
|
|
function setVolume(volume) {
|
|
_volume = Math.max(0, Math.min(1, volume));
|
|
if (_gainNode) {
|
|
_gainNode.gain.value = _volume;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mute/unmute audio
|
|
*/
|
|
function setMuted(muted) {
|
|
_isMuted = muted;
|
|
if (_gainNode) {
|
|
_gainNode.gain.value = muted ? 0 : _volume;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle mute
|
|
*/
|
|
function toggleMute() {
|
|
setMuted(!_isMuted);
|
|
return _isMuted;
|
|
}
|
|
|
|
/**
|
|
* Get mute state
|
|
*/
|
|
function isMuted() {
|
|
return _isMuted;
|
|
}
|
|
|
|
/**
|
|
* Check if audio is enabled and initialized
|
|
*/
|
|
function isEnabled() {
|
|
return _isEnabled;
|
|
}
|
|
|
|
/**
|
|
* Resume audio context (needed after user gesture)
|
|
*/
|
|
async function resume() {
|
|
if (_audioContext && _audioContext.state === 'suspended') {
|
|
await _audioContext.resume();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up audio resources
|
|
*/
|
|
function cleanup() {
|
|
if (_opusDecoder) {
|
|
try { _opusDecoder.destroy(); } catch (e) {}
|
|
_opusDecoder = null;
|
|
}
|
|
if (_audioContext) {
|
|
_audioContext.close().catch(() => {});
|
|
_audioContext = null;
|
|
}
|
|
_initialized = false;
|
|
_nextPlayTime = 0;
|
|
}
|
|
|
|
return {
|
|
init,
|
|
handleAudioFrame,
|
|
setFormat,
|
|
setVolume,
|
|
setMuted,
|
|
toggleMute,
|
|
isMuted,
|
|
isEnabled,
|
|
resume,
|
|
cleanup
|
|
};
|
|
})();
|