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.
This commit is contained in:
German Khokhlov committed 2026-11-03 14:32:55 +04:00
1 parent cfc83a6078
commit 81ffa5b663
5 files changed
+131 -16

No files matched your search

+5 -5
View File
@@ -4703,11 +4703,11 @@ ownership tokens plus the session boundary keep late callbacks from a retired
session inert. session inert.
A tuner drag resolves previews against the single accepted snapshot captured at A tuner drag resolves previews against the single accepted snapshot captured at
pointer-down, even while a newer catalog is admitted. Like Previous/Next, 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 release starts playback only if a stream was active when the drag began;
only when the current same-ID record still exactly matches the frozen otherwise it just selects the station. It commits only when the current same-ID
presentation and stream metadata; otherwise it just selects the station. Removal record still exactly matches the frozen presentation and stream metadata.
or replacement reports the channel as unavailable and never silently retargets Removal or replacement reports the channel as unavailable and never silently
the drag. A cold degraded fallback may retargets the drag. A cold degraded fallback may
still populate the directory and globe, but its null accepted generation cannot still populate the directory and globe, but its null accepted generation cannot
populate or begin the tuner. populate or begin the tuner.
+102 -5
View File
@@ -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) { function withFakeAudioContext(body) {
const log = { contexts: [], sources: [] }; const log = { contexts: [], sources: [] };
const node = () => ({ connect() {}, disconnect() {} }); const node = () => ({ connect() {}, disconnect() {} });
@@ -281,8 +284,17 @@ function withFakeAudioContext(body) {
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} }); Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
log.contexts.push(this); log.contexts.push(this);
} }
resume() { this.state = 'running'; return Promise.resolve(); } resume() {
suspend() { this.state = 'suspended'; return Promise.resolve(); } 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(); } close() { this.state = 'closed'; return Promise.resolve(); }
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; } createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
createBufferSource() { 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 withFakeAudioContext(async ({ contexts, sources }) => {
await withLayer([], async ({ playUrls }) => { await withLayer([], async ({ playUrls }) => {
radioLayer.selectStation(ids[0], { autoplay: false }); radioLayer.selectStation(ids[0], { autoplay: false });
assert.equal(radioLayer.beginTuning(), true); assert.equal(radioLayer.beginTuning(), true);
radioLayer.previewTuningStation(ids[1], { rotate: false }); radioLayer.previewTuningStation(ids[1], { rotate: false });
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true); assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
await settle(); await sleep(RESUME_MS * 4);
assert.deepEqual(playUrls, []); assert.deepEqual(playUrls, []);
assert.equal(sources[0].started, true); assert.equal(sources[0].started, true);
assert.equal(sources[0].stopped, true, 'noise source still looping'); 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');
});
});
+1 -1
View File
@@ -181,7 +181,7 @@ export function createSelection({
current < 0 current < 0
? 0 ? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length; : (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 = const fallbackId =
fallback && ranked.length > 1 fallback && ranked.length > 1
? ranked[(nextIndex + 1) % ranked.length].id ? ranked[(nextIndex + 1) % ranked.length].id
+1 -1
View File
@@ -214,7 +214,7 @@ export function createTuning({ state: layerState, services, parts, source }) {
layerState._playFallbackFocus = null; layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic'; layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null; 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(); if (autoplay) parts.tuningNoise.syncTuningNoiseGain();
else parts.tuningNoise.clearRadioTuningNoise({ emit: false }); else parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState(); parts.presentation.emitState();
+22 -4
View File
@@ -50,6 +50,11 @@ export function createTuningNoise({
layerState._tuningNoiseGain = null; layerState._tuningNoiseGain = null;
} }
function suspendTuningNoiseContext() {
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
}
function syncTuningNoiseGain() { function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return; if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({ const audible = radioTuningStaticShouldPlay({
@@ -79,8 +84,21 @@ export function createTuningNoise({
if (!AudioContextClass) return false; if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext) if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass(); layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.(); const context = layerState._tuningNoiseContext;
if (resumed?.catch) void resumed.catch(() => {}); 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; if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max( const frameCount = Math.max(
@@ -120,6 +138,7 @@ export function createTuningNoise({
if (layerState._tuningCameraNavigation) if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation(); parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false; layerState._tuningActive = false;
layerState._tuningResumesPlayback = false;
layerState._tuningStatic = false; layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null; layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null; layerState._tuningPreviewId = null;
@@ -131,8 +150,7 @@ export function createTuningNoise({
parts.rendering.updateSelectionEntity(); parts.rendering.updateSelectionEntity();
syncTuningNoiseGain(); syncTuningNoiseGain();
stopTuningNoiseSource(); stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.(); suspendTuningNoiseContext();
if (suspended?.catch) void suspended.catch(() => {});
if (emit) parts.presentation.emitState(); if (emit) parts.presentation.emitState();
} }
return { return {