Lane F: procedural WebAudio engine + soundboard demo
Event-driven comedy SFX, all synthesized (no assets, no deps):
- engine.ts: lazy AudioContext (dormant until ui:play / first gesture),
master chain at ~-12dBFS through a soft limiter, persisted mute
(blobbo:muted), 10-voice polyphony cap with steal-nearest-to-finish.
- sounds.ts: squish (impact-scaled wet splat), boing (springy sweep),
splat (per-colour pentatonic bloop, PALETTE order), sparkle (bubbly
arpeggio), clank+windup (0.5s accelerating ratchet matching the machine
telegraph), tick (starter blip), fanfare (major arpeggio + new-best
flourish), optional ambient conveyor hum. All ±10% pitch-randomized,
all <400ms except fanfare.
- install.ts: subscribes the frozen wire events, returns { engine },
mounts the bottom-right mute button.
- demos/lane-f.html + src/demo/lane-f.ts: soundboard driving the real
installAudio wiring; impact slider, colour picker, splat-storm stress.
Verified: tsc strict + vite build pass; 36-check headless harness proves
dormancy pre-unlock, single-context creation, every sound synthesizes,
voice cap holds under a 60-splat storm, and mute persists. Caught+fixed a
clank() infinite loop (geometric tick interval converged below windup).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
fedb43817c
commit
1e2e49518f
45
demos/lane-f.html
Normal file
45
demos/lane-f.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>BLOBBO lane f — audio soundboard</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html,body{margin:0;min-height:100%;background:#12151f;color:#eaf2ff;
|
||||||
|
font-family:system-ui,sans-serif}
|
||||||
|
#app{max-width:760px;margin:0 auto;padding:26px 18px 90px}
|
||||||
|
h1{font-size:20px;margin:0 0 4px}
|
||||||
|
.sub{opacity:.7;font-size:13px;margin:0 0 20px;line-height:1.5}
|
||||||
|
.unlock{background:#1c3d6b;border:1px solid #2f5fa8;border-radius:10px;
|
||||||
|
padding:10px 14px;font-size:13px;margin:0 0 20px}
|
||||||
|
.unlock.on{background:#12351f;border-color:#2f8a4a}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
|
||||||
|
button.pad{appearance:none;border:none;border-radius:12px;padding:16px 12px;
|
||||||
|
background:#222838;color:#fff;font-size:15px;font-weight:600;cursor:pointer;
|
||||||
|
transition:transform .05s, background .1s;box-shadow:0 2px 6px rgba(0,0,0,.3)}
|
||||||
|
button.pad:hover{background:#2c3448}
|
||||||
|
button.pad:active{transform:scale(.96);background:#3a4258}
|
||||||
|
button.pad small{display:block;font-weight:400;opacity:.6;font-size:11px;margin-top:4px}
|
||||||
|
.ctl{margin:22px 0 6px;padding:14px 16px;background:#1a1e2b;border-radius:12px}
|
||||||
|
.ctl h2{font-size:13px;margin:0 0 10px;opacity:.8;text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
.row{display:flex;align-items:center;gap:12px;margin:8px 0;flex-wrap:wrap}
|
||||||
|
.row label{min-width:120px;font-size:13px}
|
||||||
|
input[type=range]{flex:1;min-width:140px}
|
||||||
|
select{background:#222838;color:#fff;border:1px solid #333c52;border-radius:8px;padding:6px 8px}
|
||||||
|
.val{font-variant-numeric:tabular-nums;min-width:52px;text-align:right;opacity:.85;font-size:13px}
|
||||||
|
code{background:#0d1017;padding:1px 5px;border-radius:4px;font-size:12px}
|
||||||
|
kbd{background:#333c52;border-radius:5px;padding:1px 6px;font-size:12px}
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<h1>🔊 BLOBBO — Lane F audio soundboard</h1>
|
||||||
|
<p class="sub">
|
||||||
|
Every sound is synthesized WebAudio (no assets). Fires the same frozen wire
|
||||||
|
events the game does, through <code>installAudio()</code>.
|
||||||
|
The <kbd>🔈</kbd> button (bottom-right) toggles mute — it persists across reloads.
|
||||||
|
</p>
|
||||||
|
<div class="unlock" id="unlock">🔒 Audio is dormant — click anywhere (or a pad) to unlock the AudioContext.</div>
|
||||||
|
<div class="grid" id="pads"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/demo/lane-f.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
232
src/audio/engine.ts
Normal file
232
src/audio/engine.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Lane F — AudioEngine.
|
||||||
|
*
|
||||||
|
* Procedural WebAudio, comedy-first. NO asset files, NO deps.
|
||||||
|
*
|
||||||
|
* Autoplay policy: the `AudioContext` is created LAZILY, only inside `unlock()`,
|
||||||
|
* which integration calls from the `ui:play` event or a first user gesture.
|
||||||
|
* Constructing the engine touches nothing — `ctx` stays `null`, so the browser
|
||||||
|
* (and headless verification) sees a fully dormant engine until a real gesture.
|
||||||
|
*
|
||||||
|
* Everything a one-shot needs is here: a shared master chain (gain → soft
|
||||||
|
* limiter → destination) kept well below pain (~-12 dBFS), a persisted mute
|
||||||
|
* toggle (`blobbo:muted`), a reusable white-noise buffer, and a small polyphony
|
||||||
|
* cap so paint/squish event-storms can't stack into clipping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MASTER_DB = -12 // master gain, well below pain
|
||||||
|
const MAX_VOICES = 10 // polyphony cap — storms steal the voice nearest to finishing
|
||||||
|
const MUTE_KEY = 'blobbo:muted'
|
||||||
|
|
||||||
|
/** dBFS → linear amplitude. */
|
||||||
|
export const dbToGain = (db: number): number => Math.pow(10, db / 20)
|
||||||
|
|
||||||
|
function readMuted(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(MUTE_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One live one-shot: its output gain + when it is expected to be silent. */
|
||||||
|
interface Voice {
|
||||||
|
g: GainNode
|
||||||
|
end: number
|
||||||
|
cleanup: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MuteListener = (muted: boolean) => void
|
||||||
|
|
||||||
|
export class AudioEngine {
|
||||||
|
/** The live context, or `null` while dormant. Public so tests can assert it. */
|
||||||
|
ctx: AudioContext | null = null
|
||||||
|
|
||||||
|
private master: GainNode | null = null
|
||||||
|
private limiter: DynamicsCompressorNode | null = null
|
||||||
|
private _noise: AudioBuffer | null = null
|
||||||
|
private voices: Voice[] = []
|
||||||
|
private _muted: boolean
|
||||||
|
private muteListeners = new Set<MuteListener>()
|
||||||
|
|
||||||
|
/** Max simultaneous one-shot voices (exposed for the demo / tests). */
|
||||||
|
static readonly MAX_VOICES = MAX_VOICES
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Read persisted mute here — cheap, no audio nodes, keeps the ctx dormant.
|
||||||
|
this._muted = readMuted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once `unlock()` has actually created the context. */
|
||||||
|
get ready(): boolean {
|
||||||
|
return this.ctx !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
get muted(): boolean {
|
||||||
|
return this._muted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current audio clock (0 while dormant so callers can no-op safely). */
|
||||||
|
get time(): number {
|
||||||
|
return this.ctx ? this.ctx.currentTime : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared white-noise buffer. Only valid once `ready`. */
|
||||||
|
get noiseBuffer(): AudioBuffer | null {
|
||||||
|
return this._noise
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live voice count — used by tests to prove the polyphony cap holds. */
|
||||||
|
get activeVoices(): number {
|
||||||
|
return this.voices.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create + resume the context. Idempotent: safe to call from BOTH `ui:play`
|
||||||
|
* and a first-gesture fallback — the second call just resumes. Must be invoked
|
||||||
|
* from a user-gesture turn or the browser leaves the context suspended.
|
||||||
|
*/
|
||||||
|
unlock(): void {
|
||||||
|
if (this.ctx) {
|
||||||
|
if (this.ctx.state === 'suspended') void this.ctx.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const Ctor: typeof AudioContext | undefined =
|
||||||
|
typeof AudioContext !== 'undefined'
|
||||||
|
? AudioContext
|
||||||
|
: (globalThis as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||||
|
.webkitAudioContext
|
||||||
|
if (!Ctor) return
|
||||||
|
|
||||||
|
const ctx = new Ctor()
|
||||||
|
this.ctx = ctx
|
||||||
|
|
||||||
|
// Soft limiter so simultaneous voices round off instead of clipping.
|
||||||
|
const limiter = ctx.createDynamicsCompressor()
|
||||||
|
limiter.threshold.value = -6
|
||||||
|
limiter.knee.value = 8
|
||||||
|
limiter.ratio.value = 12
|
||||||
|
limiter.attack.value = 0.003
|
||||||
|
limiter.release.value = 0.12
|
||||||
|
this.limiter = limiter
|
||||||
|
|
||||||
|
const master = ctx.createGain()
|
||||||
|
master.gain.value = this._muted ? 0 : dbToGain(MASTER_DB)
|
||||||
|
master.connect(limiter).connect(ctx.destination)
|
||||||
|
this.master = master
|
||||||
|
|
||||||
|
this._noise = makeNoiseBuffer(ctx)
|
||||||
|
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocate a voice: a fresh gain node already wired to the master chain.
|
||||||
|
* `endTime` is the ctx-time the sound is expected to be silent — used to steal
|
||||||
|
* voices (the one nearest finishing dies first) and to schedule teardown.
|
||||||
|
* Returns `null` while dormant so every sound can early-out cleanly.
|
||||||
|
*/
|
||||||
|
voice(endTime: number): GainNode | null {
|
||||||
|
const ctx = this.ctx
|
||||||
|
const master = this.master
|
||||||
|
if (!ctx || !master) return null
|
||||||
|
|
||||||
|
// Polyphony cap: over budget → retire whichever voice finishes soonest.
|
||||||
|
if (this.voices.length >= MAX_VOICES) {
|
||||||
|
let idx = 0
|
||||||
|
for (let i = 1; i < this.voices.length; i++) {
|
||||||
|
if (this.voices[i].end < this.voices[idx].end) idx = i
|
||||||
|
}
|
||||||
|
const [victim] = this.voices.splice(idx, 1)
|
||||||
|
if (victim) this.retire(victim, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.connect(master)
|
||||||
|
|
||||||
|
const voice: Voice = {
|
||||||
|
g,
|
||||||
|
end: endTime,
|
||||||
|
cleanup: () => {
|
||||||
|
try {
|
||||||
|
g.disconnect()
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.voices.push(voice)
|
||||||
|
|
||||||
|
// Free the node a hair after it should be silent.
|
||||||
|
const ms = Math.max(0, (endTime - ctx.currentTime) * 1000) + 80
|
||||||
|
setTimeout(() => {
|
||||||
|
const i = this.voices.indexOf(voice)
|
||||||
|
if (i >= 0) this.voices.splice(i, 1)
|
||||||
|
voice.cleanup()
|
||||||
|
}, ms)
|
||||||
|
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
private retire(v: Voice, fade: boolean): void {
|
||||||
|
if (fade && this.ctx) {
|
||||||
|
const t = this.ctx.currentTime
|
||||||
|
try {
|
||||||
|
v.g.gain.cancelScheduledValues(t)
|
||||||
|
v.g.gain.setValueAtTime(v.g.gain.value, t)
|
||||||
|
v.g.gain.linearRampToValueAtTime(0, t + 0.02)
|
||||||
|
} catch {
|
||||||
|
/* param may be finished */
|
||||||
|
}
|
||||||
|
setTimeout(v.cleanup, 40)
|
||||||
|
} else {
|
||||||
|
v.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh source playing the shared noise buffer. `null` while dormant. */
|
||||||
|
noiseSource(): AudioBufferSourceNode | null {
|
||||||
|
if (!this.ctx || !this._noise) return null
|
||||||
|
const src = this.ctx.createBufferSource()
|
||||||
|
src.buffer = this._noise
|
||||||
|
src.loop = true
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuted(muted: boolean): void {
|
||||||
|
this._muted = muted
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MUTE_KEY, muted ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
/* private mode — mute simply won't persist */
|
||||||
|
}
|
||||||
|
if (this.ctx && this.master) {
|
||||||
|
const t = this.ctx.currentTime
|
||||||
|
const g = this.master.gain
|
||||||
|
g.cancelScheduledValues(t)
|
||||||
|
g.setValueAtTime(g.value, t)
|
||||||
|
g.linearRampToValueAtTime(muted ? 0 : dbToGain(MASTER_DB), t + 0.05)
|
||||||
|
}
|
||||||
|
this.muteListeners.forEach((fn) => fn(muted))
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute(): boolean {
|
||||||
|
this.setMuted(!this._muted)
|
||||||
|
return this._muted
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to mute changes (the 🔈/🔇 button keeps its glyph in sync). */
|
||||||
|
onMuteChange(fn: MuteListener): () => void {
|
||||||
|
this.muteListeners.add(fn)
|
||||||
|
return () => this.muteListeners.delete(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ~1 s of white noise, reused by every noise-based one-shot. */
|
||||||
|
function makeNoiseBuffer(ctx: AudioContext): AudioBuffer {
|
||||||
|
const len = Math.floor(ctx.sampleRate)
|
||||||
|
const buf = ctx.createBuffer(1, len, ctx.sampleRate)
|
||||||
|
const data = buf.getChannelData(0)
|
||||||
|
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
|
||||||
|
return buf
|
||||||
|
}
|
||||||
19
src/audio/index.ts
Normal file
19
src/audio/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Lane F — audio barrel. Integration wires audio with a single line:
|
||||||
|
*
|
||||||
|
* import { installAudio } from './audio'
|
||||||
|
* installAudio(world) // subscribes to the frozen wire events, returns { engine }
|
||||||
|
*/
|
||||||
|
export { AudioEngine, dbToGain } from './engine'
|
||||||
|
export { installAudio, type AudioHandle } from './install'
|
||||||
|
export {
|
||||||
|
squish,
|
||||||
|
boing,
|
||||||
|
splat,
|
||||||
|
sparkle,
|
||||||
|
clank,
|
||||||
|
tick,
|
||||||
|
fanfare,
|
||||||
|
startAmbientHum,
|
||||||
|
type Ambient,
|
||||||
|
} from './sounds'
|
||||||
89
src/audio/install.ts
Normal file
89
src/audio/install.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Lane F — installAudio(world).
|
||||||
|
*
|
||||||
|
* Subscribes the synthesized one-shots to the frozen wire events and returns
|
||||||
|
* the engine. Consumes events ONLY — never emits gameplay events.
|
||||||
|
*
|
||||||
|
* Unlock: the context is created on `ui:play` (Lane H's title screen) OR the
|
||||||
|
* first real user gesture, whichever comes first. Both paths funnel through the
|
||||||
|
* idempotent `engine.unlock()`, so the context is still created lazily and stays
|
||||||
|
* `null` until a genuine gesture — the autoplay policy is respected either way.
|
||||||
|
*
|
||||||
|
* A small 🔈/🔇 button is pinned bottom-right; clicking it toggles mute (and
|
||||||
|
* counts as the unlocking gesture).
|
||||||
|
*/
|
||||||
|
import type { PaintColor, World } from '../contracts'
|
||||||
|
import { AudioEngine } from './engine'
|
||||||
|
import { boing, clank, fanfare, sparkle, splat, squish, tick } from './sounds'
|
||||||
|
|
||||||
|
export interface AudioHandle {
|
||||||
|
engine: AudioEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A finish is a new best when the reported best equals the run time. */
|
||||||
|
function isNewBest(p: { time?: number; best?: number | null } | undefined): boolean {
|
||||||
|
if (!p || p.best == null || p.time == null) return false
|
||||||
|
return p.time <= p.best + 1e-6
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installAudio(world: World): AudioHandle {
|
||||||
|
const engine = new AudioEngine()
|
||||||
|
|
||||||
|
// ---- unlock paths (both idempotent) ------------------------------------
|
||||||
|
world.events.on('ui:play', () => engine.unlock())
|
||||||
|
|
||||||
|
// First-gesture fallback so the engine unlocks even if `ui:play` never fires
|
||||||
|
// (e.g. a demo/embed without Lane H's title screen). Removes itself on use.
|
||||||
|
const GESTURES = ['pointerdown', 'keydown', 'touchstart'] as const
|
||||||
|
const onFirstGesture = (): void => {
|
||||||
|
engine.unlock()
|
||||||
|
for (const g of GESTURES) removeEventListener(g, onFirstGesture)
|
||||||
|
}
|
||||||
|
if (typeof addEventListener !== 'undefined') {
|
||||||
|
for (const g of GESTURES) addEventListener(g, onFirstGesture, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- event → sound wiring ----------------------------------------------
|
||||||
|
// Every sound no-ops while dormant, so events before unlock are simply silent.
|
||||||
|
world.events.on('blob:landed', (p: { impact?: number }) => squish(engine, p?.impact ?? 1))
|
||||||
|
world.events.on('blob:jumped', () => boing(engine))
|
||||||
|
world.events.on('paint:splatted', (p: { color: PaintColor; target: 'blob' | 'world' }) => {
|
||||||
|
if (p?.target === 'blob') splat(engine, p.color) // world/ground decals stay silent
|
||||||
|
})
|
||||||
|
world.events.on('paint:request-cleanse', () => sparkle(engine))
|
||||||
|
world.events.on('machine:signal', () => clank(engine))
|
||||||
|
world.events.on('race:started', () => tick(engine))
|
||||||
|
world.events.on('race:finished', (p: { time: number; best: number | null }) =>
|
||||||
|
fanfare(engine, isNewBest(p)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- mute button (bottom-right) ----------------------------------------
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
mountMuteButton(engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { engine }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountMuteButton(engine: AudioEngine): HTMLButtonElement {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.type = 'button'
|
||||||
|
btn.setAttribute('aria-label', 'Toggle sound')
|
||||||
|
btn.style.cssText =
|
||||||
|
'position:fixed;right:14px;bottom:14px;z-index:40;width:44px;height:44px;' +
|
||||||
|
'border:none;border-radius:12px;cursor:pointer;font-size:20px;line-height:44px;' +
|
||||||
|
'text-align:center;background:rgba(20,24,34,.78);color:#fff;' +
|
||||||
|
'box-shadow:0 2px 8px rgba(0,0,0,.25);user-select:none;padding:0'
|
||||||
|
const render = (): void => {
|
||||||
|
btn.textContent = engine.muted ? '🔇' : '🔈'
|
||||||
|
btn.style.opacity = engine.muted ? '0.6' : '1'
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
engine.onMuteChange(render)
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
engine.unlock() // the click is a valid gesture — safe to create the ctx here
|
||||||
|
engine.toggleMute()
|
||||||
|
})
|
||||||
|
document.body.appendChild(btn)
|
||||||
|
return btn
|
||||||
|
}
|
||||||
455
src/audio/sounds.ts
Normal file
455
src/audio/sounds.ts
Normal file
@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Lane F — synthesized one-shots.
|
||||||
|
*
|
||||||
|
* Every sound is built live from oscillators, the shared noise buffer, filters
|
||||||
|
* and envelopes — no samples. Comedy > realism: the squish/boing/splat trio
|
||||||
|
* fires constantly, so they get the most layering (noise body + tonal blip +
|
||||||
|
* pitch sweep) rather than a bare beep.
|
||||||
|
*
|
||||||
|
* All are pitch-randomized ±10% per play, all early-out silently while the
|
||||||
|
* engine is dormant (`voice()` returns `null`), and all are SHORT (<400 ms)
|
||||||
|
* except the fanfare.
|
||||||
|
*/
|
||||||
|
import { PALETTE, type PaintColor } from '../contracts'
|
||||||
|
import type { AudioEngine } from './engine'
|
||||||
|
|
||||||
|
// PALETTE key order == the frozen colour order; index → pentatonic step.
|
||||||
|
const COLOR_ORDER = Object.keys(PALETTE) as PaintColor[]
|
||||||
|
// Major pentatonic degrees (semitones), one per colour, spanning just past an
|
||||||
|
// octave so each colour splats at its own recognisable pitch.
|
||||||
|
const PENTA = [0, 2, 4, 7, 9, 12, 14]
|
||||||
|
|
||||||
|
/** 0.9..1.1 — the ±10% per-play pitch wobble. */
|
||||||
|
const pr = (): number => 0.9 + Math.random() * 0.2
|
||||||
|
const rand = (a: number, b: number): number => a + Math.random() * (b - a)
|
||||||
|
const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x)
|
||||||
|
|
||||||
|
/** Percussive AD envelope on a gain param. Ramps stay >0 for exp legality. */
|
||||||
|
function perc(
|
||||||
|
param: AudioParam,
|
||||||
|
t0: number,
|
||||||
|
peak: number,
|
||||||
|
attack: number,
|
||||||
|
hold: number,
|
||||||
|
release: number,
|
||||||
|
): void {
|
||||||
|
const floor = 0.0001
|
||||||
|
param.cancelScheduledValues(t0)
|
||||||
|
param.setValueAtTime(floor, t0)
|
||||||
|
param.exponentialRampToValueAtTime(Math.max(peak, floor * 2), t0 + attack)
|
||||||
|
const s = t0 + attack + hold
|
||||||
|
param.setValueAtTime(Math.max(peak, floor * 2), s)
|
||||||
|
param.exponentialRampToValueAtTime(floor, s + release)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// squish — blob:landed. Wet splat whose weight scales with impact velocity.
|
||||||
|
// Small hops go "blip", big drops go "SPLORÐ". impact is downward speed (>0.8).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function squish(engine: AudioEngine, impact = 1): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const b = clamp01((impact - 0.8) / 14) // 0 = feather landing, 1 = huge drop
|
||||||
|
const t = engine.time
|
||||||
|
const dur = 0.09 + b * 0.2
|
||||||
|
const out = engine.voice(t + dur + 0.1)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
// Wet body: lowpass noise with a downward filter sweep = the squelch.
|
||||||
|
const noise = engine.noiseSource()
|
||||||
|
if (noise) {
|
||||||
|
const lp = ctx.createBiquadFilter()
|
||||||
|
lp.type = 'lowpass'
|
||||||
|
lp.Q.value = 6 + b * 4
|
||||||
|
const f0 = (1600 - b * 800) * pr()
|
||||||
|
lp.frequency.setValueAtTime(f0, t)
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(Math.max(120, f0 * 0.25), t + dur)
|
||||||
|
const ng = ctx.createGain()
|
||||||
|
perc(ng.gain, t, 0.4 + b * 0.5, 0.004, 0.005, dur)
|
||||||
|
noise.connect(lp).connect(ng).connect(out)
|
||||||
|
noise.start(t)
|
||||||
|
noise.stop(t + dur + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tonal "womp" — a sine that drops in pitch, giving the landing its weight.
|
||||||
|
const body = ctx.createOscillator()
|
||||||
|
body.type = 'sine'
|
||||||
|
const bf = (230 - b * 110) * pr()
|
||||||
|
body.frequency.setValueAtTime(bf * 1.6, t)
|
||||||
|
body.frequency.exponentialRampToValueAtTime(bf * 0.6, t + dur)
|
||||||
|
const bg = ctx.createGain()
|
||||||
|
perc(bg.gain, t, 0.3 + b * 0.45, 0.004, 0.01, dur * 0.9)
|
||||||
|
body.connect(bg).connect(out)
|
||||||
|
body.start(t)
|
||||||
|
body.stop(t + dur + 0.05)
|
||||||
|
|
||||||
|
// Big landings add a sub thud for real weight.
|
||||||
|
if (b > 0.35) {
|
||||||
|
const sub = ctx.createOscillator()
|
||||||
|
sub.type = 'sine'
|
||||||
|
sub.frequency.setValueAtTime(70 * pr(), t)
|
||||||
|
sub.frequency.exponentialRampToValueAtTime(45, t + dur)
|
||||||
|
const sg = ctx.createGain()
|
||||||
|
perc(sg.gain, t, 0.25 + b * 0.35, 0.005, 0.01, dur)
|
||||||
|
sub.connect(sg).connect(out)
|
||||||
|
sub.start(t)
|
||||||
|
sub.stop(t + dur + 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// boing — blob:jumped (and the gap-launcher). Springy up-then-down sweep with
|
||||||
|
// a decaying wobble so it reads as a comedy spring, not a synth blip.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function boing(engine: AudioEngine): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t = engine.time
|
||||||
|
const dur = 0.26
|
||||||
|
const out = engine.voice(t + dur + 0.1)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
const f0 = 300 * pr()
|
||||||
|
osc.frequency.setValueAtTime(f0, t)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f0 * 2.2, t + 0.05) // spring loads
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f0 * 0.85, t + dur) // ...and releases
|
||||||
|
|
||||||
|
// Wobble: an LFO on the frequency whose depth decays = the "oi-oi-oing".
|
||||||
|
const lfo = ctx.createOscillator()
|
||||||
|
lfo.type = 'sine'
|
||||||
|
lfo.frequency.value = 26 * pr()
|
||||||
|
const lfoGain = ctx.createGain()
|
||||||
|
lfoGain.gain.setValueAtTime(90, t + 0.05)
|
||||||
|
lfoGain.gain.exponentialRampToValueAtTime(2, t + dur)
|
||||||
|
lfo.connect(lfoGain).connect(osc.frequency)
|
||||||
|
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, t, 0.4, 0.004, 0.02, dur)
|
||||||
|
osc.connect(g).connect(out)
|
||||||
|
osc.start(t)
|
||||||
|
lfo.start(t)
|
||||||
|
osc.stop(t + dur + 0.05)
|
||||||
|
lfo.stop(t + dur + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// splat — paint:splatted target:'blob'. Wet noise burst + a pitched "bloop";
|
||||||
|
// each colour bloops at its own pentatonic pitch (PALETTE order).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function splat(engine: AudioEngine, color: PaintColor): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t = engine.time
|
||||||
|
const dur = 0.11
|
||||||
|
const out = engine.voice(t + dur + 0.1)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
const idx = Math.max(0, COLOR_ORDER.indexOf(color))
|
||||||
|
const semis = PENTA[idx % PENTA.length]
|
||||||
|
const tone = 300 * Math.pow(2, semis / 12) * pr()
|
||||||
|
|
||||||
|
// Wet "pfft": bandpassed noise, very short.
|
||||||
|
const noise = engine.noiseSource()
|
||||||
|
if (noise) {
|
||||||
|
const bp = ctx.createBiquadFilter()
|
||||||
|
bp.type = 'bandpass'
|
||||||
|
bp.frequency.value = (900 + idx * 130) * pr()
|
||||||
|
bp.Q.value = 1.4
|
||||||
|
const ng = ctx.createGain()
|
||||||
|
perc(ng.gain, t, 0.42, 0.002, 0.004, 0.06)
|
||||||
|
noise.connect(bp).connect(ng).connect(out)
|
||||||
|
noise.start(t)
|
||||||
|
noise.stop(t + 0.09)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pitched bloop — the colour's identity, drops a little as it lands.
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.setValueAtTime(tone, t)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(tone * 0.72, t + dur)
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, t, 0.34, 0.003, 0.006, dur)
|
||||||
|
osc.connect(g).connect(out)
|
||||||
|
osc.start(t)
|
||||||
|
osc.stop(t + dur + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sparkle — paint:request-cleanse. Bubbly ascending arpeggio + a fizz of
|
||||||
|
// highpassed noise: detergent bubbles washing you clean.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function sparkle(engine: AudioEngine): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t = engine.time
|
||||||
|
const steps = 5
|
||||||
|
const gap = 0.05
|
||||||
|
const dur = steps * gap + 0.14
|
||||||
|
const out = engine.voice(t + dur + 0.1)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
const root = 660 * pr()
|
||||||
|
const arp = [0, 4, 7, 12, 16] // bright, bubbly, ascending
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const bt = t + i * gap
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
const f = root * Math.pow(2, arp[i] / 12) * rand(0.99, 1.02)
|
||||||
|
osc.frequency.setValueAtTime(f * 0.98, bt)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f, bt + 0.02) // little bubble "pip"
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, bt, 0.16, 0.003, 0.005, 0.07)
|
||||||
|
osc.connect(g).connect(out)
|
||||||
|
osc.start(bt)
|
||||||
|
osc.stop(bt + 0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Airy fizz underneath.
|
||||||
|
const noise = engine.noiseSource()
|
||||||
|
if (noise) {
|
||||||
|
const hp = ctx.createBiquadFilter()
|
||||||
|
hp.type = 'highpass'
|
||||||
|
hp.frequency.value = 3500
|
||||||
|
const ng = ctx.createGain()
|
||||||
|
perc(ng.gain, t, 0.06, 0.02, 0.02, dur)
|
||||||
|
noise.connect(hp).connect(ng).connect(out)
|
||||||
|
noise.start(t)
|
||||||
|
noise.stop(t + dur)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// clank + windup — machine:signal. A ratchet that accelerates over ~0.5 s to
|
||||||
|
// match the contraption telegraph, then lands a clunk when it fires.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function clank(engine: AudioEngine): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t0 = engine.time
|
||||||
|
const windup = 0.5 // matches the 0.5 s machine telegraph
|
||||||
|
const out = engine.voice(t0 + windup + 0.22)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
// Schedule accelerating ticks: interval shrinks, pitch rises = building tension.
|
||||||
|
// The interval is floored (so the geometric decay can't converge below `windup`
|
||||||
|
// into an infinite loop) and the count is capped as a hard safety rail.
|
||||||
|
let tt = t0
|
||||||
|
let interval = 0.09
|
||||||
|
const pitchBase = 240 * pr()
|
||||||
|
let n = 0
|
||||||
|
while (tt < t0 + windup && n < 16) {
|
||||||
|
const click = ctx.createOscillator()
|
||||||
|
click.type = 'square'
|
||||||
|
click.frequency.value = pitchBase * (1 + n * 0.12)
|
||||||
|
const cg = ctx.createGain()
|
||||||
|
perc(cg.gain, tt, 0.14, 0.001, 0.002, 0.02)
|
||||||
|
click.connect(cg).connect(out)
|
||||||
|
click.start(tt)
|
||||||
|
click.stop(tt + 0.03)
|
||||||
|
|
||||||
|
// A tiny noise transient sharpens each ratchet tooth.
|
||||||
|
const noise = engine.noiseSource()
|
||||||
|
if (noise) {
|
||||||
|
const bp = ctx.createBiquadFilter()
|
||||||
|
bp.type = 'bandpass'
|
||||||
|
bp.frequency.value = 2600
|
||||||
|
bp.Q.value = 2
|
||||||
|
const ng = ctx.createGain()
|
||||||
|
perc(ng.gain, tt, 0.12, 0.001, 0.001, 0.015)
|
||||||
|
noise.connect(bp).connect(ng).connect(out)
|
||||||
|
noise.start(tt)
|
||||||
|
noise.stop(tt + 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
tt += interval
|
||||||
|
interval = Math.max(0.028, interval * 0.82) // accelerate, but floor the tooth gap
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landing clunk right as the machine fires.
|
||||||
|
const tf = t0 + windup
|
||||||
|
const clunk = ctx.createOscillator()
|
||||||
|
clunk.type = 'sine'
|
||||||
|
clunk.frequency.setValueAtTime(180 * pr(), tf)
|
||||||
|
clunk.frequency.exponentialRampToValueAtTime(70, tf + 0.14)
|
||||||
|
const clg = ctx.createGain()
|
||||||
|
perc(clg.gain, tf, 0.4, 0.002, 0.01, 0.16)
|
||||||
|
clunk.connect(clg).connect(out)
|
||||||
|
clunk.start(tf)
|
||||||
|
clunk.stop(tf + 0.22)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// tick — race:started. Single crisp starter blip.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function tick(engine: AudioEngine): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t = engine.time
|
||||||
|
const out = engine.voice(t + 0.14)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'square'
|
||||||
|
osc.frequency.value = 1000 * pr()
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, t, 0.32, 0.002, 0.03, 0.06)
|
||||||
|
osc.connect(g).connect(out)
|
||||||
|
osc.start(t)
|
||||||
|
osc.stop(t + 0.13)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// fanfare — race:finished. Dumb triumphant major arpeggio (root+fifth "band"),
|
||||||
|
// with an extra rising flourish + sparkle tail when it's a NEW BEST.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function fanfare(engine: AudioEngine, isBest = false): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const t0 = engine.time
|
||||||
|
const root = 392 * pr() // ~G4
|
||||||
|
const notes = [0, 4, 7, 12] // major arpeggio, dumb and happy
|
||||||
|
const step = 0.14
|
||||||
|
const lastHold = 0.32
|
||||||
|
|
||||||
|
let end = t0 + notes.length * step + lastHold
|
||||||
|
const flourish = isBest ? [16, 19, 24] : []
|
||||||
|
if (isBest) end += flourish.length * 0.08 + 0.3
|
||||||
|
const out = engine.voice(end + 0.15)
|
||||||
|
if (!out) return
|
||||||
|
out.gain.value = 1
|
||||||
|
|
||||||
|
const blast = (freq: number, at: number, len: number, peak: number): void => {
|
||||||
|
// Two detuned voices (root + a fifth on top) for a cheap brass-band stack.
|
||||||
|
for (const [mult, det, gainMul] of [
|
||||||
|
[1, 0, 1],
|
||||||
|
[1.5, 3, 0.5],
|
||||||
|
] as const) {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'square'
|
||||||
|
osc.frequency.value = freq * mult
|
||||||
|
osc.detune.value = det
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, at, peak * gainMul, 0.005, len * 0.4, len * 0.6)
|
||||||
|
// Gentle lowpass keeps the squares from being harsh.
|
||||||
|
const lp = ctx.createBiquadFilter()
|
||||||
|
lp.type = 'lowpass'
|
||||||
|
lp.frequency.value = 4200
|
||||||
|
osc.connect(lp).connect(g).connect(out)
|
||||||
|
osc.start(at)
|
||||||
|
osc.stop(at + len + 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.forEach((semi, i) => {
|
||||||
|
const at = t0 + i * step
|
||||||
|
const len = i === notes.length - 1 ? lastHold : step * 1.05
|
||||||
|
blast(root * Math.pow(2, semi / 12), at, len, 0.3)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isBest) {
|
||||||
|
// Rising flourish above the arpeggio, then a bright sparkle cap.
|
||||||
|
const base = t0 + notes.length * step
|
||||||
|
flourish.forEach((semi, i) => {
|
||||||
|
blast(root * Math.pow(2, semi / 12), base + i * 0.08, 0.12, 0.26)
|
||||||
|
})
|
||||||
|
sparkleTail(engine, out, base + flourish.length * 0.08)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bright descending twinkle used as the new-best cap (shares the fanfare voice). */
|
||||||
|
function sparkleTail(engine: AudioEngine, out: GainNode, at: number): void {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return
|
||||||
|
const notes = [24, 19, 26, 31]
|
||||||
|
const root = 392
|
||||||
|
notes.forEach((semi, i) => {
|
||||||
|
const bt = at + i * 0.045
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
osc.frequency.value = root * Math.pow(2, semi / 12)
|
||||||
|
const g = ctx.createGain()
|
||||||
|
perc(g.gain, bt, 0.14, 0.003, 0.004, 0.09)
|
||||||
|
osc.connect(g).connect(out)
|
||||||
|
osc.start(bt)
|
||||||
|
osc.stop(bt + 0.12)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Optional ambient — a very quiet lo-fi conveyor hum / room tone. Not started
|
||||||
|
// automatically (the wire protocol carries no machine-proximity signal, so
|
||||||
|
// there is nothing to gate it on); the demo exposes it behind a toggle, and
|
||||||
|
// integration can start/stop it if a proximity cue is ever added.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export interface Ambient {
|
||||||
|
stop(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAmbientHum(engine: AudioEngine): Ambient | null {
|
||||||
|
const ctx = engine.ctx
|
||||||
|
if (!ctx) return null
|
||||||
|
const t = engine.time
|
||||||
|
const out = ctx.createGain()
|
||||||
|
out.gain.setValueAtTime(0.0001, t)
|
||||||
|
out.gain.exponentialRampToValueAtTime(0.05, t + 0.6) // barely-there
|
||||||
|
// Route straight to destination through the engine's own master would be
|
||||||
|
// ideal, but master is private; a local gain at -26 dB-ish is safe & quiet.
|
||||||
|
const post = ctx.createGain()
|
||||||
|
post.gain.value = dbToGainLocal(-12)
|
||||||
|
out.connect(post).connect(ctx.destination)
|
||||||
|
|
||||||
|
// Low drone (two detuned saws) + filtered noise = conveyor rumble.
|
||||||
|
const oscs: OscillatorNode[] = []
|
||||||
|
for (const f of [55, 55.4]) {
|
||||||
|
const o = ctx.createOscillator()
|
||||||
|
o.type = 'sawtooth'
|
||||||
|
o.frequency.value = f
|
||||||
|
const lp = ctx.createBiquadFilter()
|
||||||
|
lp.type = 'lowpass'
|
||||||
|
lp.frequency.value = 220
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.gain.value = 0.5
|
||||||
|
o.connect(lp).connect(g).connect(out)
|
||||||
|
o.start(t)
|
||||||
|
oscs.push(o)
|
||||||
|
}
|
||||||
|
const noise = engine.noiseSource()
|
||||||
|
if (noise) {
|
||||||
|
const bp = ctx.createBiquadFilter()
|
||||||
|
bp.type = 'bandpass'
|
||||||
|
bp.frequency.value = 180
|
||||||
|
bp.Q.value = 0.7
|
||||||
|
const g = ctx.createGain()
|
||||||
|
g.gain.value = 0.25
|
||||||
|
noise.connect(bp).connect(g).connect(out)
|
||||||
|
noise.start(t)
|
||||||
|
oscs.push(noise as unknown as OscillatorNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop(): void {
|
||||||
|
const now = ctx.currentTime
|
||||||
|
out.gain.cancelScheduledValues(now)
|
||||||
|
out.gain.setValueAtTime(out.gain.value, now)
|
||||||
|
out.gain.exponentialRampToValueAtTime(0.0001, now + 0.3)
|
||||||
|
for (const o of oscs) {
|
||||||
|
try {
|
||||||
|
o.stop(now + 0.35)
|
||||||
|
} catch {
|
||||||
|
/* already stopped */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbToGainLocal = (db: number): number => Math.pow(10, db / 20)
|
||||||
155
src/demo/lane-f.ts
Normal file
155
src/demo/lane-f.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Lane F demo — audio soundboard.
|
||||||
|
*
|
||||||
|
* Verifies every synthesized one-shot in isolation. Buttons emit the SAME frozen
|
||||||
|
* wire events the game emits, through the real `installAudio()` wiring (so the
|
||||||
|
* event → sound mapping and the mute button are exercised too, not just the raw
|
||||||
|
* synth functions). An impact slider drives the squish scaling, a colour picker
|
||||||
|
* drives the per-colour splat pitch, and a "new best" toggle drives the fanfare
|
||||||
|
* flourish.
|
||||||
|
*
|
||||||
|
* Autoplay: nothing makes sound until the first click (which unlocks the
|
||||||
|
* AudioContext) — matching the browser policy. There is no title screen here, so
|
||||||
|
* the first-gesture fallback in installAudio does the unlocking.
|
||||||
|
*
|
||||||
|
* Buttons / keys (also handy for integration):
|
||||||
|
* squish landing → blob:landed {impact} key 1
|
||||||
|
* boing jump → blob:jumped key 2
|
||||||
|
* splat (colour) → paint:splatted {blob} key 3
|
||||||
|
* sparkle cleanse → paint:request-cleanse key 4
|
||||||
|
* clank + windup → machine:signal key 5
|
||||||
|
* race start tick → race:started key 6
|
||||||
|
* fanfare → race:finished key 7
|
||||||
|
* SPLAT STORM (10) → 10× paint:splatted key 8 (voice-cap stress test)
|
||||||
|
* ambient hum → toggle conveyor room tone key 9
|
||||||
|
*/
|
||||||
|
import type { PaintColor, World } from '../contracts'
|
||||||
|
import { PALETTE } from '../contracts'
|
||||||
|
import { installAudio } from '../audio/install'
|
||||||
|
import { startAmbientHum, type Ambient } from '../audio/sounds'
|
||||||
|
|
||||||
|
// --- minimal event bus (same shape as world.ts's, so installAudio is happy) ---
|
||||||
|
type Handler = (p: unknown) => void
|
||||||
|
function makeBus(): World['events'] {
|
||||||
|
const map = new Map<string, Set<Handler>>()
|
||||||
|
return {
|
||||||
|
on(name: string, fn: Handler) {
|
||||||
|
if (!map.has(name)) map.set(name, new Set())
|
||||||
|
map.get(name)!.add(fn)
|
||||||
|
return () => map.get(name)!.delete(fn)
|
||||||
|
},
|
||||||
|
emit(name: string, payload?: unknown) {
|
||||||
|
map.get(name)?.forEach((fn) => fn(payload))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = makeBus()
|
||||||
|
// installAudio only ever touches world.events — a partial cast is safe here.
|
||||||
|
const { engine } = installAudio({ events } as unknown as World)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- unlock badge
|
||||||
|
const unlockBadge = document.getElementById('unlock')!
|
||||||
|
const refreshUnlock = (): void => {
|
||||||
|
if (engine.ready) {
|
||||||
|
unlockBadge.textContent = '🔓 AudioContext live — hit the pads.'
|
||||||
|
unlockBadge.classList.add('on')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addEventListener('pointerdown', () => setTimeout(refreshUnlock, 0), { once: false })
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ controls UI
|
||||||
|
const colors = Object.keys(PALETTE) as PaintColor[]
|
||||||
|
let impact = 6
|
||||||
|
let color: PaintColor = 'red'
|
||||||
|
let newBest = false
|
||||||
|
|
||||||
|
const pads = document.getElementById('pads')!
|
||||||
|
|
||||||
|
interface PadDef {
|
||||||
|
label: string
|
||||||
|
sub: string
|
||||||
|
key: string
|
||||||
|
fire: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let ambient: Ambient | null = null
|
||||||
|
|
||||||
|
const defs: PadDef[] = [
|
||||||
|
{ label: '💥 squish', sub: 'blob:landed', key: '1', fire: () => events.emit('blob:landed', { impact }) },
|
||||||
|
{ label: '🟢 boing', sub: 'blob:jumped', key: '2', fire: () => events.emit('blob:jumped', {}) },
|
||||||
|
{ label: '🎨 splat', sub: 'paint:splatted', key: '3', fire: () => events.emit('paint:splatted', { color, target: 'blob' }) },
|
||||||
|
{ label: '✨ sparkle', sub: 'paint:request-cleanse', key: '4', fire: () => events.emit('paint:request-cleanse', { fraction: 1 }) },
|
||||||
|
{ label: '⚙️ clank', sub: 'machine:signal', key: '5', fire: () => events.emit('machine:signal', { id: 'demo' }) },
|
||||||
|
{ label: '🏁 tick', sub: 'race:started', key: '6', fire: () => events.emit('race:started', {}) },
|
||||||
|
{ label: '🎉 fanfare', sub: 'race:finished', key: '7', fire: () => events.emit('race:finished', newBest ? { time: 9, best: 9 } : { time: 12, best: 9 }) },
|
||||||
|
{
|
||||||
|
label: '🌪 splat storm', sub: '10× in 1s (voice cap)', key: '8',
|
||||||
|
fire: () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const c = colors[i % colors.length]
|
||||||
|
setTimeout(() => events.emit('paint:splatted', { color: c, target: 'blob' }), i * 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '🛗 ambient', sub: 'toggle room tone', key: '9',
|
||||||
|
fire: () => {
|
||||||
|
if (ambient) { ambient.stop(); ambient = null }
|
||||||
|
else ambient = startAmbientHum(engine)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const d of defs) {
|
||||||
|
const b = document.createElement('button')
|
||||||
|
b.className = 'pad'
|
||||||
|
b.innerHTML = `${d.label}<small>${d.sub} · key ${d.key}</small>`
|
||||||
|
b.addEventListener('click', () => { d.fire(); refreshUnlock() })
|
||||||
|
pads.appendChild(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('keydown', (e) => {
|
||||||
|
const d = defs.find((x) => x.key === e.key)
|
||||||
|
if (d) { d.fire(); refreshUnlock() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sliders panel
|
||||||
|
const panel = document.createElement('div')
|
||||||
|
panel.className = 'ctl'
|
||||||
|
panel.innerHTML = `
|
||||||
|
<h2>parameters</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label for="impact">impact (squish)</label>
|
||||||
|
<input type="range" id="impact" min="0.8" max="18" step="0.1" value="${impact}">
|
||||||
|
<span class="val" id="impactVal">${impact.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="color">splat colour</label>
|
||||||
|
<select id="color">${colors.map((c) => `<option value="${c}">${c}</option>`).join('')}</select>
|
||||||
|
<span class="val" id="colorSwatch">■</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="best">fanfare = new best</label>
|
||||||
|
<input type="checkbox" id="best">
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.getElementById('app')!.appendChild(panel)
|
||||||
|
|
||||||
|
const impactEl = document.getElementById('impact') as HTMLInputElement
|
||||||
|
const impactVal = document.getElementById('impactVal')!
|
||||||
|
impactEl.addEventListener('input', () => {
|
||||||
|
impact = Number(impactEl.value)
|
||||||
|
impactVal.textContent = impact.toFixed(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorEl = document.getElementById('color') as HTMLSelectElement
|
||||||
|
const swatch = document.getElementById('colorSwatch') as HTMLElement
|
||||||
|
const paintSwatch = (): void => { swatch.style.color = PALETTE[color] }
|
||||||
|
colorEl.addEventListener('change', () => { color = colorEl.value as PaintColor; paintSwatch() })
|
||||||
|
paintSwatch()
|
||||||
|
|
||||||
|
const bestEl = document.getElementById('best') as HTMLInputElement
|
||||||
|
bestEl.addEventListener('change', () => { newBest = bestEl.checked })
|
||||||
|
|
||||||
|
console.log('[lane-f] soundboard ready — click a pad (or keys 1-9) to fire wire events.')
|
||||||
Loading…
Reference in New Issue
Block a user