Skip to content

Commit eb360a6

Browse files
kieranhjclaude
andcommitted
Track running noise mode through pitch envelope on channel 0
The MOS writes the noise control register with the running pitch byte (low 3 bits) on every envelope tick, so PI/PN walk the noise type+rate over time — that's what gives Elite hyperspace its warble. Previously expand() short- circuited stepPitch on noise channel and the audio engine treated noise mode as a single fixed value for the whole note. Now expand() runs pitch envelope on all channels, the audio engine builds a one-shot noise buffer matching the sample stream, and the LFSR is reset to 0x4000 only when the running noise mode actually changes (matching jsbeeb's noisePoked). UI: pitch envelope inputs are no longer greyed on noise channel since they're meaningful there. Visualiser replaces the static noise caption with a coloured strip showing how the mode evolves cs-by-cs (warm = white, cool = periodic; brighter = faster shift rate). Game presets: added Elite explosion / missile / hyperspace and Thrust engine, decoded from the SFX tables in markmoxon/elite-source-code-bbc- micro-cassette and the kieranhj/thrust-disassembly app_init.asm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ab334dd commit eb360a6

5 files changed

Lines changed: 120 additions & 68 deletions

File tree

src/audio.ts

Lines changed: 50 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@ let activeNodes: ActiveNodes | null = null;
1111
let playStart: number | null = null;
1212
let playDuration: number | null = null;
1313

14-
// Noise buffer cache, keyed by `${type}:${rate}` and rebuilt per AudioContext.
15-
let noiseBufferCache: { ctx: AudioContext; buffers: Map<string, AudioBuffer> } | null = null;
16-
1714
// BBC Micro feeds the SN76489 at 4 MHz. The TI datasheet documents the noise
1815
// shift rate as clock/N where N ∈ {512, 1024, 2048} — i.e., the divisors are
1916
// applied to the raw input clock (the chip's own /16 prescaler is implied
@@ -23,60 +20,73 @@ const BBC_CHIP_CLOCK_HZ = 4_000_000;
2320
const NOISE_DIVISORS = [512, 1024, 2048] as const;
2421

2522
/**
26-
* Build a tiled mono AudioBuffer of the SN76489AN noise output for one
27-
* (type, rate) combination. We simulate a 15-bit LFSR — white feeds back the
28-
* XOR of bits 0 and 1, periodic feeds back bit 0 only (period 15 → the
29-
* characteristic BBC "drum" buzz). The LFSR is advanced sub-sample-accurately
30-
* with a fractional counter so the output is correct at any AudioContext rate.
23+
* Build a one-shot mono AudioBuffer covering the entire sample stream for the
24+
* noise channel. The MOS writes the running pitch byte to the noise control
25+
* register on every envelope tick, so the LFSR's type (white/periodic) and
26+
* shift rate evolve as PI/PN advance the pitch envelope. We mirror that here:
27+
* each centisecond of output is generated using the noise mode encoded in
28+
* (basePitch + sample.pitchOffset) & 7. P=3 / P=7 ("follows tone 2") are
29+
* punted to medium rate.
3130
*
32-
* Two seconds is plenty: white noise loops imperceptibly, periodic loops at
33-
* ~30 Hz/8 Hz so two seconds easily contains a whole number of cycles.
31+
* LFSR algorithm matches jsbeeb (src/soundchip.js):
32+
* white — feedback = bit0 ^ bit1, shifted into bit 14 (15-bit LFSR)
33+
* periodic — pure right shift, reload to 0x4000 (bit 14) when it reaches 0
3434
*/
35-
function buildNoiseBuffer(ac: AudioContext, mode: NoiseMode): AudioBuffer {
35+
function buildNoiseBufferForStream(ac: AudioContext, samples: Sample[], basePitch: number): AudioBuffer {
3636
const sampleRate = ac.sampleRate;
37-
const seconds = 2;
38-
const n = Math.floor(sampleRate * seconds);
37+
const seconds = samples.length * CS;
38+
const n = Math.max(1, Math.floor(sampleRate * seconds));
3939
const buf = ac.createBuffer(1, n, sampleRate);
4040
const data = buf.getChannelData(0);
4141

42-
// Rate 3 ("follows tone2") is punted to medium (rate 1).
43-
const rateIdx = mode.rate === 3 ? 1 : mode.rate;
44-
const shiftHz = BBC_CHIP_CLOCK_HZ / NOISE_DIVISORS[rateIdx]!;
45-
const shiftsPerSample = shiftHz / sampleRate;
46-
4742
let lfsr = 0x4000;
4843
let phase = 0;
4944
let out = (lfsr & 1) === 1 ? 1 : -1;
45+
const samplesPerCs = sampleRate * CS;
46+
let prevCsIdx = -1;
47+
let prevModeP = -1;
5048

5149
for (let i = 0; i < n; i++) {
50+
const csIdx = Math.min(samples.length - 1, Math.floor(i / samplesPerCs));
51+
const p = ((basePitch + samples[csIdx]!.pitchOffset) | 0) & 0x07;
52+
if (csIdx !== prevCsIdx) {
53+
// The MOS only writes the noise control register when the pitch byte
54+
// (low 3 bits) actually changes — env3 trace confirms: pure-volume
55+
// envelopes emit zero noise-register writes. jsbeeb's `noisePoked`
56+
// resets the LFSR to 0x4000 only on those writes. Mirror that: keep
57+
// LFSR running normally on flat-pitch envelopes (clean white noise),
58+
// and reset it whenever PI/PN advance the noise mode (gives the BBC's
59+
// characteristic warble for pitch-modulated effects like hyperspace).
60+
if (p !== prevModeP) {
61+
lfsr = 0x4000;
62+
phase = 0;
63+
out = (lfsr & 1) === 1 ? 1 : -1;
64+
}
65+
prevCsIdx = csIdx;
66+
prevModeP = p;
67+
}
68+
const isWhite = (p & 0x04) !== 0;
69+
const rateBits = p & 0x03;
70+
const rateIdx = rateBits === 3 ? 1 : rateBits;
71+
const shiftsPerSample = (BBC_CHIP_CLOCK_HZ / NOISE_DIVISORS[rateIdx]!) / sampleRate;
72+
5273
phase += shiftsPerSample;
5374
while (phase >= 1) {
5475
phase -= 1;
55-
const fb = mode.type === "white"
56-
? (lfsr & 1) ^ ((lfsr >> 1) & 1)
57-
: (lfsr & 1);
58-
lfsr = ((lfsr >> 1) | (fb << 14)) & 0x7fff;
59-
if (lfsr === 0) lfsr = 0x4000; // guard against the trivial lock-up state
76+
if (isWhite) {
77+
const fb = (lfsr & 1) ^ ((lfsr >> 1) & 1);
78+
lfsr = ((lfsr >> 1) | (fb << 14)) & 0x7fff;
79+
} else {
80+
lfsr = lfsr >> 1;
81+
if (lfsr === 0) lfsr = 0x4000;
82+
}
6083
out = (lfsr & 1) === 1 ? 1 : -1;
6184
}
6285
data[i] = out;
6386
}
6487
return buf;
6588
}
6689

67-
function getNoiseBuffer(ac: AudioContext, mode: NoiseMode): AudioBuffer {
68-
if (!noiseBufferCache || noiseBufferCache.ctx !== ac) {
69-
noiseBufferCache = { ctx: ac, buffers: new Map() };
70-
}
71-
const key = `${mode.type}:${mode.rate}`;
72-
let buf = noiseBufferCache.buffers.get(key);
73-
if (!buf) {
74-
buf = buildNoiseBuffer(ac, mode);
75-
noiseBufferCache.buffers.set(key, buf);
76-
}
77-
return buf;
78-
}
79-
8090
function getCtx(): AudioContext {
8191
if (!ctx) ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
8292
return ctx;
@@ -116,8 +126,10 @@ export function play(samples: Sample[], basePitch: number, noiseMode: NoiseMode
116126

117127
if (noiseMode) {
118128
const noiseSrc = ac.createBufferSource();
119-
noiseSrc.buffer = getNoiseBuffer(ac, noiseMode);
120-
noiseSrc.loop = true;
129+
// The noise buffer is rendered to match the sample stream exactly,
130+
// walking through whatever noise modes the running pitch byte selects
131+
// each centisecond. No looping — playback length matches the envelope.
132+
noiseSrc.buffer = buildNoiseBufferForStream(ac, samples, basePitch);
121133
noiseSrc.connect(gain).connect(ac.destination);
122134
source = noiseSrc;
123135
} else {

src/envelope.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function decodeNoiseP(pitch: number): NoiseMode {
8181
* The returned stream covers attack + decay + sustain + release, i.e. the
8282
* full audible lifetime of the note.
8383
*/
84-
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number, hold = false, channel = 1): Sample[] {
84+
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number, hold = false, _channel = 1): Sample[] {
8585
const samples: Sample[] = [];
8686

8787
// SOUND amplitude argument: 0 = silence, -15..-1 = static volume, 1..4 = envelope number.
@@ -125,8 +125,11 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
125125
// resets offset and runs the same tick's step from section 0, so the
126126
// wrap itself doesn't cost extra time (matches a normal step interval).
127127
const stepPitch = () => {
128-
if (channel === 0) return; // noise channel ignores PI/PN entirely
129128
if (tStep === 0) return; // T=0 disables the pitch envelope
129+
// Note: this runs on channel 0 too. The MOS writes the running pitch
130+
// byte to the noise control register every envelope tick, so PI/PN
131+
// walk the noise type+rate combinations over time — essential for
132+
// effects like Elite's hyperspace warble.
130133
if (sectionIdx >= sections.length) {
131134
if (!repeat) return;
132135
sectionIdx = 0;

src/main.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -277,20 +277,15 @@ noiseSelect.addEventListener("change", () => {
277277
noiseModeWrap.appendChild(noiseSelect);
278278
soundGrid.appendChild(noiseModeWrap);
279279

280-
const pitchH3 = pitchGrid.previousElementSibling as HTMLElement;
281280
const pitchInputWrap = soundInputs.get("pitch")!.closest("label") as HTMLElement;
282281

283282
function applyChannelMode(): void {
284283
const isNoise = sound.channel === 0;
285-
// Grey out the pitch envelope inputs rather than hiding the section, so
286-
// the layout doesn't jump when switching channels. Same treatment for the
287-
// SOUND pitch number input (replaced by the noise-mode dropdown).
288-
pitchH3.classList.toggle("disabled", isNoise);
289-
pitchGrid.classList.toggle("disabled", isNoise);
290-
for (const f of PITCH_FIELDS) {
291-
const i = envInputs.get(f.key);
292-
if (i) i.disabled = isNoise;
293-
}
284+
// The pitch envelope is editable on noise channel too: the MOS writes
285+
// the running pitch byte (low 3 bits) to the noise control register on
286+
// every envelope tick, so T/PI/PN walk the noise mode (white/periodic +
287+
// shift rate) over time. Only the SOUND pitch input is swapped for the
288+
// noise-mode dropdown that picks the *starting* mode.
294289
pitchInputWrap.classList.toggle("disabled", isNoise);
295290
const pitchInput = soundInputs.get("pitch");
296291
if (pitchInput) pitchInput.disabled = isNoise;

src/presets.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ export const PRESETS: Preset[] = [
205205
aa: 22, ad: 0, as: 0, ar: -127, ala: 126, ald: 0 },
206206
sound: { channel: 3, amplitude: 4, pitch: 194, duration: 80 },
207207
},
208+
{
209+
name: "Elite: explosion",
210+
description: "Elite — death / kill; SFX entry 24 in the cassette source — channel 0 white noise (P=7), originally static amp -15, duration 26",
211+
// Original: SOUND 0,-15,7,26. Converted to a flat-max envelope with a
212+
// hard release (AR=-127) so the noise cuts off cleanly when the SOUND
213+
// duration ends — mimicking the real chip's gate behaviour.
214+
env: { n: 1, t: 1, pi1: 0, pi2: 0, pi3: 0, pn1: 0, pn2: 0, pn3: 0,
215+
aa: 127, ad: 0, as: 0, ar: -127, ala: 126, ald: 126 },
216+
sound: { channel: 0, amplitude: 1, pitch: 7, duration: 26 },
217+
},
218+
{
219+
name: "Elite: missile",
220+
description: "Elite — missile launch / ship launching from station; SFX entry 48 — channel 0 white noise (P=6), originally static amp -15",
221+
env: { n: 1, t: 1, pi1: 0, pi2: 0, pi3: 0, pn1: 0, pn2: 0, pn3: 0,
222+
aa: 127, ad: 0, as: 0, ar: -127, ala: 126, ald: 126 },
223+
sound: { channel: 0, amplitude: 1, pitch: 6, duration: 12 },
224+
},
225+
{
226+
name: "Elite: hyperspace",
227+
description: "Elite — hyperspace drive engaged; SFX entry 56 — channel 0 noise with envelope 2 (the same envelope used for laser hits) and pitch 96 (P&7 = 0 → periodic noise, high rate)",
228+
env: { n: 2, t: 1, pi1: 14, pi2: -18, pi3: -1, pn1: 44, pn2: 32, pn3: 50,
229+
aa: 6, ad: 1, as: 0, ar: -2, ala: 120, ald: 126 },
230+
sound: { channel: 0, amplitude: 2, pitch: 96, duration: 16 },
231+
},
208232
{
209233
name: "Chuckie: blip",
210234
description: "Chuckie Egg (1983, A&F / Alderton) — short percussive bleep used for collecting eggs and similar",
@@ -233,6 +257,16 @@ export const PRESETS: Preset[] = [
233257
aa: 126, ad: -4, as: -2, ar: -4, ala: 126, ald: 110 },
234258
sound: { channel: 2, amplitude: 3, pitch: 185, duration: 5 },
235259
},
260+
{
261+
name: "Thrust: engine",
262+
description: "Thrust — engine-thrust noise blip retriggered every frame while thrusting; channel 0 white noise medium rate (P=5), originally static amp -10",
263+
// Original sound block: $10,$00,$F6,$FF,$05,$00,$03,$00 → channel 0,
264+
// amp -10, pitch 5, duration 3. Converted to an envelope holding at
265+
// ~84/126 (the BBC level corresponding to static -10) for the duration.
266+
env: { n: 1, t: 1, pi1: 0, pi2: 0, pi3: 0, pn1: 0, pn2: 0, pn3: 0,
267+
aa: 127, ad: 0, as: 0, ar: -127, ala: 84, ald: 84 },
268+
sound: { channel: 0, amplitude: 1, pitch: 5, duration: 4 },
269+
},
236270
// Examples from the BBC Microcomputer User Guide chapter on SOUND/ENVELOPE.
237271
{
238272
name: "UG #1 wobble",

src/visualizer.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,7 @@ export function render(
4040
ctx.font = "12px system-ui, sans-serif";
4141
ctx.fillText("Amplitude (0..126)", padding, ampTop - 6);
4242
if (noiseMode) {
43-
const rateLabels = ["high", "medium", "low", "follows tone2 (treated as medium)"] as const;
44-
ctx.fillText(
45-
`Noise: ${noiseMode.type}, ${rateLabels[noiseMode.rate]}${samples.length} centiseconds`,
46-
padding, pitchTop - 6,
47-
);
43+
ctx.fillText(`Noise mode (${samples.length} centiseconds)`, padding, pitchTop - 6);
4844
} else {
4945
ctx.fillText(`Pitch (BBC units, 4 = 1 semitone) — ${samples.length} centiseconds`, padding, pitchTop - 6);
5046
}
@@ -175,26 +171,38 @@ export function render(
175171
}
176172
ctx.stroke();
177173
} else {
178-
// Noise channel: pitch plot is replaced by a centred mode caption.
174+
// Noise channel: pitch plot becomes a strip showing how the noise mode
175+
// evolves over time. The MOS writes the running pitch byte (low 3 bits)
176+
// into the noise control register on every envelope tick, so PI/PN walk
177+
// the noise type+rate combinations during the note. We colour-band each
178+
// cs by its current mode so the user can see when the chip switches.
179+
const modeColour = (p: number): string => {
180+
const isWhite = (p & 0x04) !== 0;
181+
const rateBits = p & 0x03;
182+
// White = warm, periodic = cool. Brightness encodes rate.
183+
const whitePalette = ["#f4a261", "#e76f51", "#bc4749", "#6a040f"]; // high → low → tone2
184+
const periodicPalette = ["#90e0ef", "#48cae4", "#0096c7", "#023e8a"];
185+
return (isWhite ? whitePalette : periodicPalette)[rateBits]!;
186+
};
187+
const stripTop = pitchTop + halfH * 0.35;
188+
const stripH = halfH * 0.3;
189+
for (let i = 0; i < samples.length; i++) {
190+
const x = xFor(i);
191+
const xNext = i + 1 < samples.length ? xFor(i + 1) : padding + plotW;
192+
const p = ((basePitch + samples[i]!.pitchOffset) | 0) & 0x07;
193+
ctx.fillStyle = modeColour(p);
194+
ctx.fillRect(x, stripTop, Math.max(1, xNext - x + 1), stripH);
195+
}
179196
ctx.fillStyle = "#8b949e";
180197
ctx.textAlign = "center";
181-
ctx.textBaseline = "middle";
182-
ctx.font = "bold 14px system-ui, sans-serif";
183-
ctx.fillText(
184-
`${noiseMode.type.toUpperCase()} noise`,
185-
padding + plotW / 2,
186-
pitchTop + halfH / 2 - 8,
187-
);
198+
ctx.textBaseline = "alphabetic";
188199
ctx.font = "12px system-ui, sans-serif";
189-
ctx.fillStyle = "#6e7681";
190-
const rateNames = ["high (~7.8 kHz)", "medium (~3.9 kHz)", "low (~2.0 kHz)", "follows tone 2"] as const;
191200
ctx.fillText(
192-
`LFSR shift rate: ${rateNames[noiseMode.rate]}`,
201+
"Noise mode strip — warm = white, cool = periodic; brightness ↑ = faster shift rate",
193202
padding + plotW / 2,
194-
pitchTop + halfH / 2 + 12,
203+
stripTop - 6,
195204
);
196205
ctx.textAlign = "start";
197-
ctx.textBaseline = "alphabetic";
198206
}
199207

200208
if (playhead !== null && playhead >= 0 && playhead <= 1) {

0 commit comments

Comments
 (0)