Take-selection was keyed only on the caller's v, but callers pass small constants (chip is always v:0), so only the first take of each sound ever played. Add a per-name cursor that advances every call: consecutive plays now walk the takes, v just offsets simultaneous sounds and drives pitch.
368 lines
15 KiB
JavaScript
368 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;
|
|
|
|
// 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;
|
|
// A few percent off pitch, walking with the same cursor, so even a single-take
|
|
// sound like the shuffle isn't pitch-identical twice in a row.
|
|
if (cfg.vary) src.playbackRate.value = 1 + (((k % 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); },
|
|
};
|
|
})();
|