From cfc83a607842dda14dde702713a374137fa83b04 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 17:19:53 +0400 Subject: [PATCH] Release the tuner noise graph after a silent dial release A release that only selects a station now stops the looping noise source and suspends its AudioContext. The qa-radio dial scenarios start from an active stream, or expect a silent release from a stopped Radio. --- scripts/qa-radio.mjs | 14 ++++++++-- src/data/radioNextState.test.mjs | 47 ++++++++++++++++++++++++++++++++ src/layers/radio/tuning.js | 4 ++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index 8e86de86d..83b359605 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -3073,20 +3073,27 @@ async function main() { expectedSlot: targetIndex, previewPlayDelta: previewPlays - playsBefore, commitPlayDelta: window.__qaRadioPlayCalls.length - playsBefore, + audioState: state.audioState, + tuningStatic: state.tuningStatic, samples, spread: Math.max(...samples) - Math.min(...samples), }; }); check( - 'pointer preview never plays and release commits the nearest station once without a needle sweep', + 'from a stopped Radio, pointer release only selects the nearest station, silently and without a needle sweep', tunerDirectRelease.targetId && tunerDirectRelease.selectedId === tunerDirectRelease.targetId && tunerDirectRelease.sliderSlot === tunerDirectRelease.expectedSlot && tunerDirectRelease.previewPlayDelta === 0 - && tunerDirectRelease.commitPlayDelta === 1 + && tunerDirectRelease.commitPlayDelta === 0 + && tunerDirectRelease.audioState === 'stopped' + && !tunerDirectRelease.tuningStatic && tunerDirectRelease.spread < 0.5, JSON.stringify(tunerDirectRelease), ); + // The release plays only if a stream was active when the drag began. + await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); + await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); // Earlier cases scroll and resize the directory. Bring the physical drag // target back into its scroller before deriving viewport mouse coordinates. await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' })); @@ -3494,6 +3501,9 @@ async function main() { }); }); await sleep(500); + // Next left the Radio stopped; a release plays only from an active stream. + await page.evaluate(() => window.__godsEyeView.dataManager.layers.get('radio').module.togglePlayback({ origin: 'user' })); + await page.waitForFunction(() => window.__godsEyeView.dataManager.layers.get('radio').module.getUIState().audioState === 'playing'); const failedTuner = await page.evaluate(async () => { const gev = window.__godsEyeView; const slider = document.getElementById('radio-tuner-slider'); diff --git a/src/data/radioNextState.test.mjs b/src/data/radioNextState.test.mjs index 3f9b2c6f6..2c52b4bbc 100644 --- a/src/data/radioNextState.test.mjs +++ b/src/data/radioNextState.test.mjs @@ -271,3 +271,50 @@ test('a dial release plays only if a stream was active when the drag began', asy assert.equal(state.playingStationId, ids[2]); }); }); + +// Records the tuner noise graph so a test can see whether it was released. +function withFakeAudioContext(body) { + const log = { contexts: [], sources: [] }; + const node = () => ({ connect() {}, disconnect() {} }); + class FakeAudioContext { + constructor() { + 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(); } + close() { this.state = 'closed'; return Promise.resolve(); } + createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; } + createBufferSource() { + const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } }; + log.sources.push(source); + return source; + } + createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; } + createGain() { + return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } }; + } + } + const original = globalThis.AudioContext; + globalThis.AudioContext = FakeAudioContext; + return Promise.resolve(body(log)).finally(() => { + if (original === undefined) delete globalThis.AudioContext; + else globalThis.AudioContext = original; + }); +} + +test('a silent dial release stops the tuner noise and suspends its audio context', 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(); + assert.deepEqual(playUrls, []); + assert.equal(sources[0].started, true); + assert.equal(sources[0].stopped, true, 'noise source still looping'); + assert.equal(contexts[0].state, 'suspended', 'audio context still running'); + }); + }); +}); diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js index d9903b1a2..53d5637e0 100644 --- a/src/layers/radio/tuning.js +++ b/src/layers/radio/tuning.js @@ -214,7 +214,9 @@ export function createTuning({ state: layerState, services, parts, source }) { layerState._playFallbackFocus = null; layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackAttemptId = null; - parts.tuningNoise.syncTuningNoiseGain(); + // Static covers only a connecting stream; a silent release frees the noise graph. + if (autoplay) parts.tuningNoise.syncTuningNoiseGain(); + else parts.tuningNoise.clearRadioTuningNoise({ emit: false }); parts.presentation.emitState(); const selected = parts.selection.selectRadioStation(frozenStation.id, { autoplay,