diff --git a/src/app/util/audio-context.spec.ts b/src/app/util/audio-context.spec.ts index a94b7c2143..5b3359b149 100644 --- a/src/app/util/audio-context.spec.ts +++ b/src/app/util/audio-context.spec.ts @@ -6,6 +6,8 @@ import { unlockAudioContext, ensureAudioContextRunning, playBuffer, + suspendAudioContext, + setAudioContextKeepAwake, } from './audio-context'; describe('audio-context', () => { @@ -351,6 +353,96 @@ describe('audio-context', () => { }); }); + describe('suspendAudioContext', () => { + const mockRunningContext = (): { suspend: jasmine.Spy } => { + const suspend = jasmine.createSpy('suspend').and.returnValue(Promise.resolve()); + const mockContext = { + state: 'running', + resume: jasmine.createSpy('resume').and.returnValue(Promise.resolve()), + suspend, + close: jasmine.createSpy('close'), + }; + (window as any).AudioContext = jasmine + .createSpy('AudioContext') + .and.returnValue(mockContext); + return { suspend }; + }; + + it('should suspend a running context', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + + it('should be a no-op when no context exists yet', () => { + const { suspend } = mockRunningContext(); + + // Never call getAudioContext() — nothing should be created or suspended. + suspendAudioContext(); + + expect((window as any).AudioContext).not.toHaveBeenCalled(); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should not suspend a context that is already suspended', () => { + const suspend = jasmine.createSpy('suspend').and.returnValue(Promise.resolve()); + const mockContext = { + state: 'suspended', + resume: jasmine.createSpy('resume').and.returnValue(Promise.resolve()), + suspend, + close: jasmine.createSpy('close'), + }; + (window as any).AudioContext = jasmine + .createSpy('AudioContext') + .and.returnValue(mockContext); + getAudioContext(); + + suspendAudioContext(); + + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should not suspend while keep-awake is set (e.g. white noise playing)', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + + suspendAudioContext(); + + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should suspend again after keep-awake is cleared', () => { + const { suspend } = mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + suspendAudioContext(); + expect(suspend).not.toHaveBeenCalled(); + + setAudioContextKeepAwake(false); + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + + it('should re-allow suspend after closeAudioContext resets keep-awake', () => { + mockRunningContext(); + getAudioContext(); + setAudioContextKeepAwake(true); + closeAudioContext(); + + // Fresh running context after teardown; keep-awake must no longer block. + const { suspend } = mockRunningContext(); + getAudioContext(); + suspendAudioContext(); + + expect(suspend).toHaveBeenCalled(); + }); + }); + describe('closeAudioContext', () => { it('should close the context and clear cache', () => { const mockContext = { diff --git a/src/app/util/audio-context.ts b/src/app/util/audio-context.ts index ffd831d66a..2c1b9579f8 100644 --- a/src/app/util/audio-context.ts +++ b/src/app/util/audio-context.ts @@ -6,6 +6,10 @@ let audioContext: AudioContext | null = null; const audioBufferCache = new Map(); let unlocked = false; +// Whether something still needs the context running while the app is backgrounded +// (e.g. focus-mode white noise). While set, suspendAudioContext() is a no-op so +// the ongoing sound is not cut off. See setAudioContextKeepAwake(). +let keepAwake = false; /** * Returns the singleton AudioContext instance, creating it if necessary. @@ -93,6 +97,37 @@ export const playBuffer = async ( source.start(0); }; +/** + * Marks (or clears) a need to keep the AudioContext running even while the app is + * backgrounded. Used by long-lived background audio such as the focus-mode white + * noise so that suspendAudioContext() does not cut it off mid-playback. + * + * Single-owner: white noise is currently the only caller. If a second long-lived + * background sound is ever added, replace this boolean with a reference count so + * one source clearing the flag cannot release another's. + */ +export const setAudioContextKeepAwake = (keep: boolean): void => { + keepAwake = keep; +}; + +/** + * Suspends the singleton AudioContext to release the underlying audio output + * stream. On Android a `running` AudioContext keeps an AudioTrack open in the + * audio HAL even while completely silent — which drains the battery around the + * clock and keeps the process alive in the background (issue #8243). Suspending + * releases that stream; the next playback resumes it via ensureAudioContextRunning(). + * + * No-op when the context was never created, is already suspended, or something + * still needs it running (see setAudioContextKeepAwake). + */ +export const suspendAudioContext = (): void => { + if (!keepAwake && audioContext && audioContext.state === 'running') { + // suspend() returns a promise; the release begins immediately so we don't + // await it. Swallow errors — the context may already be closing. + void audioContext.suspend().catch(() => {}); + } +}; + /** * Registers one-time touchend/click listeners that create and resume the AudioContext * on the first user gesture. This is required on iOS where AudioContext can only be @@ -137,4 +172,5 @@ export const closeAudioContext = (): void => { } audioBufferCache.clear(); unlocked = false; + keepAwake = false; }; diff --git a/src/app/util/white-noise.ts b/src/app/util/white-noise.ts index b1a30f15fc..2c9230a47d 100644 --- a/src/app/util/white-noise.ts +++ b/src/app/util/white-noise.ts @@ -1,4 +1,4 @@ -import { ensureAudioContextRunning } from './audio-context'; +import { ensureAudioContextRunning, setAudioContextKeepAwake } from './audio-context'; let activeSource: AudioBufferSourceNode | null = null; let activeGain: GainNode | null = null; @@ -40,10 +40,13 @@ export const startWhiteNoise = async (volume: number): Promise => { source.start(0); activeSource = source; activeGain = gain; + // Keep the context running while backgrounded so the focus sound keeps playing. + setAudioContextKeepAwake(true); }; export const stopWhiteNoise = (): void => { startCancelled = true; + setAudioContextKeepAwake(false); if (activeSource) { try { activeSource.stop(); @@ -57,4 +60,9 @@ export const stopWhiteNoise = (): void => { activeGain.disconnect(); activeGain = null; } + // NOTE: intentionally does NOT suspend the AudioContext here. stopWhiteNoise() + // runs (via the whiteNoiseSound$ selector effect) right as a session completes, + // racing the session-done chime (playSound) on the shared context — suspending + // would cut the chime off. The context is released instead by the appStateChange + // background handler in main.ts (#8243). }; diff --git a/src/main.ts b/src/main.ts index ff989cd17e..e95c13ef29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,7 +91,7 @@ import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe'; import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service'; import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter'; import { TranslateMatDatepickerIntl } from './app/core/date-time-format/translate-mat-datepicker-intl'; -import { unlockAudioContext } from './app/util/audio-context'; +import { suspendAudioContext, unlockAudioContext } from './app/util/audio-context'; import { NetworkRetryInterceptorService } from './app/core/http/network-retry-interceptor.service'; if (environment.production || environment.stage) { @@ -488,6 +488,9 @@ if (IS_ANDROID_WEB_VIEW) { if (isActive) { return; } + // Release the audio output stream so a silent-but-running AudioContext does + // not keep the audio hardware (and the process) awake in the background (#8243). + suspendAudioContext(); const taskId = await BackgroundTask.beforeExit(async () => { try { await flushPendingOperations('Android'); @@ -505,6 +508,9 @@ if (IS_IOS_NATIVE) { if (isActive) { return; } + // Release the audio output stream so a silent-but-running AudioContext does + // not keep the audio hardware (and the process) awake in the background (#8243). + suspendAudioContext(); const taskId = await BackgroundTask.beforeExit(async () => { try { // Dispatch any accumulated tracked time so it is enqueued before the