Lobby: MP3 dice sound with pitch per die, DM sound settings

Default dice sound is public/dice.mp3 with a small random pitch and a
base pitch per die size (d4 lighter ... d100 heavier). The DM can upload
an own dice sound and sounds for critical success / failure per lobby,
reset them, and switch result and critical sounds on or off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
This commit is contained in:
Claude
2026-10-23 19:04:12 +00:00
parent ce24a0ba6f
commit 03ebc3a138
7 changed files with 226 additions and 62 deletions
Binary file not shown.
+58 -8
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import PaperSheet, { ThemeSwitch } from './PaperSheet.jsx'
import { sound, playRoll } from './sound.js'
import { sound, playRoll, preload, DEFAULT_ROLL } from './sound.js'
import { MapView, MapLibrary, TokenForm, WallLayer } from './MapView.jsx'
const J = { 'Content-Type': 'application/json' }
@@ -303,7 +303,7 @@ function DieIcon({ sides }) {
const MODES = [['mystery', 'Mystery', 'Players see grey dice, but not the result'], ['hidden', 'Hidden', 'Only you see the roll'], ['public', 'Public', 'Everybody sees the roll']]
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy }) {
function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, onRoll, busy, sfx }) {
const [snd, setSnd] = useState(sound.enabled)
const [vol, setVol] = useState(sound.volume)
return (
@@ -322,8 +322,8 @@ function DiceBar({ to, me, isDm, count, setCount, mode, setMode, own, setOwn, on
<input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} style={{ width: 'auto' }} /> Own dice
</label>
<span className="soundctl small" title="Dice sounds">
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(1, 'success') }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(1)} />}
<button className="ghost" aria-pressed={snd} aria-label={snd ? 'Mute dice sounds' : 'Turn dice sounds on'} onClick={() => { sound.setEnabled(!snd); setSnd(!snd); if (!snd) playRoll(20, 1, undefined, sfx) }}>{snd ? '🔊' : '🔇'}</button>
{snd && <input type="range" min="0" max="1" step="0.05" value={vol} aria-label="Dice sound volume" onChange={(e) => { setVol(+e.target.value); sound.setVolume(+e.target.value) }} onPointerUp={() => playRoll(20, 1, undefined, sfx)} />}
</span>
{isDm && !to && (
<span className="seg small" role="group" aria-label="Who sees the DM's rolls">
@@ -498,6 +498,49 @@ function CharTab({ cid, rules, onRoll, tick }) {
}
// lobby settings: join code, rename, colour, character switch, members, leave / delete
// DM: replace the dice sound, add sounds for critical results, switch cues on or off
function SoundSettings({ lobby, id, act, sfx }) {
const sn = lobby.sounds
const [err, setErr] = useState('')
const upload = async (slot, e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
const r = await fetch(`/api/lobbies/${id}/sounds/${slot}`, { method: 'POST', headers: { 'Content-Type': 'audio/mpeg' }, body: f }).catch(() => null)
if (!r || !r.ok) return setErr(r ? ((await r.json().catch(() => ({}))).detail || 'Upload failed') : 'Upload failed')
act()
}
const flag = (k, v) => act(`/api/lobbies/${id}/sounds`, 'PUT', { [k]: v })
const row = (slot, label, defaultText, flagKey) => (
<div className="soundrow">
<div className="row">
{flagKey && <label className="small chk"><input type="checkbox" checked={!!sn[flagKey]} onChange={(e) => flag(flagKey, e.target.checked)} /> {label}</label>}
{!flagKey && <strong className="small">{label}</strong>}
</div>
<div className="row small">
<span className="muted">{sn.custom[slot] ? 'Your file' : defaultText}</span>
<span className="row">
<button className="ghost" onClick={() => playRoll(slot === 'roll' ? 20 : 100, 1, slot === 'roll' ? undefined : slot, { ...sfx, [`${slot}_on`]: true })}>▶ Test</button>
<label className="uploadbtn small">Upload MP3<input type="file" accept="audio/mpeg,.mp3" hidden onChange={(e) => upload(slot, e)} /></label>
{sn.custom[slot] && <button className="ghost" onClick={() => act(`/api/lobbies/${id}/sounds/${slot}`, 'DELETE')}>Reset</button>}
</span>
</div>
</div>
)
return (
<div className="soundsettings">
<h4>Sounds</h4>
<p className="small muted">For everyone in this lobby. MP3 up to 1.5 MB; players can still mute the sounds for themselves.</p>
{row('roll', 'Dice sound', 'Default sound', null)}
<label className="small chk"><input type="checkbox" checked={!!sn.result} onChange={(e) => flag('result', e.target.checked)} /> Short tones for success / failure of a check</label>
{row('crit_success', 'Critical success', 'Built-in tone', 'crit_success')}
{row('crit_failure', 'Critical failure', 'Built-in tone', 'crit_failure')}
{err && <p className="warn small">{err}</p>}
</div>
)
}
function Settings({ lobby, id, mine, act, setInfo, onClose, inline }) {
const [rename, setRename] = useState('')
const [pick, setPick] = useState('')
@@ -604,10 +647,17 @@ function LobbyView({ id, rules, user }) {
return () => clearInterval(t)
}, [load, loadMap, up])
const sfx = useRef({})
const sn = lobby?.sounds
sfx.current = sn ? { roll: sn.custom.roll ? `/api/lobbies/${id}/sounds/roll?v=${sn.v}` : DEFAULT_ROLL,
crit_success: sn.custom.crit_success ? `/api/lobbies/${id}/sounds/crit_success?v=${sn.v}` : null,
crit_failure: sn.custom.crit_failure ? `/api/lobbies/${id}/sounds/crit_failure?v=${sn.v}` : null,
result: sn.result, crit_success_on: sn.crit_success, crit_failure_on: sn.crit_failure } : { roll: DEFAULT_ROLL }
useEffect(() => { preload(sfx.current) }, [sn?.v]) // eslint-disable-line
const onRoll = useCallback((m) => {
const key = `${m.id}`
if (m.kind === 'check') playRoll(1, m.check.outcome)
else if (m.kind === 'roll') playRoll(m.roll.n)
if (m.kind === 'check') playRoll(100, 1, m.check.outcome, sfx.current)
else if (m.kind === 'roll') playRoll(m.roll.sides, m.roll.n, undefined, sfx.current)
setTray((t) => [...t.slice(-2), { ...m, key }])
setTimeout(() => setTray((t) => t.filter((x) => x.key !== key)), 20000)
}, [])
@@ -695,7 +745,7 @@ function LobbyView({ id, rules, user }) {
<span className="tabright">{(!lobby.is_dm || leftTab === 'sheet') && <ThemeSwitch />} <button className="ghost" onClick={() => { setSheetOpen(false); store('aw_sheetopen', '0') }} title="Hide this column">◀</button></span>
</div>
{lobby.is_dm && leftTab === 'lobby'
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
? <><MapLibrary activeId={mapState.map?.id} lobbyId={id} act={act} /><SoundSettings lobby={lobby} id={id} act={act} sfx={sfx.current} /><Settings inline lobby={lobby} id={id} mine={mine} act={act} setInfo={setInfo} /></>
: cur ? <CharTab key={cur.character_id} cid={cur.character_id} rules={rules} tick={sheetTick.cid === cur.character_id ? sheetTick.n : 0} onRoll={cur.me && canCheck ? check : undefined} />
: <p className="muted small">{lobby.is_dm ? 'Pick a character in the bar to look at its sheet.' : 'You have no character in this lobby.'}</p>}
</div>
@@ -737,7 +787,7 @@ function LobbyView({ id, rules, user }) {
{pending && <ManualEntry key={JSON.stringify(pending)} pending={pending} onSubmit={submitManual} onCancel={() => { setPending(null); setMErr('') }} err={mErr} />}
{!pending && mErr && <p className="warn small tableerr">{mErr}</p>}
</div>
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} />
<DiceBar to={dest} me={user} isDm={lobby.is_dm} count={count} setCount={setCount} mode={mode} setMode={setMode} own={own} setOwn={setOwn} onRoll={throwDice} busy={busy} sfx={sfx.current} />
</div>
<aside className="lobbychat card" aria-label="Chat">
+51 -52
View File
@@ -1,4 +1,6 @@
// Dice sounds, made with the Web Audio API (no sound files): a clatter per die and a short cue for the result of a check.
// Dice sounds. The default is public/dice.mp3 (the DM can replace it per lobby), played with a slight random pitch and a pitch that
// depends on the die: d4 a little higher and lighter, d100 a little lower and heavier. Results of checks add a short cue; the cues for
// critical results can be custom MP3s from the DM, otherwise they are synthesized tones.
let ctx = null
const get = (k) => { try { return localStorage.getItem(k) } catch { return null } }
const put = (k, v) => { try { localStorage.setItem(k, v) } catch { /* storage may be blocked */ } }
@@ -10,6 +12,9 @@ export const sound = {
setVolume(v) { this.volume = v; put('aw_volume', String(v)) },
}
export const DEFAULT_ROLL = '/dice.mp3'
const PITCH = { 4: 1.08, 6: 1.05, 8: 1.025, 10: 1.0, 12: 0.98, 20: 0.955, 100: 0.92 } // playback speed per die (lighter ... heavier)
const audio = () => {
if (!ctx) {
const C = window.AudioContext || window.webkitAudioContext
@@ -20,42 +25,28 @@ const audio = () => {
return ctx
}
let noise = null
const noiseBuffer = (c) => {
if (!noise) {
noise = c.createBuffer(1, c.sampleRate * 0.25, c.sampleRate)
const d = noise.getChannelData(0)
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1
const buffers = new Map() // url -> Promise<AudioBuffer | null>
const buffer = (url) => {
if (!url) return Promise.resolve(null)
if (!buffers.has(url)) {
const c = audio()
buffers.set(url, !c ? Promise.resolve(null) : fetch(url).then((r) => (r.ok ? r.arrayBuffer() : null))
.then((b) => (b ? c.decodeAudioData(b) : null)).catch(() => { buffers.delete(url); return null }))
}
return noise
return buffers.get(url)
}
// load everything of a lobby up front, so the first roll does not wait for the download
export const preload = (cfg) => { if (cfg) [cfg.roll, cfg.crit_success, cfg.crit_failure].forEach(buffer) }
// one bounce of a heavy die on a wooden table: a low thump with a dull knock on top
const tock = (c, out, t, strength) => {
const src = c.createBufferSource()
src.buffer = noiseBuffer(c)
const f = c.createBiquadFilter()
f.type = 'bandpass'
f.frequency.value = 260 + Math.random() * 900
f.Q.value = 0.7 + Math.random()
const clip = (c, out, buf, t, rate, vol) => {
const s = c.createBufferSource()
s.buffer = buf
s.playbackRate.value = rate
const g = c.createGain()
g.gain.setValueAtTime(0.0001, t)
g.gain.exponentialRampToValueAtTime(strength, t + 0.004)
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07 + Math.random() * 0.06)
src.connect(f).connect(g).connect(out)
src.start(t, Math.random() * 0.1)
src.stop(t + 0.2)
const o = c.createOscillator() // the weight: a sine that drops in pitch
o.type = 'sine'
o.frequency.setValueAtTime(130 + Math.random() * 40, t)
o.frequency.exponentialRampToValueAtTime(48, t + 0.14)
const og = c.createGain()
og.gain.setValueAtTime(0.0001, t)
og.gain.exponentialRampToValueAtTime(strength * 1.1, t + 0.006)
og.gain.exponentialRampToValueAtTime(0.0001, t + 0.16)
o.connect(og).connect(out)
o.start(t)
o.stop(t + 0.2)
g.gain.value = vol
s.connect(g).connect(out)
s.start(t)
return buf.duration / rate
}
const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
@@ -71,31 +62,39 @@ const tone = (c, out, t, freq, len, type = 'triangle', vol = 0.25) => {
o.stop(t + len + 0.05)
}
// n dice thrown; outcome ('crit_success' | 'success' | 'failure' | 'crit_failure') adds a short cue after they settled
export function playRoll(n, outcome) {
const cue = {
crit_success: (c, out, t) => { tone(c, out, t, 196, 0.7, 'triangle', 0.3); tone(c, out, t + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t + 0.16, 392, 0.9, 'sine', 0.12) },
success: (c, out, t) => { tone(c, out, t, 147, 0.5, 'triangle', 0.3); tone(c, out, t, 220, 0.4, 'sine', 0.1) },
failure: (c, out, t) => tone(c, out, t, 82, 0.55, 'triangle', 0.35),
crit_failure: (c, out, t) => { tone(c, out, t, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t + 0.22, 62, 1.0, 'sawtooth', 0.18) },
}
// sides: kind of die (100 for checks), n: number of dice, outcome: result of a check (optional)
// cfg: { roll: url, crit_success: url | null, crit_failure: url | null, result: bool, crit_success_on: bool, crit_failure_on: bool }
export async function playRoll(sides, n, outcome, cfg = {}) {
if (!sound.enabled || sound.volume <= 0 || document.hidden) return
const c = audio()
if (!c) return
const [buf, custom] = await Promise.all([buffer(cfg.roll || DEFAULT_ROLL), outcome ? buffer(cfg[outcome]) : null])
const out = c.createGain()
out.gain.value = sound.volume
const dull = c.createBiquadFilter() // takes the brightness off everything
dull.type = 'lowpass'
dull.frequency.value = 2200
const comp = c.createDynamicsCompressor()
out.connect(dull).connect(comp).connect(c.destination)
out.connect(c.destination)
const t0 = c.currentTime + 0.02
let end = 0
for (let i = 0; i < Math.min(n, 10); i++) {
let t = t0 + i * 0.11
for (let k = 0; k < 5; k++) { // fewer, heavier bounces that die away quickly
tock(c, out, t, 0.85 * Math.pow(0.55, k) * (0.75 + Math.random() * 0.25))
t += 0.07 + k * 0.05 + Math.random() * 0.03
let length = 0.9
if (buf) {
const base = PITCH[sides] || 1
const layers = Math.min(Math.max(n, 1), 3) // many dice: a few layers, each with its own pitch
for (let i = 0; i < layers; i++) {
const rate = base * (1 + (Math.random() - 0.5) * 0.05)
length = Math.max(length, i * 0.08 + clip(c, out, buf, t0 + i * 0.08, rate, 1 / Math.sqrt(layers)))
}
end = Math.max(end, t)
}
const t1 = end + 0.1
if (outcome === 'crit_success') { tone(c, out, t1, 196, 0.7, 'triangle', 0.3); tone(c, out, t1 + 0.16, 294, 0.8, 'triangle', 0.28); tone(c, out, t1 + 0.16, 392, 0.9, 'sine', 0.12) }
else if (outcome === 'success') { tone(c, out, t1, 147, 0.5, 'triangle', 0.3); tone(c, out, t1, 220, 0.4, 'sine', 0.1) }
else if (outcome === 'failure') tone(c, out, t1, 82, 0.55, 'triangle', 0.35)
else if (outcome === 'crit_failure') { tone(c, out, t1, 98, 0.5, 'sawtooth', 0.14); tone(c, out, t1 + 0.22, 62, 1.0, 'sawtooth', 0.18) }
if (!outcome) return
const t1 = t0 + Math.min(1.2, length * 0.75)
const crit = outcome === 'crit_success' || outcome === 'crit_failure'
if (crit) {
if (cfg[`${outcome}_on`] === false) return
if (custom) clip(c, out, custom, t1, 1, 1)
else cue[outcome](c, out, t1)
} else if (cfg.result !== false) cue[outcome](c, out, t1)
}
+4
View File
@@ -462,3 +462,7 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.traytoken { font-size: .85rem; }
.soundctl { display: inline-flex; align-items: center; gap: .3rem; }
.soundctl input[type=range] { width: 5rem; padding: 0; }
.soundsettings { display: grid; gap: .5rem; margin-bottom: 1rem; }
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }