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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user