games: real recorded foley for cards and chips, synth for the rest

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.
This commit is contained in:
prosolis
2026-07-14 23:28:10 -07:00
parent 4189c03a82
commit 30b0e8debb
21 changed files with 123 additions and 10 deletions

View File

@@ -0,0 +1,21 @@
Casino Audio
by Kenney Vleugels (Kenney.nl)
------------------------------
License (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these assets in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Follow on Twitter for updates:
@KenneyNL

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,12 +1,18 @@
// The noise the room makes. // The noise the room makes.
// //
// There are no audio files here and there is nothing to download. Every sound in // The room speaks in two voices. The cards and chips are *recorded* — real clay
// this casino is *made* — an oscillator, a burst of filtered noise, an envelope — // and real card stock, CC0 foley from Kenney's casino pack, sitting as small ogg
// the same bargain the weather engine takes with its clouds. A card is a short // files under /static/audio/casino. Synthesis never quite caught the grain of a
// slap of noise through a bandpass; a chip is two detuned sines with a click on // chip landing on a chip, so for those we stopped trying. Everything melodic —
// the front; a win is four notes going up. It costs about six kilobytes and no // the wins, the losses, the little ticks — is still *made*: an oscillator, a
// round trips, and it means a sound can be pitched, stretched and detuned per // burst of filtered noise, an envelope, the same bargain the weather engine takes
// call instead of being the same wav 300 times. // 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. // Two rules hold the whole file up.
// //
@@ -21,6 +27,10 @@
// default-off switch for the entire file, checked before anything else happens. // default-off switch for the entire file, checked before anything else happens.
// //
// Exposed as window.PeteSFX. Nothing in here knows what blackjack is. // 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 () { (function () {
"use strict"; "use strict";
@@ -108,6 +118,82 @@
src.stop(t0 + (o.attack || 0.003) + (o.decay || 0.09) + 0.02); 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 ------------------------------------------------------------ // ---- the sounds ------------------------------------------------------------
// //
// Each one takes the time it starts at, and a `v` — a small per-call variation, // Each one takes the time it starts at, and a `v` — a small per-call variation,
@@ -217,6 +303,7 @@
// gesture — after which play() can schedule freely. // gesture — after which play() can schedule freely.
function wake() { function wake() {
if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {}); if (ctx && ctx.state === "suspended") ctx.resume().catch(function () {});
warm();
} }
["pointerdown", "keydown", "touchstart"].forEach(function (ev) { ["pointerdown", "keydown", "touchstart"].forEach(function (ev) {
window.addEventListener(ev, wake, { passive: true }); window.addEventListener(ev, wake, { passive: true });
@@ -231,13 +318,18 @@
// lands in 400ms can say so rather than sleeping. // lands in 400ms can say so rather than sleeping.
function play(name, opts) { function play(name, opts) {
if (muted) return; if (muted) return;
var s = SOUNDS[name]; if (!SOUNDS[name] && !SAMPLES[name]) return; // known to neither voice
if (!s) return;
if (!boot()) return; if (!boot()) return;
wake(); wake();
if (ctx.state !== "running") return; // not yet touched: no sound, and no error 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 { try {
s(ctx.currentTime + ((opts && opts.delay) || 0), ((opts && opts.v) || 0)); // 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) { } catch (e) {
/* a sound is never worth throwing over */ /* a sound is never worth throwing over */
} }