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
+99 -13

No files matched your search

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