mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
cfc83a6078
commit
81ffa5b663
5 files changed
+131
-16
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user