projects/shelled/rustdesk-web-client/js/audio.js
Z User 7b758ebe01 Add standalone RustDesk web client (reverse-engineered)
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/
2026-04-06 21:11:21 +00:00

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
};
})();