// The noise the room makes. // // The room speaks in two voices. The cards and chips are *recorded* — real clay // and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg // files under /static/audio/casino. Synthesis never quite caught the grain of a // chip landing on a chip, so for those we stopped trying. Everything melodic — // the wins, the losses, the little ticks — is still *made*: an oscillator, a // burst of filtered noise, an envelope, the same bargain the weather engine takes // with its clouds. A win is four notes going up; a chip is a recording of a chip. // // A name is a recording if it appears in SAMPLES and synthesised if it appears in // SOUNDS. The recordings guard against the one weakness of a sample — the same wav // 300 times is a machine gun — by keeping several takes per sound and rotating // through them, and by nudging every play a few percent off pitch. The synthesised // half was always immune to that; it varies itself. // // Two rules hold the whole file up. // // **Nothing is built until it's asked for.** A browser will not let a page make a // noise before the user has touched it, and a page that builds an AudioContext on // load gets one in the "suspended" state and a warning in the console for its // trouble. So the context is made on the first sound anybody actually asks for — // which, in a casino, is a click on a chip. // // **Muted means silent, not quiet.** When the room is muted nothing is // constructed, nothing is scheduled and no context is opened. Mute is the // default-off switch for the entire file, checked before anything else happens. // // Exposed as window.PeteSFX. Nothing in here knows what blackjack is. // // If a recording hasn't finished decoding on the frame it's first needed, that one // call falls through to the synthesised version — so the table is never silent // while the ogg loads, and by the second card everything is real. (function () { "use strict"; var KEY = "pete.sfx.off"; var ctx = null; var master = null; var noiseBuf = null; var muted = false; var listeners = []; try { muted = localStorage.getItem(KEY) === "1"; } catch (e) {} // ---- the instrument -------------------------------------------------------- function boot() { if (ctx) return ctx; var AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null; try { ctx = new AC(); } catch (e) { return null; } master = ctx.createGain(); master.gain.value = 0.45; // the room is furniture, not a nightclub master.connect(ctx.destination); return ctx; } // A second of white noise, made once and played at different speeds and through // different filters for the rest of the session. Card flicks, riffles, the // transient on a chip: all of them are this buffer wearing a different coat. function noise() { if (noiseBuf) return noiseBuf; var n = ctx.sampleRate; noiseBuf = ctx.createBuffer(1, n, n); var d = noiseBuf.getChannelData(0); for (var i = 0; i < n; i++) d[i] = Math.random() * 2 - 1; return noiseBuf; } // env is the shape of every sound in here: up fast, down slow, and never to // zero — an exponential ramp to actual zero is undefined, and a browser that // hits one either throws or clicks. function env(g, t0, attack, decay, peak) { g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak), t0 + attack); g.gain.exponentialRampToValueAtTime(0.0001, t0 + attack + decay); } // tone is one note. function tone(t0, freq, o) { o = o || {}; var osc = ctx.createOscillator(); var g = ctx.createGain(); osc.type = o.type || "sine"; osc.frequency.setValueAtTime(freq, t0); if (o.to) osc.frequency.exponentialRampToValueAtTime(o.to, t0 + (o.glide || o.decay || 0.1)); env(g, t0, o.attack || 0.004, o.decay || 0.12, o.gain == null ? 0.3 : o.gain); osc.connect(g).connect(master); osc.start(t0); osc.stop(t0 + (o.attack || 0.004) + (o.decay || 0.12) + 0.02); } // hiss is a burst of the noise buffer through a filter, which is what a card, // a riffle and the knock on a chip all are. function hiss(t0, o) { o = o || {}; var src = ctx.createBufferSource(); src.buffer = noise(); src.playbackRate.value = o.rate || 1; var f = ctx.createBiquadFilter(); f.type = o.filter || "bandpass"; f.frequency.setValueAtTime(o.freq || 2000, t0); if (o.sweepTo) f.frequency.exponentialRampToValueAtTime(o.sweepTo, t0 + (o.decay || 0.1)); f.Q.value = o.q == null ? 1.1 : o.q; var g = ctx.createGain(); env(g, t0, o.attack || 0.003, o.decay || 0.09, o.gain == null ? 0.3 : o.gain); src.connect(f).connect(g).connect(master); src.start(t0); src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02); } // ---- the recordings -------------------------------------------------------- // // The foley. Each name maps to a handful of takes; `v` (the index of the card or // chip in a run) picks which one and how far off pitch it lands, so a dealt hand // is four different cards rather than one card four times. `gain` is per-sound // and multiplies the master, exactly like the synthesised sounds' peak does. var SAMPLE_BASE = "/static/audio/casino/"; var SAMPLES = { // A card thrown down onto the table. `vary` is the largest fraction a take is // ever pitched by, up or down — 0.20 means a play can land anywhere from a fifth // slower (deeper) to a fifth faster (brighter). card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.20 }, // A card slid into place — softer, longer than a throw. deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.20 }, // A card flicked over. flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.18 }, // The riffle — one take, so the pitch wobble is all that keeps two shuffles apart. shuffle: { files: ["cardShuffle"], gain: 0.6, vary: 0.10 }, // A clay chip set down on a stack. chip: { files: ["chipLay1", "chipLay2"], gain: 0.6, vary: 0.22 }, // Chips gathered and slid away. sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.14 }, }; // Decoded buffers by filename. A value of null means "claimed, in flight or // decoding"; false means "we tried and it failed, never bother again"; an // AudioBuffer means ready. var buffers = {}; var warmed = false; // A rotating cursor per sound, so that even a run of identical calls walks // through the takes rather than replaying the first one. Callers pass `v` to // separate simultaneous sounds (the cards of one deal), but many pass the same // constant every time — the cursor is what keeps a stack of chips from being the // same click over and over. var turn = {}; // decode fetches a file and turns it into a buffer, exactly once per filename. // decodeAudioData exists in a modern promise flavour and an old callback one; // this handles both so the foley works on older Safari too. function decode(file) { if (file in buffers) return; // ready, in flight, or known-bad buffers[file] = null; fetch(SAMPLE_BASE + file + ".ogg") .then(function (r) { return r.ok ? r.arrayBuffer() : Promise.reject(); }) .then(function (ab) { return new Promise(function (res, rej) { var p = ctx.decodeAudioData(ab, res, rej); if (p && p.then) p.then(res, rej); }); }) .then(function (buf) { buffers[file] = buf; }) .catch(function () { buffers[file] = false; }); } // warm pulls every take down once the context is awake, so that after the first // click the whole set is decoded and ready and nothing has to fall back. function warm() { if (warmed || !ctx) return; warmed = true; for (var name in SAMPLES) SAMPLES[name].files.forEach(decode); } // sample plays one recorded take. It returns false — rather than making a noise — // when the name isn't a recording or its buffer isn't decoded yet, which is the // caller's signal to reach for the synthesised version instead. function sample(name, t0, v) { var cfg = SAMPLES[name]; if (!cfg) return false; // Advance the cursor, then offset it by v. The cursor guarantees consecutive // calls move to the next take; v spreads apart sounds fired together (one // deal's cards) so they don't all land on the same take at once. var k = (turn[name] = (turn[name] || 0) + 1) + Math.abs(Math.round(v)); var buf = buffers[cfg.files[k % cfg.files.length]]; if (!buf) return false; // not decoded yet, or failed: let synth cover it var src = ctx.createBufferSource(); src.buffer = buf; // Pitch, walking with the same cursor across seven steps between -vary and // +vary, so even a single-take sound like the shuffle isn't identical twice in // a row. Seven steps rather than a couple means the runs don't read as a loop. if (cfg.vary) src.playbackRate.value = 1 + (((k % 7) - 3) / 3) * cfg.vary; var g = ctx.createGain(); g.gain.value = cfg.gain == null ? 0.6 : cfg.gain; src.connect(g).connect(master); src.start(t0); return true; } // ---- the sounds ------------------------------------------------------------ // // Each one takes the time it starts at, and a `v` — a small per-call variation, // usually the index of the card or chip in a run. Four cards dealt with the // identical sample four times is a machine gun; four cards each a semitone off // is a hand dealing. var SOUNDS = { // A card landing: mostly air, with a slap on the front of it. card: function (t, v) { hiss(t, { freq: 1800 + v * 140, q: 0.9, decay: 0.075, rate: 1 + v * 0.05, gain: 0.3 }); hiss(t, { filter: "highpass", freq: 4200, decay: 0.02, gain: 0.16 }); }, // Dealing is the same card, lower and softer: it's being placed, not thrown. deal: function (t, v) { hiss(t, { freq: 1250 + v * 110, q: 1.1, decay: 0.085, rate: 0.9, gain: 0.24 }); }, // A card turning over — shorter, brighter, and it comes in two halves, which // is what a flip actually sounds like. flip: function (t) { hiss(t, { freq: 2600, q: 1.4, decay: 0.035, gain: 0.22 }); hiss(t + 0.045, { freq: 1500, q: 1.0, decay: 0.06, gain: 0.24 }); }, // The riffle. One long sweep of noise with the filter climbing through it, // and a rattle laid over the top so it isn't just a shhh. shuffle: function (t) { hiss(t, { freq: 700, sweepTo: 3400, q: 0.7, attack: 0.02, decay: 0.34, gain: 0.2 }); for (var i = 0; i < 9; i++) { hiss(t + 0.03 + i * 0.032, { freq: 2400 + i * 130, q: 2.2, decay: 0.02, gain: 0.09 }); } }, // A chip landing on a chip. Two detuned partials well above the staff, with a // knock underneath — the knock is most of what makes it read as *clay* rather // than as a bell. chip: function (t, v) { var f = 1750 + v * 55; tone(t, f, { type: "sine", decay: 0.07, gain: 0.16 }); tone(t, f * 1.34, { type: "sine", decay: 0.05, gain: 0.1 }); hiss(t, { filter: "lowpass", freq: 900, decay: 0.03, gain: 0.3, q: 0.5 }); }, // Chips sliding away across felt. sweep: function (t) { hiss(t, { freq: 1600, sweepTo: 500, q: 0.6, attack: 0.015, decay: 0.26, gain: 0.16 }); }, // Four notes up. This is the only sound in the room allowed to be pleased. win: function (t) { [523.25, 659.25, 783.99, 1046.5].forEach(function (f, i) { tone(t + i * 0.085, f, { type: "triangle", decay: 0.26, gain: 0.24 }); }); }, // Two notes down, and the second one is flat. Nobody needs telling twice. lose: function (t) { tone(t, 311.13, { type: "triangle", decay: 0.24, gain: 0.22 }); tone(t + 0.15, 233.08, { type: "triangle", decay: 0.42, gain: 0.22 }); }, // Nothing happened and nothing was lost. push: function (t) { tone(t, 392, { type: "sine", decay: 0.2, gain: 0.16 }); }, // A button. Short enough that you feel it rather than hear it. blip: function (t) { tone(t, 880, { type: "sine", decay: 0.055, gain: 0.12 }); }, // UNO, called. A bright stab plus a zip upwards: it is a shout, and it is the // one thing on that table you want to feel good about pressing. uno: function (t) { [659.25, 830.61, 987.77].forEach(function (f) { tone(t, f, { type: "triangle", decay: 0.3, gain: 0.2 }); }); tone(t, 500, { type: "sawtooth", to: 1600, glide: 0.16, decay: 0.18, gain: 0.09 }); }, // Got them. A rising snap with a thump on the end — the sound of a finger // being pointed. catch: function (t) { tone(t, 300, { type: "square", to: 900, glide: 0.11, decay: 0.12, gain: 0.13 }); hiss(t + 0.1, { filter: "lowpass", freq: 1100, decay: 0.1, gain: 0.34, q: 0.7 }); tone(t + 0.1, 174.61, { type: "triangle", decay: 0.22, gain: 0.2 }); }, // Something bad landed on you: a stack, a +4, a catch you walked into. bad: function (t) { tone(t, 155.56, { type: "square", to: 98, glide: 0.18, decay: 0.22, gain: 0.14 }); hiss(t, { filter: "lowpass", freq: 700, decay: 0.14, gain: 0.3, q: 0.6 }); }, // A clock you can hear. Used sparingly, and never in a run of more than a few. tick: function (t) { hiss(t, { freq: 3200, q: 3, decay: 0.02, gain: 0.14 }); }, }; // ---- the door -------------------------------------------------------------- // A browser will not make a noise until the user has touched the page, and a // context built before that arrives suspended. This wakes it on the first real // gesture — after which play() can schedule freely. function wake() { if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {}); warm(); } ["pointerdown", "keydown", "touchstart"].forEach(function (ev) { window.addEventListener(ev, wake, { passive: true }); }); // play makes a sound, or — if the room is muted — makes nothing at all, opens no // context and builds no graph. // // `v` is the variation: pass the index of the card or chip in a run and the // sound moves a little each time, which is the difference between a hand and a // machine. `delay` schedules it ahead in seconds, so a caller that knows a card // lands in 400ms can say so rather than sleeping. function play(name, opts) { if (muted) return; if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice if (!boot()) return; wake(); if (ctx.state !== "running") return; // not yet touched: no sound, and no error var t0 = ctx.currentTime + ((opts && opts.delay) || 0); var v = (opts && opts.v) || 0; try { // A recording if we have one decoded; the synthesised take otherwise — both // for names that are only ever synthesised, and for the first call of a // recorded name while its ogg is still loading. if (sample(name, t0, v)) return; if (SOUNDS[name]) SOUNDS[name](t0, v); } catch (e) { /* a sound is never worth throwing over */ } } function setMuted(on) { muted = !!on; try { if (muted) localStorage.setItem(KEY, "1"); else localStorage.removeItem(KEY); } catch (e) {} if (window.PetePrefs) window.PetePrefs.push(); listeners.forEach(function (fn) { fn(muted); }); if (!muted) play("blip"); // so you know what you just turned back on } window.PeteSFX = { play: play, muted: function () { return muted; }, toggle: function () { setMuted(!muted); return muted; }, set: setMuted, // onChange lets the mute button re-label itself without owning the state. onChange: function (fn) { listeners.push(fn); fn(muted); }, }; })();