feat(sound): play one-shot sounds immediately and abortably (#2372)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-02 17:29:43 +02:00
1 parent 453abfa145
commit dfe42ae3e3
3 files changed
+103 -17

No files matched your search

@@ -350,8 +350,11 @@ const playOneShotSound = async (
type: SoundType,
volume: number,
onAutoplayBlocked?: () => void,
signal?: AbortSignal,
): Promise<HTMLAudioElement | null> => {
if (signal?.aborted) return null;
const soundUrl = await getSoundUrl(type);
if (signal?.aborted) return null;
const audio = createAudioElement(soundUrl);
audio.currentTime = 0;
audio.loop = false;
@@ -361,6 +364,7 @@ const playOneShotSound = async (
const routeToCaptureBus = shouldRouteSoundToCaptureBus(type);
try {
const ctx = await resumeAudioContextIfNeeded();
if (signal?.aborted) return null;
if (ctx.state !== 'suspended') {
sourceNode = ctx.createMediaElementSource(audio);
gainNode = ctx.createGain();
@@ -377,26 +381,49 @@ const playOneShotSound = async (
audio.volume = effectiveVolume;
applyOutputDeviceToAudioElement(audio);
}
let cleanedUp = false;
let abortPlayback: (() => void) | null = null;
const instance: OneShotAudioInstance = {
audio,
gainNode,
sourceNode,
cleanup: () => {
if (cleanedUp) return;
cleanedUp = true;
activeOneShotSounds.delete(instance);
audio.removeEventListener('ended', instance.cleanup);
audio.removeEventListener('error', instance.cleanup);
if (abortPlayback) signal?.removeEventListener('abort', abortPlayback);
disconnectNodes(sourceNode, gainNode);
},
};
abortPlayback = () => {
audio.pause();
audio.removeAttribute('src');
audio.load();
instance.cleanup();
};
audio.addEventListener('ended', instance.cleanup);
audio.addEventListener('error', instance.cleanup);
if (signal?.aborted) {
abortPlayback();
return null;
}
signal?.addEventListener('abort', abortPlayback, {once: true});
trimActiveOneShotSounds();
activeOneShotSounds.add(instance);
try {
await audio.play();
if (signal?.aborted) {
abortPlayback();
return null;
}
return audio;
} catch (error) {
instance.cleanup();
if (signal?.aborted) {
return null;
}
if (isAutoplayBlockedError(error)) {
logger.debug('Autoplay blocked; dropping sound', {type});
onAutoplayBlocked?.();
@@ -412,6 +439,7 @@ export async function playSound(
loop = false,
volume = 0.4,
onAutoplayBlocked?: () => void,
signal?: AbortSignal,
): Promise<HTMLAudioElement | null> {
const activeSound = activeSounds.get(type);
if (loop && activeSound && !activeSound.audio.paused) {
@@ -419,7 +447,7 @@ export async function playSound(
}
try {
if (!loop) {
return await playOneShotSound(type, volume, onAutoplayBlocked);
return await playOneShotSound(type, volume, onAutoplayBlocked, signal);
}
const ctx = await resumeAudioContextIfNeeded();
if (ctx.state === 'suspended') {
@@ -57,6 +57,18 @@ export function playSoundBypassingSelfDeafened(sound: SoundType, loop?: boolean)
dispatchSoundIntent({kind: 'play', sound, loop, options: {bypassSelfDeafened: true}});
}
export function playOneShotSoundImmediatelyBypassingSelfDeafened(
sound: SoundType,
signal?: AbortSignal,
options: {ignoreGroupCooldown?: boolean} = {},
): Promise<boolean> {
return Sound.playOneShotSoundImmediately(
sound,
{bypassSelfDeafened: true, ignoreGroupCooldown: options.ignoreGroupCooldown === true},
signal,
);
}
export function previewSound(sound: SoundType): void {
dispatchSoundIntent({kind: 'preview', sound});
}
+62 -16
View File
@@ -17,6 +17,7 @@ export interface SoundSettings {
export interface SoundPlayOptions {
bypassSelfDeafened?: boolean;
ignoreGroupCooldown?: boolean;
}
interface OneShotSoundPolicy {
@@ -294,6 +295,36 @@ class Sound {
});
}
async playOneShotSoundImmediately(
sound: SoundType,
options: SoundPlayOptions = {},
signal?: AbortSignal,
): Promise<boolean> {
if (StreamerMode.shouldDisableSounds) return false;
if (this.isSuppressedWhileSelfDeafened(sound, options)) return false;
if (!this.isSoundEnabled(sound)) return false;
const policy = getOneShotSoundPolicy(sound);
if (options.ignoreGroupCooldown !== true) {
const lastGroupPlayedAt = this.lastOneShotPlayedAtByGroup.get(policy.group);
if (lastGroupPlayedAt !== undefined && Date.now() < lastGroupPlayedAt + policy.cooldownMs) return false;
}
this.clearPendingOneShotSound(sound);
const effectiveVolume = this.volume * this.getEffectiveMultiplier(sound);
const played = await this.playOneShotSoundNow(
sound,
effectiveVolume,
options.bypassSelfDeafened === true,
signal,
false,
);
if (played) {
const playedAt = Date.now();
this.lastOneShotPlayedAt = playedAt;
this.lastOneShotPlayedAtByGroup.set(policy.group, playedAt);
}
return played;
}
previewSound(sound: SoundType): void {
if (StreamerMode.shouldDisableSounds) {
return;
@@ -553,7 +584,7 @@ class Sound {
) {
this.lastOneShotPlayedAt = now;
this.lastOneShotPlayedAtByGroup.set(selectedGroup, now);
this.playOneShotSoundNow(selectedSound.sound, selectedSound.volume, selectedSound.bypassSelfDeafened);
void this.playOneShotSoundNow(selectedSound.sound, selectedSound.volume, selectedSound.bypassSelfDeafened);
}
this.scheduleOneShotDrain();
}
@@ -579,23 +610,38 @@ class Sound {
if (Date.now() > pending.expiresAt) return;
if (!this.isSoundEnabled(pending.sound)) return;
if (this.isSuppressedWhileSelfDeafened(pending.sound, pending)) return;
this.playOneShotSoundNow(pending.sound, pending.volume, pending.bypassSelfDeafened);
void this.playOneShotSoundNow(pending.sound, pending.volume, pending.bypassSelfDeafened);
}
private playOneShotSoundNow(sound: SoundType, volume: number, bypassSelfDeafened: boolean): void {
SoundUtils.playSound(sound, false, volume, () =>
this.handleOneShotAutoplayBlocked(sound, volume, bypassSelfDeafened),
)
.then((result) => {
if (!result) return;
this.addCurrentlyPlayingSound(sound);
const remove = () => this.removeCurrentlyPlayingSound(sound);
result.addEventListener('ended', remove, {once: true});
result.addEventListener('error', remove, {once: true});
})
.catch((error) => {
this.logger.warn('Failed to play sound:', error);
});
private async playOneShotSoundNow(
sound: SoundType,
volume: number,
bypassSelfDeafened: boolean,
signal?: AbortSignal,
retryOnAutoplayBlocked = true,
): Promise<boolean> {
try {
const result = await SoundUtils.playSound(
sound,
false,
volume,
retryOnAutoplayBlocked ? () => this.handleOneShotAutoplayBlocked(sound, volume, bypassSelfDeafened) : undefined,
signal,
);
if (!result) return false;
this.addCurrentlyPlayingSound(sound);
const remove = (): void => {
signal?.removeEventListener('abort', remove);
this.removeCurrentlyPlayingSound(sound);
};
result.addEventListener('ended', remove, {once: true});
result.addEventListener('error', remove, {once: true});
signal?.addEventListener('abort', remove, {once: true});
return true;
} catch (error) {
this.logger.warn('Failed to play sound:', error);
return false;
}
}
private addCurrentlyPlayingSound(sound: SoundType): void {