Remaster: cel-shaded skin, a crowd that tracks trade, and a funk band

Two skins over one game. 1979 is the machine as it was; the remaster is
the same rules in cel-shaded vector art with a modern palette. The
simulation is untouched - only the paint differs - and the toggle sits
under the set.

The street is now driven by the game. Before prices are in, the number
of people walking on comes from the forecast; afterwards it comes from
the glasses that actually sold, so the report shows the crowd you
earned. A heat wave has them fanning themselves; a downpour leaves one
figure hurrying past under an umbrella.

Selling no longer snaps straight to the books. The stand trades for a
few seconds first, tally climbing and till filling, which is where that
crowd animation pays off. Skippable.

The synth grew a kit (pitched-sine kick, snare with body, hats), a
sweeping resonant lowpass for the bass, chords and a shuffle - enough
for a funk band. The chiptunes stay for the 1979 skin.

Boot is now a cassette load, which doubles as the gesture browsers
require before audio will play.

Fixes found on the way: the picture scaled about its centre while flex
auto-margins collapsed to zero on overflow, which pushed the report's
buttons off the bottom; and the validation line moved the button when
it appeared, so it is now reserved and faded. Sibling tabs in one
browser also keep their score tables in step.
This commit is contained in:
2026-09-08 15:59:52 -07:00
parent 175589223a
commit 7fcb863771
23 changed files with 1909 additions and 370 deletions
+176
View File
@@ -0,0 +1,176 @@
import type { Track, Tune } from './synth'
/**
* The remaster's band. D dorian, light shuffle, everything syncopated:
* a slap-ish filtered bass, clavinet stabs off the beat, and a kit that
* leaves the downbeat alone more often than it hits it.
*
* Patterns are written in sixteenths, sixteen to a bar.
*/
const bar = (...steps: string[]) => steps
const KIT_GAIN = { kick: 0.5, snare: 0.32, hat: 0.12 }
const kick = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.kick, notes })
const snare = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.snare, notes })
const hats = (notes: string[]): Track => ({ wave: 'noise', gain: KIT_GAIN.hat, notes })
/** Sixteenth hats with the off-beats ducked, so the groove breathes. */
const HAT_BAR = bar('H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'H', 'h', 'O', 'h')
const hatPattern = HAT_BAR.map((n) => (n === 'h' ? 'H' : n))
export const FUNK_DAY: Tune = {
bpm: 102,
stepsPerBeat: 4,
swing: 0.16,
tracks: [
// Bass: the engine. Short, filtered, and never on every beat.
{
wave: 'saw',
gain: 0.22,
gate: 0.55,
filter: { from: 1600, to: 260, q: 7 },
notes: [
...bar('D2', '.', '.', 'D2', '.', '.', 'D3', '.', 'A2', '.', '.', 'C3', '.', 'D2', '.', '.'),
...bar('D2', '.', '.', 'D2', '.', 'F2', '.', '.', 'G2', '.', 'A2', '.', '.', 'C3', '.', '.'),
...bar('G2', '.', '.', 'G2', '.', '.', 'G3', '.', 'D3', '.', '.', 'F3', '.', 'G2', '.', '.'),
...bar('A2', '.', '.', 'A2', '.', 'C3', '.', '.', 'E3', '.', 'G3', '.', 'A3', '.', 'G3', '.'),
],
},
// Clavinet: stabs in the gaps the bass leaves.
{
wave: 'pulse25',
gain: 0.09,
gate: 0.3,
filter: { from: 3200, to: 900, q: 9 },
notes: [
...bar('.', '.', 'F4/A4/C5', '.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'E4/A4/C5', '.', '.', '.', '.', '.'),
...bar('.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'F4/A4/D5', '.', '.', '.', '.', 'E4/G4/C5', '.', '.', '.'),
...bar('.', '.', 'G4/B4/D5', '.', '.', 'G4/B4/D5', '.', '.', '.', '.', 'F4/B4/D5', '.', '.', '.', '.', '.'),
...bar('.', '.', 'E4/A4/C5', '.', '.', '.', 'E4/G4/C5', '.', '.', 'E4/A4/C5', '.', '.', '.', '.', '.', '.'),
],
},
// A thin lead line that comes and goes.
{
wave: 'pulse12',
gain: 0.07,
gate: 0.7,
notes: [
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'D5', '.', 'F5', '.', 'E5', '=', '.', '.'),
...bar('.', '.', '.', '.', 'C5', '.', 'D5', '=', '=', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'G5', '.', 'F5', '.', 'D5', '=', '.', '.'),
...bar('C5', '.', 'D5', '.', 'E5', '=', '=', '.', '.', '.', '.', '.', 'A4', '.', 'C5', '.'),
],
},
kick([
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
...bar('K', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', 'K', '.'),
]),
snare([
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', 'C', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', 'C', '.', '.', '.', '.', 'S', '.', 'S', 'S'),
]),
hats([...hatPattern, ...hatPattern, ...hatPattern, ...hatPattern]),
],
}
/** Title theme: the same band, louder and showing off. */
export const FUNK_TITLE: Tune = {
bpm: 108,
stepsPerBeat: 4,
swing: 0.15,
tracks: [
{
wave: 'saw',
gain: 0.24,
gate: 0.5,
filter: { from: 2200, to: 300, q: 8 },
notes: [
...bar('D2', '.', 'D3', '.', '.', 'D2', '.', '.', 'A2', '.', 'C3', '.', 'D3', '.', 'A2', '.'),
...bar('D2', '.', 'D3', '.', '.', 'D2', '.', 'F2', '.', 'G2', '.', 'A2', '.', 'C3', '.', '.'),
...bar('B2', '.', 'B3', '.', '.', 'B2', '.', '.', 'F3', '.', 'A3', '.', 'B3', '.', 'F3', '.'),
...bar('G2', '.', 'G3', '.', '.', 'G2', '.', 'A2', '.', 'B2', '.', 'C3', '.', 'C3', 'C3', '.'),
],
},
{
wave: 'pulse25',
gain: 0.11,
gate: 0.26,
filter: { from: 4200, to: 1100, q: 10 },
notes: [
...bar('.', 'F4/A4/C5', '.', '.', 'F4/A4/C5', '.', '.', '.', '.', 'E4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.'),
...bar('.', 'F4/A4/C5', '.', '.', '.', '.', 'F4/A4/D5', '.', '.', '.', 'E4/G4/C5', '.', '.', '.', '.', '.'),
...bar('.', 'D4/F4/B4', '.', '.', 'D4/F4/B4', '.', '.', '.', '.', 'D4/F4/A4', '.', '.', '.', 'D4/F4/B4', '.', '.'),
...bar('.', 'D4/G4/B4', '.', '.', '.', 'D4/G4/B4', '.', '.', '.', 'E4/G4/C5', '.', '.', '.', '.', '.', '.'),
],
},
// Horn-ish hook.
{
wave: 'pulse50',
gain: 0.1,
gate: 0.85,
filter: { from: 2600, to: 1400, q: 3 },
notes: [
...bar('D5', '.', 'F5', '.', 'G5', '=', '=', '.', 'F5', '.', 'D5', '.', 'C5', '=', '.', '.'),
...bar('.', '.', 'A4', '.', 'C5', '.', 'D5', '=', '=', '=', '.', '.', '.', '.', '.', '.'),
...bar('B4', '.', 'D5', '.', 'F5', '=', '=', '.', 'A5', '.', 'F5', '.', 'D5', '=', '.', '.'),
...bar('G5', '.', 'F5', '.', 'D5', '.', 'C5', '.', 'D5', '=', '=', '=', '=', '.', '.', '.'),
],
},
kick([
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
...bar('K', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', '.', '.', 'K', '.', 'K', '.'),
]),
snare([
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', 'C', '.', '.', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', 'C', 'S', '.', '.', 'S'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', 'C', '.', '.', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', 'S', '.', '.', '.', 'S', '.', 'S', 'S', '.', 'S'),
]),
hats([...hatPattern, ...hatPattern, ...hatPattern, ...hatPattern]),
],
}
/** Closing theme: slower, fatter, a little smug. */
export const FUNK_END: Tune = {
bpm: 92,
stepsPerBeat: 4,
swing: 0.18,
tracks: [
{
wave: 'saw',
gain: 0.24,
gate: 0.6,
filter: { from: 1800, to: 240, q: 7 },
notes: [
...bar('D2', '.', '.', 'D2', '.', '.', 'A2', '.', 'D3', '.', '.', 'C3', '.', 'A2', '.', '.'),
...bar('G2', '.', '.', 'G2', '.', '.', 'D3', '.', 'G3', '.', '.', 'F3', '.', 'D3', '.', '.'),
],
},
{
wave: 'pulse25',
gain: 0.12,
gate: 0.9,
filter: { from: 3000, to: 1200, q: 5 },
notes: [
...bar('D5/F5/A5', '=', '=', '=', '.', '.', 'C5/E5/A5', '=', '=', '.', '.', '.', 'A4/D5/F5', '=', '=', '.'),
...bar('D5/G5/B5', '=', '=', '=', '.', '.', 'D5/F5/A5', '=', '=', '.', '.', '.', 'C5/E5/G5', '=', '=', '.'),
],
},
kick([
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', 'K', 'K', '.'),
]),
snare([
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', 'S', 'S'),
]),
hats([...hatPattern, ...hatPattern]),
],
}
+142 -23
View File
@@ -22,14 +22,26 @@ export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noi
export interface Track {
wave: Wave
gain: number
/** One entry per step. '.' rest, '=' sustain previous, otherwise a note. */
/**
* One entry per step. '.' rest, '=' sustain previous, otherwise a note.
* Slashes stack notes into a chord: "F4/A4/C5". On a noise track the
* letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat.
*/
notes: string[]
/** Sweeping lowpass, the whole point of a funk bass. */
filter?: { from: number; to: number; q?: number }
/** Fraction of the note's length actually sounded; low values are stabs. */
gate?: number
/** Cents, for a fatter unison. */
detune?: number
}
export interface Tune {
bpm: number
stepsPerBeat: number
tracks: Track[]
/** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */
swing?: number
}
/** Fourier series for a pulse wave of the given duty cycle. */
@@ -112,6 +124,7 @@ export class Synth {
at: number,
dur: number,
gain: number,
opts: { filter?: Track['filter']; detune?: number } = {},
) {
const ctx = this.ensure()
const osc = ctx.createOscillator()
@@ -119,6 +132,7 @@ export class Synth {
else if (wave === 'saw') osc.type = 'sawtooth'
else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!)
osc.frequency.setValueAtTime(freq, at)
if (opts.detune) osc.detune.setValueAtTime(opts.detune, at)
const env = ctx.createGain()
const peak = Math.max(0.0001, gain)
@@ -127,38 +141,78 @@ export class Synth {
env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6))
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
osc.connect(env).connect(dest)
let node: AudioNode = osc
if (opts.filter) {
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.Q.value = opts.filter.q ?? 6
lp.frequency.setValueAtTime(opts.filter.from, at)
lp.frequency.exponentialRampToValueAtTime(
Math.max(60, opts.filter.to),
at + Math.max(0.05, dur),
)
osc.connect(lp)
node = lp
}
node.connect(env).connect(dest)
osc.start(at)
osc.stop(at + dur + 0.02)
}
private percussion(dest: AudioNode, kind: string, at: number, gain: number) {
/** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */
private drum(dest: AudioNode, kind: string, at: number, gain: number) {
const ctx = this.ensure()
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
if (kind === 'K') {
const osc = ctx.createOscillator()
osc.type = 'sine'
osc.frequency.setValueAtTime(130, at)
osc.frequency.exponentialRampToValueAtTime(42, at + 0.11)
const env = ctx.createGain()
env.gain.setValueAtTime(gain * 1.5, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
osc.connect(env).connect(dest)
osc.start(at)
osc.stop(at + 0.26)
return
}
const noise = ctx.createBufferSource()
noise.buffer = this.noiseBuffer
const filter = ctx.createBiquadFilter()
const env = ctx.createGain()
let dur = 0.05
let dur = 0.06
if (kind === 'K') {
filter.type = 'lowpass'
filter.frequency.value = 220
dur = 0.12
} else if (kind === 'S') {
if (kind === 'S' || kind === 'C') {
filter.type = 'bandpass'
filter.frequency.value = 1800
dur = 0.12
filter.frequency.value = kind === 'S' ? 1900 : 1300
filter.Q.value = kind === 'S' ? 0.9 : 2.4
dur = kind === 'S' ? 0.16 : 0.1
if (kind === 'S') {
// A little body under the crack.
const tone = ctx.createOscillator()
tone.type = 'triangle'
tone.frequency.setValueAtTime(210, at)
tone.frequency.exponentialRampToValueAtTime(150, at + 0.09)
const tenv = ctx.createGain()
tenv.gain.setValueAtTime(gain * 0.6, at)
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1)
tone.connect(tenv).connect(dest)
tone.start(at)
tone.stop(at + 0.12)
}
} else {
filter.type = 'highpass'
filter.frequency.value = 6000
dur = 0.04
filter.frequency.value = 7200
dur = kind === 'O' ? 0.22 : 0.035
}
env.gain.setValueAtTime(gain, at)
env.gain.exponentialRampToValueAtTime(0.0001, at + dur)
src.connect(filter).connect(env).connect(dest)
src.start(at)
src.stop(at + dur + 0.02)
noise.connect(filter).connect(env).connect(dest)
noise.start(at)
noise.stop(at + dur + 0.02)
}
// ------------------------------------------------------------- sequencer
@@ -189,8 +243,12 @@ export class Synth {
if (!ctx || !tune) return
const stepDur = 60 / tune.bpm / tune.stepsPerBeat
const length = Math.max(...tune.tracks.map((t) => t.notes.length))
const swing = tune.swing ?? 0
while (this.nextStepTime < ctx.currentTime + 0.2) {
// A shuffle pushes every other step late without moving the downbeats.
const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0)
for (const track of tune.tracks) {
const note = track.notes[this.step % track.notes.length]
if (!note || note === '.' || note === '=') continue
@@ -201,13 +259,20 @@ export class Synth {
if (track.notes[(this.step + i) % track.notes.length] === '=') held++
else break
}
const dur = held * stepDur * 0.95
const dur = held * stepDur * (track.gate ?? 0.95)
if (track.wave === 'noise') {
this.percussion(this.musicBus, note, this.nextStepTime, track.gain)
} else {
const f = noteToFreq(note)
if (f) this.voice(this.musicBus, track.wave, f, this.nextStepTime, dur, track.gain)
this.drum(this.musicBus, note, at, track.gain)
continue
}
for (const part of note.split('/')) {
const f = noteToFreq(part)
if (!f) continue
this.voice(this.musicBus, track.wave, f, at, dur, track.gain, {
filter: track.filter,
detune: track.detune,
})
}
}
this.nextStepTime += stepDur
@@ -230,6 +295,11 @@ export class Synth {
this.seq([['E5', 0.05]], 'pulse12', 0.15)
}
/** A quiet register blip, played once per few glasses as the day runs. */
tick() {
this.seq([['B5', 0.035]], 'pulse12', 0.09)
}
select() {
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
}
@@ -285,6 +355,55 @@ export class Synth {
osc.stop(at + 0.7)
}
/** The Atari's key-click beep. */
beep() {
this.seq([['A5', 0.06]], 'pulse50', 0.14)
}
/**
* Cassette load: bandpassed hiss under a square wave flipping between two
* tones, which is roughly what audio FSK sounded like coming off tape.
*/
tape(seconds: number): () => void {
const ctx = this.ensure()
const at = ctx.currentTime + 0.02
const until = at + seconds
const osc = ctx.createOscillator()
osc.type = 'square'
for (let t = at, flip = 0; t < until; t += 0.018, flip++) {
osc.frequency.setValueAtTime(flip % 2 === 0 ? 1300 : 2400, t)
}
const oscGain = ctx.createGain()
oscGain.gain.value = 0.045
const hiss = ctx.createBufferSource()
hiss.buffer = this.noiseBuffer
hiss.loop = true
const band = ctx.createBiquadFilter()
band.type = 'bandpass'
band.frequency.value = 1800
band.Q.value = 0.7
const hissGain = ctx.createGain()
hissGain.gain.value = 0.05
osc.connect(oscGain).connect(this.sfxBus)
hiss.connect(band).connect(hissGain).connect(this.sfxBus)
osc.start(at)
hiss.start(at)
osc.stop(until)
hiss.stop(until)
return () => {
try {
osc.stop()
hiss.stop()
} catch {
// Already stopped; nothing to do.
}
}
}
fanfare() {
this.seq(
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],