- commit
- 40d1df9d562378b386ea8049f43a494d893cb904
- parent
- 2371dd2c77ac201560dcea44dc04c07534ead37e
- author
- Dami <dami.etoile@gmail.com>
| M | portal/ | 34 | +++ |
| M | portal/ | 53 | ++++- |
| M | portal/ | 381 | ++++++++++++++---------------- |
| M | portal/ | 14 | + |
| M | portal/ | 30 | ++- |
@@ -1,3 +1,37 @@
1+import { BoundedAudioMessageTracker } from './audio-ring.mjs';2+3+export function newAudioSession({ muted = false } = {}) {4+ return {5+ expectedGeneration: null,6+ acceptsNativeEvents: false,7+ pendingTerminalStates: [],8+ failed: false,9+ failureDetail: null,10+ stopRequested: false,11+ channel: null,12+ context: null,13+ decoder: null,14+ workletNode: null,15+ available: false,16+ muted,17+ state: 'unavailable',18+ stateDetail: 'not connected',19+ packetsReceived: 0,20+ decoderDropped: 0,21+ bufferedFrames: 0,22+ underflows: 0,23+ underflowDurationMicros: 0,24+ silentDurationMicros: 0,25+ transportDropped: 0,26+ frontendDropped: 0,27+ workletDroppedFrames: 0,28+ workletRecoveryDiscardedFrames: 0,29+ audioTimeline: null,30+ avSyncEpoch: 0,31+ messageTracker: new BoundedAudioMessageTracker(),32+ };33+}34+135 export function audioButtonPresentation({ available, muted, state, detail }) {236 const normalizedState = typeof state === 'string' && state.length > 0 ? state : 'unavailable';337 const normalizedDetail = typeof detail === 'string' && detail.length > 0@@ -1,7 +1,58 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 4-import { audioButtonPresentation } from './audio-ui.mjs';4+import { audioButtonPresentation, newAudioSession } from './audio-ui.mjs';5+6+test('new audio sessions pin all resource, channel, status, and telemetry defaults', () => {7+ const session = newAudioSession();8+9+ assert.deepEqual({10+ ...session,11+ messageTracker: undefined,12+ }, {13+ expectedGeneration: null,14+ acceptsNativeEvents: false,15+ pendingTerminalStates: [],16+ failed: false,17+ failureDetail: null,18+ stopRequested: false,19+ channel: null,20+ context: null,21+ decoder: null,22+ workletNode: null,23+ available: false,24+ muted: false,25+ state: 'unavailable',26+ stateDetail: 'not connected',27+ packetsReceived: 0,28+ decoderDropped: 0,29+ bufferedFrames: 0,30+ underflows: 0,31+ underflowDurationMicros: 0,32+ silentDurationMicros: 0,33+ transportDropped: 0,34+ frontendDropped: 0,35+ workletDroppedFrames: 0,36+ workletRecoveryDiscardedFrames: 0,37+ audioTimeline: null,38+ avSyncEpoch: 0,39+ messageTracker: undefined,40+ });41+ assert.equal(session.messageTracker.size, 0);42+ assert.equal(session.messageTracker.droppedMessages, 0);43+});44+45+test('new audio sessions preserve seeded mute state and isolate mutable state', () => {46+ const muted = newAudioSession({ muted: true });47+ const fresh = newAudioSession();48+49+ muted.pendingTerminalStates.push({ generation: 7 });50+ assert.equal(muted.muted, true);51+ assert.equal(fresh.muted, false);52+ assert.deepEqual(fresh.pendingTerminalStates, []);53+ assert.notEqual(muted.pendingTerminalStates, fresh.pendingTerminalStates);54+ assert.notEqual(muted.messageTracker, fresh.messageTracker);55+});556 657 test('audio button uses a speaker glyph with a mute action while playing', () => {758 assert.deepEqual(audioButtonPresentation({@@ -27,7 +27,7 @@ import {
2727 stageFrameAcknowledgment,2828 stageLegacyFrame,2929 } from './frame-session.mjs';30-import { parsePointerFeedbackMessage } from './pointer-feedback.mjs';30+import { newPointerSession, parsePointerFeedbackMessage } from './pointer-feedback.mjs';3131 import { newConnectionState } from './connection-state.mjs';3232 import {3333 audioMinusVideoSkewMs,@@ -45,8 +45,7 @@ import {
4545 stageAudioTerminalState,4646 takeAudioTerminalState,4747 } from './audio-envelope.mjs';48-import { BoundedAudioMessageTracker } from './audio-ring.mjs';49-import { audioButtonPresentation } from './audio-ui.mjs';48+import { audioButtonPresentation, newAudioSession } from './audio-ui.mjs';5049 import {5150 ControllerActivationGate,5251 ControllerActionRepeater,@@ -91,16 +90,11 @@ let fittedStreamAspect = null;
9190 let pendingStreamFit = null;9291 let lastObservedWindowSize = null;9392 let streamWindowResizeTimer = null;94-let pointerSurfaceDimensions = null;95-let remotePointerPosition = null;96-let remotePointerVisible = false;9793 let activeFrameChannel = null;9894 let activeFrameGeneration = null;9995 let activeFrameSession = null;100-let activeAudioChannel = null;101-let activePointerChannel = null;10296 let activePointerSession = null;103-let activeAudioSession = null;97+let activeAudioSession = newAudioSession();10498 let controllerActivationInProgress = false;10599 let controlTransitionInProgress = false;106100 let controlTransitionGeneration = 0;@@ -114,55 +108,14 @@ const AUDIO_DECODER_CONFIG = Object.freeze({
114108 numberOfChannels: 2,115109 });116110 const MAX_AUDIO_DECODE_QUEUE_SIZE = 3;117-let audioContext = null;118-let audioWorkletNode = null;119-let audioDecoder = null;120-let audioAvailable = false;121-let audioMuted = false;122-let audioState = 'unavailable';123-let audioStateDetail = 'not connected';124-let audioPacketsReceived = 0;125-let audioDecoderDropped = 0;126-let audioBufferedFrames = 0;127-let audioUnderflows = 0;128-let audioUnderflowDurationMicros = 0;129-let audioSilentDurationMicros = 0;130-let audioTransportDropped = 0;131-let audioFrontendDropped = 0;132-133-function newAudioSession() {134- return {135- expectedGeneration: null,136- pendingTerminalStates: [],137- failed: false,138- failureDetail: null,139- stopRequested: false,140- workletNode: null,141- workletDroppedFrames: 0,142- workletRecoveryDiscardedFrames: 0,143- audioTimeline: null,144- avSyncEpoch: 0,145- messageTracker: new BoundedAudioMessageTracker(),146- };147-}148-149-function newPointerSession() {150- return {151- received: false,152- latest: null,153- failed: false,154- failureDetail: null,155- closing: false,156- };157-}158111 159112 function applyPointerPositionFeedback(message, session) {160113 if (session !== activePointerSession || !connectionState.inputCapabilities.pointerPositionFeedback) return;161114 const surface = currentPointerSurfaceSize();162115 if (surface === null) return;163116 const feedback = validatePointerPositionFeedback(message, surface);164- remotePointerPosition = feedback.position;165- remotePointerVisible = feedback.pointer_visible;117+ session.remotePosition = feedback.position;118+ session.remoteVisible = feedback.pointer_visible;166119 renderRemotePointer();167120 }168121 @@ -175,8 +128,8 @@ function handlePointerPositionFeedback(message, session) {
175128 session.failureDetail = envelope.reason === 'eof'176129 ? 'Pointer feedback ended'177130 : 'Pointer feedback was malformed';178- remotePointerPosition = null;179- remotePointerVisible = false;131+ session.remotePosition = null;132+ session.remoteVisible = false;180133 renderRemotePointer();181134 console.error(session.failureDetail);182135 if (connectionState.connected) void disconnect();@@ -189,8 +142,8 @@ function handlePointerPositionFeedback(message, session) {
189142 } catch (error) {190143 session.failed = true;191144 session.failureDetail = `Pointer feedback failed: ${error}`;192- remotePointerPosition = null;193- remotePointerVisible = false;145+ session.remotePosition = null;146+ session.remoteVisible = false;194147 renderRemotePointer();195148 console.error('invalid pointer-position feedback:', error);196149 if (connectionState.connected) void disconnect();@@ -433,6 +386,7 @@ async function introConnect() {
433386 434387 // ─── Client ───────────────────────────────────────────────────────────────────435388 function updateAudioUI() {389+ const session = activeAudioSession;436390 const toggle = document.getElementById('audio-toggle');437391 const status = document.getElementById('stream-audio-state');438392 const webviewReceived = document.getElementById('stream-audio-webview-received-packets');@@ -447,71 +401,68 @@ function updateAudioUI() {
447401 const silence = document.getElementById('stream-audio-silence-ms');448402 if (toggle) {449403 const presentation = audioButtonPresentation({450- available: audioAvailable,451- muted: audioMuted,452- state: audioState,453- detail: audioStateDetail,404+ available: session.available,405+ muted: session.muted,406+ state: session.state,407+ detail: session.stateDetail,454408 });455409 toggle.textContent = presentation.glyph;456- toggle.classList.toggle('active', audioAvailable && !audioMuted);457- toggle.classList.toggle('disabled', !audioAvailable);458- toggle.setAttribute('aria-disabled', audioAvailable ? 'false' : 'true');410+ toggle.classList.toggle('active', session.available && !session.muted);411+ toggle.classList.toggle('disabled', !session.available);412+ toggle.setAttribute('aria-disabled', session.available ? 'false' : 'true');459413 toggle.setAttribute('aria-label', presentation.ariaLabel);460414 toggle.title = presentation.title;461415 }462416 if (status) {463- status.textContent = audioMuted && audioAvailable ? 'muted' : audioState;464- status.title = audioStateDetail;417+ status.textContent = session.muted && session.available ? 'muted' : session.state;418+ status.title = session.stateDetail;465419 }466- if (webviewReceived) webviewReceived.textContent = `${audioPacketsReceived} packets`;467- if (transportMissing) transportMissing.textContent = `${audioTransportDropped} packets`;468- if (ipcDropped) ipcDropped.textContent = `${audioFrontendDropped} packets`;469- if (decoderDropped) decoderDropped.textContent = `${audioDecoderDropped} packets`;420+ if (webviewReceived) webviewReceived.textContent = `${session.packetsReceived} packets`;421+ if (transportMissing) transportMissing.textContent = `${session.transportDropped} packets`;422+ if (ipcDropped) ipcDropped.textContent = `${session.frontendDropped} packets`;423+ if (decoderDropped) decoderDropped.textContent = `${session.decoderDropped} packets`;470424 if (pcmHandoffDropped) {471- const droppedBlocks = activeAudioSession?.messageTracker.droppedMessages ?? 0;472- pcmHandoffDropped.textContent = `${droppedBlocks} blocks`;425+ pcmHandoffDropped.textContent = `${session.messageTracker.droppedMessages} blocks`;473426 }474427 if (ringOverflow) {475- const droppedFrames = activeAudioSession?.workletDroppedFrames ?? 0;476- ringOverflow.textContent = `${(droppedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;428+ ringOverflow.textContent = `${(session.workletDroppedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;477429 }478430 if (ringRecoveryDiscarded) {479- const droppedFrames = activeAudioSession?.workletRecoveryDiscardedFrames ?? 0;480- ringRecoveryDiscarded.textContent = `${(droppedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;431+ ringRecoveryDiscarded.textContent = `${(session.workletRecoveryDiscardedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;481432 }482433 if (ringBuffered) {483- ringBuffered.textContent = `${(audioBufferedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;434+ ringBuffered.textContent = `${(session.bufferedFrames * 1000 / AUDIO_DECODER_CONFIG.sampleRate).toFixed(1)} ms`;484435 }485436 if (underruns) {486- underruns.textContent = `${audioUnderflows} events / ${(audioUnderflowDurationMicros / 1000).toFixed(1)} ms`;437+ underruns.textContent = `${session.underflows} events / ${(session.underflowDurationMicros / 1000).toFixed(1)} ms`;487438 }488- if (silence) silence.textContent = `${(audioSilentDurationMicros / 1000).toFixed(1)} ms`;439+ if (silence) silence.textContent = `${(session.silentDurationMicros / 1000).toFixed(1)} ms`;489440 }490441 491442 function setAudioState(state, detail = state) {492- audioState = state;493- audioStateDetail = detail;443+ activeAudioSession.state = state;444+ activeAudioSession.stateDetail = detail;494445 updateAudioUI();495446 }496447 497-function resetAudioTelemetry() {498- audioPacketsReceived = 0;499- audioDecoderDropped = 0;500- audioBufferedFrames = 0;501- audioUnderflows = 0;502- audioUnderflowDurationMicros = 0;503- audioSilentDurationMicros = 0;504- audioTransportDropped = 0;505- audioFrontendDropped = 0;506- videoPipeline.resetAvSync(activeAudioSession);448+function resetAudioTelemetry(session = activeAudioSession) {449+ session.packetsReceived = 0;450+ session.decoderDropped = 0;451+ session.bufferedFrames = 0;452+ session.underflows = 0;453+ session.underflowDurationMicros = 0;454+ session.silentDurationMicros = 0;455+ session.transportDropped = 0;456+ session.frontendDropped = 0;457+ videoPipeline.resetAvSync(session);507458 updateAudioUI();508459 }509460 510461 function configureAudioDecoder(session = activeAudioSession) {511- if (audioDecoder) {512- try { audioDecoder.close(); } catch (_) {}462+ if (session.decoder) {463+ try { session.decoder.close(); } catch (_) {}513464 }514- audioDecoder = new AudioDecoder({465+ session.decoder = new AudioDecoder({515466 output: (audioData) => {516467 try {517468 if (session !== activeAudioSession || session.failed) return;@@ -558,32 +509,33 @@ function configureAudioDecoder(session = activeAudioSession) {
558509 disableAudioForSession(`Opus decoder failed: ${error}`, session);559510 },560511 });561- audioDecoder.configure(AUDIO_DECODER_CONFIG);512+ session.decoder.configure(AUDIO_DECODER_CONFIG);562513 }563514 564515 function primeAudioOutputForActivation() {516+ const session = activeAudioSession;565517 const AudioContextConstructor = window.AudioContext || window.webkitAudioContext;566518 if (typeof AudioContextConstructor !== 'function') return;567- if (!audioContext || audioContext.state === 'closed') {519+ if (!session.context || session.context.state === 'closed') {568520 try {569- audioContext = new AudioContextConstructor({ latencyHint: 'interactive', sampleRate: 48000 });521+ session.context = new AudioContextConstructor({ latencyHint: 'interactive', sampleRate: 48000 });570522 } catch (_) {571523 return;572524 }573525 }574526 // This function is deliberately synchronous and is called before the first575527 // await in a click/keyboard connect handler, preserving WebKit user activation.576- void audioContext.resume().catch(() => {});528+ void session.context.resume().catch(() => {});577529 }578530 579531 async function initializeAudioPipeline(session) {580- if (audioDecoder) {581- try { audioDecoder.close(); } catch (_) {}582- audioDecoder = null;532+ if (session.decoder) {533+ try { session.decoder.close(); } catch (_) {}534+ session.decoder = null;583535 }584- if (audioWorkletNode) {585- try { audioWorkletNode.disconnect(); } catch (_) {}586- audioWorkletNode = null;536+ if (session.workletNode) {537+ try { session.workletNode.disconnect(); } catch (_) {}538+ session.workletNode = null;587539 }588540 session.messageTracker.clear();589541 if (typeof AudioDecoder !== 'function') {@@ -601,21 +553,20 @@ async function initializeAudioPipeline(session) {
601553 if (!support.supported) {602554 return { supported: false, error: 'WebCodecs Opus decoding is unsupported' };603555 }604- if (!audioContext) {556+ if (!session.context) {605557 return { supported: false, error: 'Web Audio output is unavailable' };606558 }607559 try {608- if (audioContext.sampleRate !== 48000) {609- throw new Error(`audio output opened at ${audioContext.sampleRate} Hz instead of 48000 Hz`);560+ if (session.context.sampleRate !== 48000) {561+ throw new Error(`audio output opened at ${session.context.sampleRate} Hz instead of 48000 Hz`);610562 }611- await audioContext.audioWorklet.addModule(new URL('./audio-worklet.js', import.meta.url));612- audioWorkletNode = new AudioWorkletNode(audioContext, 'sigil-audio-processor', {563+ await session.context.audioWorklet.addModule(new URL('./audio-worklet.js', import.meta.url));564+ session.workletNode = new AudioWorkletNode(session.context, 'sigil-audio-processor', {613565 numberOfInputs: 0,614566 numberOfOutputs: 1,615567 outputChannelCount: [2],616568 });617- session.workletNode = audioWorkletNode;618- audioWorkletNode.port.onmessage = (event) => {569+ session.workletNode.port.onmessage = (event) => {619570 if (event.data?.type === 'accepted') {620571 session.messageTracker.accept(event.data.id);621572 if (session === activeAudioSession) updateAudioUI();@@ -630,17 +581,17 @@ async function initializeAudioPipeline(session) {
630581 ? event.data.recoveryDiscardedFrames : session.workletRecoveryDiscardedFrames;631582 session.audioTimeline = audioOutputTimelineFromStats(event.data);632583 if (session !== activeAudioSession) return;633- audioBufferedFrames = Number.isSafeInteger(event.data.bufferedFrames)634- ? event.data.bufferedFrames : audioBufferedFrames;635- audioUnderflows = Number.isSafeInteger(event.data.underflows)636- ? event.data.underflows : audioUnderflows;637- audioUnderflowDurationMicros = Number.isFinite(event.data.underflowDurationMicros)584+ session.bufferedFrames = Number.isSafeInteger(event.data.bufferedFrames)585+ ? event.data.bufferedFrames : session.bufferedFrames;586+ session.underflows = Number.isSafeInteger(event.data.underflows)587+ ? event.data.underflows : session.underflows;588+ session.underflowDurationMicros = Number.isFinite(event.data.underflowDurationMicros)638589 && event.data.underflowDurationMicros >= 0639- ? event.data.underflowDurationMicros : audioUnderflowDurationMicros;640- audioSilentDurationMicros = Number.isFinite(event.data.silentDurationMicros)590+ ? event.data.underflowDurationMicros : session.underflowDurationMicros;591+ session.silentDurationMicros = Number.isFinite(event.data.silentDurationMicros)641592 && event.data.silentDurationMicros >= 0642- ? event.data.silentDurationMicros : audioSilentDurationMicros;643- if (audioAvailable && !audioMuted) {593+ ? event.data.silentDurationMicros : session.silentDurationMicros;594+ if (session.available && !session.muted) {644595 setAudioState(event.data.started ? 'playing' : 'priming', 'bounded Opus playback');645596 } else {646597 updateAudioUI();@@ -649,19 +600,19 @@ async function initializeAudioPipeline(session) {
649600 disableAudioForSession(`AudioWorklet failed: ${event.data.error}`, session);650601 }651602 };652- audioWorkletNode.port.onmessageerror = () => {603+ session.workletNode.port.onmessageerror = () => {653604 disableAudioForSession('AudioWorklet returned an unreadable message', session);654605 };655- audioWorkletNode.onprocessorerror = () => {606+ session.workletNode.onprocessorerror = () => {656607 disableAudioForSession('AudioWorklet processor stopped unexpectedly', session);657608 };658- audioWorkletNode.connect(audioContext.destination);659- audioWorkletNode.port.postMessage({ type: 'mute', muted: audioMuted });609+ session.workletNode.connect(session.context.destination);610+ session.workletNode.port.postMessage({ type: 'mute', muted: session.muted });660611 configureAudioDecoder(session);661- await audioContext.resume();612+ await session.context.resume();662613 setAudioState(663- audioContext.state === 'running' ? 'negotiating' : 'blocked',664- audioContext.state === 'running'614+ session.context.state === 'running' ? 'negotiating' : 'blocked',615+ session.context.state === 'running'665616 ? 'Opus output ready'666617 : 'WebKit suspended audio output; activate the audio button to retry',667618 );@@ -675,39 +626,41 @@ async function initializeAudioPipeline(session) {
675626 async function teardownAudioPipeline(resetStatus = true) {676627 const session = activeAudioSession;677628 videoPipeline.resetAvSync(session);678- activeAudioSession = null;679- activeAudioChannel = null;680- audioAvailable = false;681- if (session) {682- session.expectedGeneration = null;683- session.messageTracker.clear();684- session.workletNode = null;685- }686- if (audioDecoder) {687- try { audioDecoder.close(); } catch (_) {}688- audioDecoder = null;629+ const decoder = session.decoder;630+ const workletNode = session.workletNode;631+ const context = session.context;632+ // Replace the active owner before closing resources so delayed callbacks633+ // cannot mutate the disconnected UI or a successor session. Seed the next634+ // dormant session with the user's mute preference across reconnects.635+ activeAudioSession = newAudioSession({ muted: session.muted });636+ session.expectedGeneration = null;637+ session.channel = null;638+ session.available = false;639+ session.messageTracker.clear();640+ session.decoder = null;641+ session.workletNode = null;642+ session.context = null;643+ if (decoder) {644+ try { decoder.close(); } catch (_) {}689645 }690- if (audioWorkletNode) {691- try { audioWorkletNode.port.postMessage({ type: 'clear' }); } catch (_) {}692- try { audioWorkletNode.disconnect(); } catch (_) {}693- audioWorkletNode = null;646+ if (workletNode) {647+ try { workletNode.port.postMessage({ type: 'clear' }); } catch (_) {}648+ try { workletNode.disconnect(); } catch (_) {}694649 }695- const context = audioContext;696- audioContext = null;697650 if (context && context.state !== 'closed') {698651 try { await context.close(); } catch (_) {}699652 }700- audioBufferedFrames = 0;701- if (resetStatus) setAudioState('unavailable', 'not connected');653+ if (resetStatus) updateAudioUI();702654 }703655 704656 async function toggleAudioMute() {705- if (!audioAvailable) return;706- audioMuted = !audioMuted;657+ const session = activeAudioSession;658+ if (!session.available) return;659+ session.muted = !session.muted;707660 try {708- if (!audioMuted && audioContext?.state !== 'running') await audioContext?.resume();709- audioWorkletNode?.port.postMessage({ type: 'mute', muted: audioMuted });710- if (!audioMuted && audioContext?.state !== 'running') {661+ if (!session.muted && session.context?.state !== 'running') await session.context?.resume();662+ session.workletNode?.port.postMessage({ type: 'mute', muted: session.muted });663+ if (!session.muted && session.context?.state !== 'running') {711664 setAudioState('blocked', 'WebKit suspended audio output; click the audio button to retry');712665 } else {713666 updateAudioUI();@@ -717,6 +670,14 @@ async function toggleAudioMute() {
717670 }718671 }719672 673+function prepareAudioForConnection() {674+ activeAudioSession = newAudioSession({ muted: activeAudioSession.muted });675+ activeAudioSession.acceptsNativeEvents = true;676+ // Keep this synchronous and before createConnectionAttempt's first await so677+ // WebKit observes the originating click/keyboard user activation.678+ primeAudioOutputForActivation();679+}680+720681 async function createConnectionAttempt({ createChannel: Channel }) {721682 // Reset before opening the channel so early frames cannot race a722683 // post-connect reset and disappear from the live diagnostics.@@ -728,22 +689,18 @@ async function createConnectionAttempt({ createChannel: Channel }) {
728689 adaptiveFeedbackError = null;729690 adaptiveDecision = null;730691 resetStreamTelemetry();731- pointerSurfaceDimensions = null;732- remotePointerPosition = null;733- remotePointerVisible = false;734692 if (typeof Channel !== 'function') throw new Error('Tauri binary channels are unavailable');735- resetAudioTelemetry();736- const audioSession = newAudioSession();693+ const audioSession = activeAudioSession;694+ resetAudioTelemetry(audioSession);737695 const frameSession = newFrameSession();738696 const pointerSession = newPointerSession();739- activeAudioSession = audioSession;740697 activeFrameSession = frameSession;741698 activePointerSession = pointerSession;742699 const audioSupport = await initializeAudioPipeline(audioSession);743700 if (!audioSupport.supported) setAudioState('unavailable', audioSupport.error);744701 activeFrameChannel = new Channel((message) => handleBinaryFrameMessage(message, frameSession));745- activeAudioChannel = new Channel((message) => handleBinaryAudioMessage(message, audioSession));746- activePointerChannel = new Channel(702+ audioSession.channel = new Channel((message) => handleBinaryAudioMessage(message, audioSession));703+ pointerSession.channel = new Channel(747704 (message) => handlePointerPositionFeedback(message, pointerSession),748705 );749706 return {@@ -753,8 +710,8 @@ async function createConnectionAttempt({ createChannel: Channel }) {
753710 audioSupport,754711 connectionArgs: {755712 frameChannel: activeFrameChannel,756- audioChannel: activeAudioChannel,757- pointerChannel: activePointerChannel,713+ audioChannel: audioSession.channel,714+ pointerChannel: pointerSession.channel,758715 audioSupported: audioSupport.supported,759716 },760717 };@@ -767,16 +724,19 @@ function validateConnectedAttempt(result, attempt) {
767724 if (attempt.frameSession.failed) {768725 throw new Error(attempt.frameSession.failureDetail || 'host returned invalid frame data');769726 }770- attempt.pointerSurface = result.connected727+ attempt.pointerSession.surfaceDimensions = result.connected771728 ? validatePointerSurfaceDimensions(result.pointer_surface_dimensions)772729 : null;773730 attempt.pointerFeedbackAvailable = result.connected774731 && result.pointer_position_feedback_available === true;775- if (attempt.pointerFeedbackAvailable && attempt.pointerSurface === null) {732+ if (attempt.pointerFeedbackAvailable && attempt.pointerSession.surfaceDimensions === null) {776733 throw new Error('host offered pointer feedback without a native pointer surface');777734 }778735 attempt.initialPointerFeedback = attempt.pointerFeedbackAvailable && attempt.pointerSession.received779- ? validatePointerPositionFeedback(attempt.pointerSession.latest, attempt.pointerSurface)736+ ? validatePointerPositionFeedback(737+ attempt.pointerSession.latest,738+ attempt.pointerSession.surfaceDimensions,739+ )780740 : null;781741 }782742 @@ -798,21 +758,25 @@ function closeConnectionAttempt(attempt, { committed = false } = {}) {
798758 if (committed && attempt?.pointerSession) attempt.pointerSession.closing = true;799759 }800760 801-async function teardownConnectionAttempt() {761+async function teardownConnectionAttempt(attempt) {802762 activeFrameChannel = null;803- activePointerChannel = null;804- activePointerSession = null;763+ const pointerSession = attempt?.pointerSession ?? activePointerSession;764+ if (activePointerSession === pointerSession) activePointerSession = null;765+ if (pointerSession) pointerSession.channel = null;766+ if (attempt?.audioSession && attempt.audioSession !== activeAudioSession) {767+ attempt.audioSession.channel = null;768+ }805769 await teardownAudioPipeline();806770 }807771 808772 function updateAcceptedConnectionState({ attempt, state }) {809- pointerSurfaceDimensions = attempt.pointerSurface;773+ const session = attempt.pointerSession;810774 if (!state.inputCapabilities.pointerPositionFeedback) {811- remotePointerPosition = null;812- remotePointerVisible = false;775+ session.remotePosition = null;776+ session.remoteVisible = false;813777 } else if (attempt.initialPointerFeedback !== null) {814- remotePointerPosition = attempt.initialPointerFeedback.position;815- remotePointerVisible = attempt.initialPointerFeedback.pointer_visible;778+ session.remotePosition = attempt.initialPointerFeedback.position;779+ session.remoteVisible = attempt.initialPointerFeedback.pointer_visible;816780 }817781 controlMode = false;818782 clearPendingInput();@@ -831,13 +795,13 @@ async function showConnectedAttempt({ result, attempt }) {
831795 audioSession.failed = true;832796 audioSession.failureDetail = pendingAudioTerminal.error || 'Audio connection ended';833797 }834- audioAvailable = result.audio_available === true;835- if (audioAvailable && audioSession.failed) {798+ audioSession.available = result.audio_available === true;799+ if (audioSession.available && audioSession.failed) {836800 disableAudioForSession(audioSession.failureDetail || 'Audio output failed', audioSession);837- } else if (audioAvailable) {801+ } else if (audioSession.available) {838802 setAudioState(839- audioContext?.state === 'running' ? 'priming' : 'blocked',840- audioContext?.state === 'running'803+ audioSession.context?.state === 'running' ? 'priming' : 'blocked',804+ audioSession.context?.state === 'running'841805 ? 'Waiting for bounded Opus prebuffer'842806 : 'WebKit suspended audio output; activate the audio button to retry',843807 );@@ -887,22 +851,20 @@ async function prepareDisconnect() {
887851 888852 async function teardownConnectedResources() {889853 activeFrameChannel = null;890- activePointerChannel = null;854+ const pointerSession = activePointerSession;891855 activePointerSession = null;856+ if (pointerSession) pointerSession.channel = null;892857 await teardownAudioPipeline();893858 videoPipeline.teardown();894859 }895860 896861 async function showDisconnectedState() {897862 controlMode = false;898- pointerSurfaceDimensions = null;899863 fittedStreamAspect = null;900864 pendingStreamFit = null;901865 lastObservedWindowSize = null;902866 if (streamWindowResizeTimer !== null) clearTimeout(streamWindowResizeTimer);903867 streamWindowResizeTimer = null;904- remotePointerPosition = null;905- remotePointerVisible = false;906868 clearPendingInput();907869 updateControlUI();908870 updatePanelVisibility();@@ -924,7 +886,7 @@ async function showDisconnectedState() {
924886 const connectionState = newConnectionState({925887 invokeCommand: invoke,926888 createChannel: window.__TAURI__.core.Channel,927- beforeConnect: primeAudioOutputForActivation,889+ beforeConnect: prepareAudioForConnection,928890 createAttempt: createConnectionAttempt,929891 validateConnectedResult: validateConnectedAttempt,930892 activateAttempt: activateConnectedAttempt,@@ -1039,14 +1001,14 @@ function sampleAvSkew(videoPtsMicros, presentationTimeMs) {
10391001 || !session10401002 || session.failed10411003 || !session.audioTimeline1042- || !audioAvailable1043- || audioContext?.state !== 'running'1044- || typeof audioContext.getOutputTimestamp !== 'function'1004+ || !session.available1005+ || session.context?.state !== 'running'1006+ || typeof session.context.getOutputTimestamp !== 'function'10451007 ) return null;10461008 10471009 let outputTimestamp;10481010 try {1049- outputTimestamp = audioContext.getOutputTimestamp();1011+ outputTimestamp = session.context.getOutputTimestamp();10501012 } catch (_) {10511013 return null;10521014 }@@ -1413,10 +1375,10 @@ function disableAudioForSession(detail, session = activeAudioSession) {
14131375 videoPipeline.resetAvSync(session);14141376 session.failed = true;14151377 session.failureDetail = detail;1416- audioAvailable = false;1417- if (audioDecoder) {1418- try { audioDecoder.close(); } catch (_) {}1419- audioDecoder = null;1378+ session.available = false;1379+ if (session.decoder) {1380+ try { session.decoder.close(); } catch (_) {}1381+ session.decoder = null;14201382 }14211383 session.messageTracker.clear();14221384 try { session.workletNode?.port.postMessage({ type: 'clear' }); } catch (_) {}@@ -1443,19 +1405,19 @@ function handleBinaryAudioMessage(message, session) {
14431405 || !isCurrentAudioDelivery(packet, session.expectedGeneration)) return;14441406 14451407 try {1446- audioPacketsReceived++;1447- if (!audioDecoder || audioDecoder.state === 'closed') {1448- audioDecoderDropped++;1408+ session.packetsReceived++;1409+ if (!session.decoder || session.decoder.state === 'closed') {1410+ session.decoderDropped++;14491411 updateAudioUI();14501412 return;14511413 }1452- const queuedAudioPackets = audioDecoder.decodeQueueSize;1414+ const queuedAudioPackets = session.decoder.decodeQueueSize;14531415 if (packet.discontinuity || queuedAudioPackets >= MAX_AUDIO_DECODE_QUEUE_SIZE) {14541416 videoPipeline.resetAvSync(session, { recoverRing: true });1455- audioDecoderDropped += queuedAudioPackets;1417+ session.decoderDropped += queuedAudioPackets;14561418 configureAudioDecoder(session);14571419 }1458- audioDecoder.decode(new EncodedAudioChunk({1420+ session.decoder.decode(new EncodedAudioChunk({14591421 type: 'key',14601422 timestamp: packet.ptsMicros,14611423 duration: (packet.frameSamples * 1_000_000) / packet.sampleRate,@@ -1558,7 +1520,9 @@ listen('frame-error', (event) => {
15581520 15591521 listen('audio-state', (event) => {15601522 const session = activeAudioSession;1561- if (connectionState.disconnecting || !session || event.payload?.available !== false) return;1523+ if (connectionState.disconnecting1524+ || !session.acceptsNativeEvents1525+ || event.payload?.available !== false) return;15621526 if (session.expectedGeneration === null) {15631527 try {15641528 stageAudioTerminalState(session.pendingTerminalStates, event.payload);@@ -1572,14 +1536,12 @@ listen('audio-state', (event) => {
15721536 });15731537 15741538 listen('audio-stats', (event) => {1575- if (!isCurrentAudioGeneration(1576- event.payload?.generation,1577- activeAudioSession?.expectedGeneration,1578- )) return;1579- audioTransportDropped = Number.isSafeInteger(event.payload?.sequence_dropped_total)1580- ? event.payload.sequence_dropped_total : audioTransportDropped;1581- audioFrontendDropped = Number.isSafeInteger(event.payload?.frontend_dropped_total)1582- ? event.payload.frontend_dropped_total : audioFrontendDropped;1539+ const session = activeAudioSession;1540+ if (!isCurrentAudioGeneration(event.payload?.generation, session.expectedGeneration)) return;1541+ session.transportDropped = Number.isSafeInteger(event.payload?.sequence_dropped_total)1542+ ? event.payload.sequence_dropped_total : session.transportDropped;1543+ session.frontendDropped = Number.isSafeInteger(event.payload?.frontend_dropped_total)1544+ ? event.payload.frontend_dropped_total : session.frontendDropped;15831545 updateAudioUI();15841546 });15851547 @@ -1791,7 +1753,7 @@ async function releaseNativeCursorGrab(releaseGeneration) {
17911753 function currentPointerSurfaceSize() {17921754 const { width: frameWidth, height: frameHeight } = videoPipeline.format;17931755 return resolvePointerSurfaceSize(1794- pointerSurfaceDimensions,1756+ activePointerSession?.surfaceDimensions ?? null,17951757 frameWidth,17961758 frameHeight,17971759 connectionState.inputCapabilities.relativePointer,@@ -1799,21 +1761,22 @@ function currentPointerSurfaceSize() {
17991761 }18001762 18011763 function renderRemotePointer() {1764+ const session = activePointerSession;18021765 const surface = currentPointerSurfaceSize();18031766 const visible = controlMode18041767 && connectionState.inputCapabilities.relativePointer18051768 && connectionState.inputCapabilities.pointerPositionFeedback1806- && remotePointerPosition !== null1807- && remotePointerVisible1769+ && session?.remotePosition !== null1770+ && session?.remoteVisible18081771 && surface !== null;18091772 remotePointer.classList.toggle('visible', visible);18101773 if (!visible) return;18111774 const canvasRect = canvas.getBoundingClientRect();18121775 const mainRect = canvas.parentElement.getBoundingClientRect();18131776 remotePointer.style.left = `${canvasRect.left - mainRect.left1814- + (remotePointerPosition.x / surface.width) * canvasRect.width}px`;1777+ + (session.remotePosition.x / surface.width) * canvasRect.width}px`;18151778 remotePointer.style.top = `${canvasRect.top - mainRect.top1816- + (remotePointerPosition.y / surface.height) * canvasRect.height}px`;1779+ + (session.remotePosition.y / surface.height) * canvasRect.height}px`;18171780 }18181781 18191782 function releasePointerLock() {@@ -2,6 +2,20 @@ import { validatePointerPositionFeedback } from './input-state.mjs';
22 33 const TERMINAL_REASONS = new Set(['eof', 'malformed']);44 5+export function newPointerSession() {6+ return {7+ received: false,8+ latest: null,9+ failed: false,10+ failureDetail: null,11+ closing: false,12+ channel: null,13+ surfaceDimensions: null,14+ remotePosition: null,15+ remoteVisible: false,16+ };17+}18+519 export function parsePointerFeedbackMessage(value, surface = null) {620 if (value?.type === 'position') {721 return {@@ -1,7 +1,35 @@
11 import assert from 'node:assert/strict';22 import test from 'node:test';33 4-import { parsePointerFeedbackMessage } from './pointer-feedback.mjs';4+import { newPointerSession, parsePointerFeedbackMessage } from './pointer-feedback.mjs';5+6+test('new pointer sessions pin feedback, channel, surface, and render defaults', () => {7+ assert.deepEqual(newPointerSession(), {8+ received: false,9+ latest: null,10+ failed: false,11+ failureDetail: null,12+ closing: false,13+ channel: null,14+ surfaceDimensions: null,15+ remotePosition: null,16+ remoteVisible: false,17+ });18+});19+20+test('new pointer sessions isolate staged and rendered feedback state', () => {21+ const staged = newPointerSession();22+ const fresh = newPointerSession();23+ staged.received = true;24+ staged.latest = { sequence: 3, position: { x: 4, y: 5 }, pointer_visible: true };25+ staged.surfaceDimensions = { width: 1280, height: 800 };26+ staged.remotePosition = { x: 4, y: 5 };27+ staged.remoteVisible = true;28+29+ assert.deepEqual(fresh, newPointerSession());30+ assert.equal(staged.received, true);31+ assert.equal(staged.remoteVisible, true);32+});533 634 test('pointer feedback position envelopes preserve validated coordinates', () => {735 assert.deepEqual(