mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(android): suspend idle AudioContext on background (#8243) The singleton Web Audio AudioContext is resumed on the first user gesture (unlockAudioContext) and was never suspended or closed afterwards. On Android a running AudioContext keeps an AudioTrack open in the audio HAL even while completely silent, which drains the battery continuously and keeps the process alive in the background. A reporter's batterystats dump showed 1364 mAh (~54% of total discharge) from 18h of continuous 'audio (running)' with no sound actually playing. Suspend the context when the app is backgrounded (appStateChange) so the output stream is released; playback lazily resumes it via ensureAudioContextRunning(). A keep-awake flag set by the focus-mode white noise prevents cutting off an intentionally-playing background sound. * fix(android): release AudioContext when white noise stops (#8243) Address multi-review finding: suspendAudioContext() previously only fired on the appStateChange background edge, but keepAwake is cleared later by stopWhiteNoise(). A focus session with white noise kept alive by the foreground service can end while backgrounded, leaving the context running and silent with no further background edge to suspend it — reintroducing the drain until the next foreground/background cycle. stopWhiteNoise() now releases the output stream once the only long-lived sound ends; the next playback lazily resumes the context. Also rename the keep-awake param for readability and document the single-owner assumption. * fix(android): don't suspend AudioContext in stopWhiteNoise (#8243) Fable-review finding: suspending in stopWhiteNoise() races the session-done chime. stopWhiteNoise() runs via the whiteNoiseSound$ selector effect the moment a session completes, around when notifyOnSessionComplete$ plays the chime on the shared context; since suspend() flips state asynchronously, the chime can start into a context that suspends ms later and gets cut off — an audible regression on every white-noise session completion. Back out the stopWhiteNoise suspend (added in the previous commit). The residual it targeted (context left running after white noise ends while backgrounded) is largely unreachable on Android anyway: the focus timer's tick pauses in the background, so a session can't complete while backgrounded. The keep-awake flag, the appStateChange background suspend, and the polish (param rename, single-owner doc, close-reset test) are kept.
This commit is contained in:
parent
5d2abbf847
commit
45298edfa6
4 changed files with 144 additions and 2 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
let audioContext: AudioContext | null = null;
|
||||
const audioBufferCache = new Map<string, AudioBuffer>();
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
|||
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).
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue