ai-chatbot/packages/editor/src/lib/sfx-player.ts
Wawa e618175bba Reset core/viewer/editor/mcp packages and apps/editor from private-editor
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
2026-05-09 20:52:26 +00:00

144 lines
4.3 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 = {
src: 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.mp3',
rateRange: [0.94, 1.06],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemDelete: {
src: '/audios/sfx/item_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
itemPick: {
src: '/audios/sfx/item_pick.mp3',
rateRange: [0.92, 1.08],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemPlace: {
src: '/audios/sfx/item_place.mp3',
rateRange: [0.98, 1.06],
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,
},
structureBuild: {
src: '/audios/sfx/structure_build.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.15,
},
snapshotCapture: {
// Shutter should sound consistent — no variation.
src: '/audios/sfx/snapshot_capture.mp3',
},
} 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
const sfxCache = new Map<SFXName, Howl>()
const lastPlayedAt = new Map<SFXName, number>()
// Initialize all sounds
Object.entries(SFX).forEach(([name, config]) => {
const sound = new Howl({
src: [config.src],
preload: true,
volume: 0.5, // Will be adjusted by the bus
})
sfxCache.set(name as SFXName, sound)
})
/**
* Play a sound effect with volume based on audio settings
*/
export function playSFX(name: SFXName) {
const sound = sfxCache.get(name)
if (!sound) {
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)
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((sound) => {
sound.volume(finalVolume)
})
}