From 31abff05f372618ab06435a726a709c34c30aa9d Mon Sep 17 00:00:00 2001 From: Talon Date: Thu, 12 Mar 2026 17:16:17 +0100 Subject: [PATCH] Stop previews when arrowing or selecting --- client/src/audio/audioEngine.ts | 44 ++++++++++++++++++++++++++ client/src/items/itemPropertyEditor.ts | 3 ++ client/src/main.ts | 13 +++++++- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 00931cf..a29363a 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -48,6 +48,9 @@ export class AudioEngine { private readonly sampleLoaders = new Map>(); private readonly activeSpatialSamples = new Set(); + private previewSourceNode: AudioBufferSourceNode | null = null; + private previewGainNode: GainNode | null = null; + private outboundSource: MediaStreamAudioSourceNode | null = null; private outboundInputGain: GainNode | null = null; private outboundInputGainValue = 1; @@ -491,6 +494,47 @@ export class AudioEngine { } } + stopPreviewSample(): void { + if (this.previewSourceNode) { + try { this.previewSourceNode.stop(); } catch { /* already ended */ } + try { this.previewSourceNode.disconnect(); } catch { /* ignore */ } + this.previewSourceNode = null; + } + if (this.previewGainNode) { + try { this.previewGainNode.disconnect(); } catch { /* ignore */ } + this.previewGainNode = null; + } + } + + async playPreviewSample(url: string, gain = 1): Promise { + this.stopPreviewSample(); + await this.ensureContext(); + const { audioCtx, sfxGainNode } = this; + if (!audioCtx || !sfxGainNode) return; + if (gain <= 0) return; + try { + const buffer = await this.getSampleBuffer(url); + this.stopPreviewSample(); + const source = audioCtx.createBufferSource(); + source.buffer = buffer; + const gainNode = audioCtx.createGain(); + gainNode.gain.setValueAtTime(0, audioCtx.currentTime); + gainNode.gain.setTargetAtTime(gain, audioCtx.currentTime, ONE_SHOT_ATTACK_SECONDS); + source.connect(gainNode).connect(sfxGainNode); + this.previewSourceNode = source; + this.previewGainNode = gainNode; + source.onended = () => { + if (this.previewSourceNode === source) { + this.previewSourceNode = null; + this.previewGainNode = null; + } + }; + source.start(); + } catch { + // Ignore decode/load errors. + } + } + /** Starts a looping sample and returns a stop callback for explicit teardown. */ async startLoopingSample(url: string, gain = 1): Promise<(() => void) | null> { await this.ensureContext(); diff --git a/client/src/items/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts index ea21b32..56b1259 100644 --- a/client/src/items/itemPropertyEditor.ts +++ b/client/src/items/itemPropertyEditor.ts @@ -49,6 +49,7 @@ type EditorDeps = { sfxUiCancel: () => void; openSoundPropertyPicker?: (item: WorldItem, key: string) => void; previewSound?: (soundPath: string) => void; + stopPreviewSound?: () => void; }; /** @@ -408,6 +409,7 @@ export function createItemPropertyEditor(deps: EditorDeps): { } if (control.type === 'select') { + deps.stopPreviewSound?.(); const selectedValue = deps.state.itemPropertyOptionValues[deps.state.itemPropertyOptionIndex]; deps.signalingSend({ type: 'item_update', itemId, params: { [propertyKey]: selectedValue } }); const item = deps.state.items.get(itemId); @@ -422,6 +424,7 @@ export function createItemPropertyEditor(deps: EditorDeps): { } if (control.type === 'cancel') { + deps.stopPreviewSound?.(); deps.state.mode = 'itemProperties'; deps.state.editingPropertyKey = null; deps.state.itemPropertyOptionValues = []; diff --git a/client/src/main.ts b/client/src/main.ts index 990e9fa..78a9642 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1027,17 +1027,27 @@ let previewDebounceTimer: ReturnType | null = null; /** Plays a sound preview with debounce, for use while navigating sound picker. */ function previewSound(soundPath: string): void { + audio.stopPreviewSample(); if (previewDebounceTimer !== null) { clearTimeout(previewDebounceTimer); } previewDebounceTimer = setTimeout(() => { previewDebounceTimer = null; if (soundPath) { - void audio.playSample(soundPath, 0.7); + void audio.playPreviewSample(soundPath, 0.7); } }, 200); } +/** Stops any in-progress preview sound and clears the debounce timer. */ +function stopPreviewSound(): void { + if (previewDebounceTimer !== null) { + clearTimeout(previewDebounceTimer); + previewDebounceTimer = null; + } + audio.stopPreviewSample(); +} + /** Opens the sound picker for a sound-typed item property, falling back to text edit if no sounds are found. */ async function openSoundPropertyPicker(item: WorldItem, key: string): Promise { updateStatus('Loading sounds...'); @@ -2629,6 +2639,7 @@ const itemPropertyEditor = createItemPropertyEditor({ sfxUiCancel: () => audio.sfxUiCancel(), openSoundPropertyPicker: (item, key) => { void openSoundPropertyPicker(item, key); }, previewSound, + stopPreviewSound, }); /** Handles nickname edit mode submission/cancel and text editing keys. */