From 81ffa5b6637b90ad52c3535c5d7f399403d4ac15 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 14:32:55 +0400 Subject: [PATCH] Suspend the tuner noise context once a pending resume settles A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests. --- docs/CURRENT-STATE.md | 10 +-- src/data/radioNextState.test.mjs | 107 +++++++++++++++++++++++++++++-- src/layers/radio/selection.js | 2 +- src/layers/radio/tuning.js | 2 +- src/layers/radio/tuningNoise.js | 26 ++++++-- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2a7e0d827..bbbd039d0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4703,11 +4703,11 @@ ownership tokens plus the session boundary keep late callbacks from a retired session inert. A tuner drag resolves previews against the single accepted snapshot captured at pointer-down, even while a newer catalog is admitted. Like Previous/Next, -release starts playback only if a stream was active when the drag began, and -only when the current same-ID record still exactly matches the frozen -presentation and stream metadata; otherwise it just selects the station. Removal -or replacement reports the channel as unavailable and never silently retargets -the drag. A cold degraded fallback may +release starts playback only if a stream was active when the drag began; +otherwise it just selects the station. It commits only when the current same-ID +record still exactly matches the frozen presentation and stream metadata. +Removal or replacement reports the channel as unavailable and never silently +retargets the drag. A cold degraded fallback may still populate the directory and globe, but its null accepted generation cannot populate or begin the tuner. diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 2c52b4bbc..ac1f82734 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -272,7 +272,10 @@ test('a dial release plays only if a stream was active when the drag began', asy }); }); -// Records the tuner noise graph so a test can see whether it was released. +// Like Chrome, resume() flips `state` only once the device starts, and suspend() +// on a context that is still suspended resolves at once without effect. +const RESUME_MS = 5; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); function withFakeAudioContext(body) { const log = { contexts: [], sources: [] }; const node = () => ({ connect() {}, disconnect() {} }); @@ -281,8 +284,17 @@ function withFakeAudioContext(body) { Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} }); log.contexts.push(this); } - resume() { this.state = 'running'; return Promise.resolve(); } - suspend() { this.state = 'suspended'; return Promise.resolve(); } + resume() { + if (this.state === 'running') return Promise.resolve(); + return new Promise((resolve) => setTimeout(() => { + if (this.state !== 'closed') this.state = 'running'; + resolve(); + }, RESUME_MS)); + } + suspend() { + if (this.state === 'running') this.state = 'suspended'; + return Promise.resolve(); + } close() { this.state = 'closed'; return Promise.resolve(); } createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; } createBufferSource() { @@ -303,14 +315,14 @@ function withFakeAudioContext(body) { }); } -test('a silent dial release stops the tuner noise and suspends its audio context', async () => { +test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => { await withFakeAudioContext(async ({ contexts, sources }) => { await withLayer([], async ({ playUrls }) => { radioLayer.selectStation(ids[0], { autoplay: false }); assert.equal(radioLayer.beginTuning(), true); radioLayer.previewTuningStation(ids[1], { rotate: false }); assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true); - await settle(); + await sleep(RESUME_MS * 4); assert.deepEqual(playUrls, []); assert.equal(sources[0].started, true); assert.equal(sources[0].stopped, true, 'noise source still looping'); @@ -318,3 +330,88 @@ test('a silent dial release stops the tuner noise and suspends its audio context }); }); }); + +test('a second gesture before the first resume() settles keeps its noise graph', async () => { + await withFakeAudioContext(async ({ contexts, sources }) => { + await withLayer([], async () => { + radioLayer.selectStation(ids[0], { autoplay: false }); + radioLayer.beginTuning(); + radioLayer.commitTuningStation(ids[0], { origin: 'user' }); + assert.equal(radioLayer.beginTuning(), true); + await sleep(RESUME_MS * 4); + assert.equal(contexts[0].state, 'running', 'second gesture lost its context'); + assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source'); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await sleep(RESUME_MS * 4); + assert.equal(sources.at(-1).stopped, true); + assert.equal(contexts[0].state, 'suspended'); + }); + }); +}); + +test('a dial release from a paused stream only selects, without static', async () => { + await withFakeAudioContext(async ({ contexts }) => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + await settle(); + await radioLayer.togglePlayback({ origin: 'user' }); + assert.equal(radioLayer.getUIState().audioState, 'paused'); + assert.equal(radioLayer.beginTuning(), true); + await sleep(RESUME_MS * 4); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await sleep(RESUME_MS * 4); + const state = radioLayer.getUIState(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(state.selected?.id, ids[1]); + assert.equal(state.audioState, 'stopped'); + assert.equal(state.tuningStatic, false); + assert.equal(state.tuningAwaitingStationId, null); + assert.equal(contexts[0].state, 'suspended'); + }); + }); +}); + +test('a dial release while a stream is still connecting plays the new station', async () => { + await withLayer([], async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' }); + assert.equal(radioLayer.getUIState().audioState, 'loading'); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[1], { rotate: false }); + radioLayer.commitTuningStation(ids[1], { origin: 'user' }); + await settle(); + const state = radioLayer.getUIState(); + assert.equal(playUrls.at(-1), stations[1].streamUrl); + assert.equal(state.audioState, 'playing'); + assert.equal(state.playingStationId, ids[1]); + }); +}); + +test('a silent user step drops the fallback a voice step left behind', async () => { + await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + // Voice steps to B and keeps C as its fallback; the user steps back to A. + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); + radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' }); + await radioLayer.play({ origin: 'voice' }); + await settle(); + await settle(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(radioLayer.getUIState().audioState, 'error'); + }); +}); + +test('a silent dial release drops the fallback a voice step left behind', async () => { + await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => { + radioLayer.selectStation(ids[0], { autoplay: false }); + radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' }); + assert.equal(radioLayer.beginTuning(), true); + radioLayer.previewTuningStation(ids[0], { rotate: false }); + radioLayer.commitTuningStation(ids[0], { origin: 'user' }); + await radioLayer.play({ origin: 'voice' }); + await settle(); + await settle(); + assert.deepEqual(playUrls, [stations[0].streamUrl]); + assert.equal(radioLayer.getUIState().audioState, 'error'); + }); +}); diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index b09a8c19a..2b2ba282a 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -181,7 +181,7 @@ export function createSelection({ current < 0 ? 0 : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; - // Voice defers playback, so its silent step needs a fallback; UI steps do not. + // Voice defers playback, so its silent step keeps a fallback; silent UI steps do not. const fallbackId = fallback && ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index 53d5637e0..015e79a15 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -214,7 +214,7 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._playFallbackFocus = null; layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackAttemptId = null; - // Static covers only a connecting stream; a silent release frees the noise graph. + // Muting alone would leave the noise graph running after a silent release. if (autoplay) parts.tuningNoise.syncTuningNoiseGain(); else parts.tuningNoise.clearRadioTuningNoise({ emit: false }); parts.presentation.emitState(); diff --git a/src/layers/radio/tuningNoise.js b/src/layers/radio/tuningNoise.js index dfbc3fe3b..ab151246f 100644 --- a/src/layers/radio/tuningNoise.js +++ b/src/layers/radio/tuningNoise.js @@ -50,6 +50,11 @@ export function createTuningNoise({ layerState._tuningNoiseGain = null; } + function suspendTuningNoiseContext() { + const suspended = layerState._tuningNoiseContext?.suspend?.(); + if (suspended?.catch) void suspended.catch(() => {}); + } + function syncTuningNoiseGain() { if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; const audible = radioTuningStaticShouldPlay({ @@ -79,8 +84,21 @@ export function createTuningNoise({ if (!AudioContextClass) return false; if (!layerState._tuningNoiseContext) layerState._tuningNoiseContext = new AudioContextClass(); - const resumed = layerState._tuningNoiseContext.resume?.(); - if (resumed?.catch) void resumed.catch(() => {}); + const context = layerState._tuningNoiseContext; + const resumed = context.resume?.(); + // A release before the device starts finds the context still suspended, + // so its suspend() is a no-op; suspend again once resume() settles. + if (resumed?.then) + void resumed.then( + () => { + if ( + layerState._tuningNoiseContext === context && + !layerState._tuningNoiseSource + ) + suspendTuningNoiseContext(); + }, + () => {}, + ); if (layerState._tuningNoiseSource) return true; const frameCount = Math.max( @@ -120,6 +138,7 @@ export function createTuningNoise({ if (layerState._tuningCameraNavigation) parts.navigation.invalidateRadioCameraNavigation(); layerState._tuningActive = false; + layerState._tuningResumesPlayback = false; layerState._tuningStatic = false; layerState._tuningAwaitingStationId = null; layerState._tuningPreviewId = null; @@ -131,8 +150,7 @@ export function createTuningNoise({ parts.rendering.updateSelectionEntity(); syncTuningNoiseGain(); stopTuningNoiseSource(); - const suspended = layerState._tuningNoiseContext?.suspend?.(); - if (suspended?.catch) void suspended.catch(() => {}); + suspendTuningNoiseContext(); if (emit) parts.presentation.emitState(); } return {