ai-chatbot/packages/editor/src/lib/sfx-player.ts
Wassim SAMAD 9bfbcc9f01 editor: add resize SFX triggered while dragging resize handles
Register a `resize` sound effect (three round-robin variations with
pitch/pan jitter and an 80ms gap) and emit it from the linear/radial
resize drag handler whenever the snapped+clamped value steps, so width /
length / depth / radius / height drags tick as the size changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:09:02 -04:00

211 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Howl } from 'howler'
import useAudio from '../store/use-audio'
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
// machine-gun feeling when the same SFX fires in rapid succession.
type SFXConfig = {
// One file, or several pre-rendered variations cycled round-robin per play.
src: string | string[]
// Random playback-rate range applied per play (1 = unchanged).
rateRange?: [number, number]
// Random volume multiplier range applied per play (1 = unchanged).
volumeRange?: [number, number]
// Minimum gap between two plays of this SFX. Triggers within this window
// are silently dropped so bursty sequences don't phase-stack into noise.
minIntervalMs?: number
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
// right). A small value like 0.15 keeps things centred but adds just enough
// spread to stop repeats from stacking on the same point in the field.
panJitter?: number
}
const DEFAULT_MIN_INTERVAL_MS = 30
// SFX sound definitions
export const SFX: Record<string, SFXConfig> = {
gridSnap: {
src: [
'/audios/sfx/grid_snap_0.mp3',
'/audios/sfx/grid_snap_1.mp3',
'/audios/sfx/grid_snap_2.mp3',
],
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.15,
minIntervalMs: 50,
},
itemDelete: {
src: '/audios/sfx/item_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.05,
},
itemPick: {
src: '/audios/sfx/item_pick.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemPlace: {
src: '/audios/sfx/item_place.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
itemRotate: {
src: '/audios/sfx/item_rotate.mp3',
rateRange: [0.94, 1.06],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
// succession, so it mirrors gridSnap: three variations cycled round-robin
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
resize: {
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
rateRange: [0.98, 1.02],
volumeRange: [0.26, 0.34],
panJitter: 0.15,
minIntervalMs: 80,
},
// Fired when a structure draft begins (first click of a wall/slab/etc).
structureBuildStart: {
src: '/audios/sfx/structure_build_start.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
// Fired when a structure is committed (segment placed / polygon closed).
structureBuildEnd: {
src: '/audios/sfx/structure_build_end.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
structureDelete: {
src: '/audios/sfx/structure_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.08,
},
snapshotCapture: {
// Shutter should sound consistent — no variation.
src: '/audios/sfx/snapshot_capture.mp3',
},
// Soft tick when hovering a main category in the Build / Items panels.
// Kept quiet and rate-locked so sweeping across the grid reads as texture,
// not a melody.
menuHover: {
src: '/audios/sfx/menu_hover.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.2, 0.3],
panJitter: 0.1,
minIntervalMs: 0,
},
// Fired when a main category in the Build / Items panels is clicked.
menuClick: {
src: '/audios/sfx/menu_click.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.1,
},
// Fired when a material is applied to a surface in paint mode. Painting can
// fire in quick succession across faces, so keep variation + a small gap.
paintApply: {
src: '/audios/sfx/paint_apply.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.85, 1.0],
panJitter: 0.12,
minIntervalMs: 60,
},
} as const
export type SFXName = keyof typeof SFX
function randomInRange([min, max]: [number, number]): number {
return min + Math.random() * (max - min)
}
// Preload all SFX sounds. Each variation gets its own Howl so they can overlap
// and be cycled round-robin.
const sfxCache = new Map<SFXName, Howl[]>()
const lastPlayedAt = new Map<SFXName, number>()
const lastVariation = new Map<SFXName, number>()
// Initialize all sounds
Object.entries(SFX).forEach(([name, config]) => {
const sources = Array.isArray(config.src) ? config.src : [config.src]
const sounds = sources.map(
(src) =>
new Howl({
src: [src],
preload: true,
volume: 0.5, // Will be adjusted by the bus
}),
)
sfxCache.set(name as SFXName, sounds)
})
/**
* Play a sound effect with volume based on audio settings
*/
export function playSFX(name: SFXName) {
const sounds = sfxCache.get(name)
if (!sounds || sounds.length === 0) {
console.warn(`SFX not found: ${name}`)
return
}
const config = SFX[name]!
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
// smear into noise, they don't add useful information.
const now = performance.now()
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
const last = lastPlayedAt.get(name)
if (last !== undefined && now - last < minInterval) return
lastPlayedAt.set(name, now)
// Pick a random variation, avoiding an immediate repeat of the last one so
// consecutive plays don't land on the same file.
let index = Math.floor(Math.random() * sounds.length)
if (sounds.length > 1 && index === lastVariation.get(name)) {
index = (index + 1) % sounds.length
}
lastVariation.set(name, index)
const sound = sounds[index]!
const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return
// Calculate final volume (masterVolume and sfxVolume are 0-100)
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
// Apply per-play variation using the returned sound id so overlapping plays
// don't fight over shared properties on the Howl.
const id = sound.play()
sound.volume(baseVolume * volumeJitter, id)
if (rate !== 1) sound.rate(rate, id)
if (config.panJitter) {
const pan = (Math.random() * 2 - 1) * config.panJitter
sound.stereo(pan, id)
}
}
/**
* Update all cached SFX volumes (useful when settings change)
*/
export function updateSFXVolumes() {
const { masterVolume, sfxVolume } = useAudio.getState()
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
sfxCache.forEach((sounds) => {
sounds.forEach((sound) => {
sound.volume(finalVolume)
})
})
}