The card and chip sounds never convinced as oscillators, so they're now CC0 recordings (Kenney's casino pack) under /static/audio/casino, embedded in the binary. Six names (card, deal, flip, shuffle, chip, sweep) play several rotating takes with a little pitch jitter; everything melodic stays synthesised. PeteSFX's API is unchanged and synthesis is the fallback while an ogg is still decoding, so no caller changed and the table is never silent.
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
// 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.
|
|
card: { files: ["cardPlace1", "cardPlace2", "cardPlace3", "cardPlace4"], gain: 0.6, vary: 0.04 },
|
|
// A card slid into place — softer, longer than a throw.
|
|
deal: { files: ["cardSlide1", "cardSlide2", "cardSlide3", "cardSlide4", "cardSlide5", "cardSlide6"], gain: 0.5, vary: 0.05 },
|
|
// A card flicked over.
|
|
flip: { files: ["cardFan1", "cardFan2"], gain: 0.5, vary: 0.04 },
|
|
// The riffle.
|
|
shuffle: { files: ["cardShuffle"], gain: 0.6 },
|
|
// A clay chip set down on a stack.
|
|
chip: { files: ["chipLay1", "chipLay2", "chipLay3"], gain: 0.6, vary: 0.05 },
|
|
// Chips gathered and slid away.
|
|
sweep: { files: ["chipsHandle2", "chipsHandle4", "chipsHandle6"], gain: 0.5, vary: 0.03 },
|
|
};
|
|
|
|
// 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;
|
|
|
|
// 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;
|
|
var i = Math.abs(Math.round(v));
|
|
var buf = buffers[cfg.files[i % cfg.files.length]];
|
|
if (!buf) return false; // not decoded yet, or failed: let synth cover it
|
|
var src = ctx.createBufferSource();
|
|
src.buffer = buf;
|
|
// A few percent off pitch, spread across a small range keyed to v, so a run of
|
|
// the same sound never reads as a loop.
|
|
if (cfg.vary) src.playbackRate.value = 1 + (((i % 5) - 2) * 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); },
|
|
};
|
|
})();
|