Skip to content

Commit 49d7e23

Browse files
committed
Add Hold checkbox for envelopes with no natural end
The Hold checkbox in the transport row sets the SOUND BASIC line to duration -1 (BBC syntax for "envelope keeps running until interrupted") and makes expand() generate ~15 s of samples without ever transitioning to release. Pitch and amp envelopes keep evolving exactly as they would inside the SOUND duration. UG #8 sweep and UG #9 ramp default to hold on, since their AR=0 means they never naturally terminate; loadPreset reads a new optional Preset.hold flag. Other presets clear it on load. Persisted in the URL via &hold=1 so shared links carry the state. Initially named "Loop" but Loop conflicts with the pitch envelope's own loop (T<128) and implies the audio repeats from the start, which is not what BBC -1 does. Renamed to Hold throughout.
1 parent 4e8a6b1 commit 49d7e23

5 files changed

Lines changed: 94 additions & 14 deletions

File tree

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ <h2>Visualisation</h2>
4646
<div class="transport">
4747
<button id="play">Play</button>
4848
<button id="stop">Stop</button>
49+
<label class="hold-toggle" title="Hold the note indefinitely (BBC SOUND duration -1)"><input type="checkbox" id="hold" /> Hold</label>
4950
<button id="share-link" title="Copy a shareable URL with auto-play that reproduces the current envelope and sound">Copy share link</button>
5051
<button id="run-emulator" title="Open bbc.xania.org with the current ENVELOPE and SOUND auto-running">Run in BBC emulator ↗</button>
5152
</div>

src/envelope.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const PITCH_NO_REPEAT_BIT = 0x80;
5656
* The returned stream covers attack + decay + sustain + release, i.e. the
5757
* full audible lifetime of the note.
5858
*/
59-
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number): Sample[] {
59+
export function expand(env: Envelope, soundAmplitude: number, soundDuration: number, hold = false): Sample[] {
6060
const samples: Sample[] = [];
6161

6262
// SOUND amplitude argument: 0 = silence, -15..-1 = static volume, 1..4 = envelope number.
@@ -65,6 +65,14 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
6565
// the envelope is bypassed entirely; we still let callers preview the envelope shape.
6666
const useEnvelope = soundAmplitude > 0;
6767

68+
// Hold mode mirrors BBC SOUND duration -1: the envelope keeps running its
69+
// attack/decay/sustain phases without ever transitioning to release. Amp,
70+
// pitch and the looping pitch envelope all keep evolving exactly as they
71+
// would within the SOUND duration; the note just doesn't terminate. We
72+
// generate ~15 s of audio so the cycling state is audible/visible — the
73+
// user can press Stop or replay to extend.
74+
const HOLD_NOTE_CS = 1500;
75+
6876
// Pitch envelope state.
6977
const tStep = env.t & 0x7f; // step length in centiseconds
7078
const repeat = (env.t & PITCH_NO_REPEAT_BIT) === 0;
@@ -118,7 +126,7 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
118126

119127
// Amplitude envelope state.
120128
// Phases run in order: attack -> decay -> sustain (until note ends) -> release (to 0).
121-
const noteCentiseconds = soundDuration * 5; // 1/20s -> 1/100s
129+
const noteCentiseconds = hold ? HOLD_NOTE_CS : soundDuration * 5; // 1/20s -> 1/100s
122130
let amplitude = 0;
123131
let phase: Sample["phase"] = "attack";
124132
let csElapsed = 0;
@@ -143,9 +151,18 @@ export function expand(env: Envelope, soundAmplitude: number, soundDuration: num
143151
// SOUND duration end forces release from any non-release phase. The check
144152
// is per-cs so release starts at the right cs even if it's between
145153
// envelope ticks; the actual release amp change happens on the next tick.
146-
if (useEnvelope && phase !== "release" && csElapsed + 1 >= noteCentiseconds) {
147-
phase = "release";
148-
if (releaseStartedAt < 0) releaseStartedAt = csElapsed;
154+
// In hold mode (BBC SOUND duration -1) the envelope keeps running its
155+
// attack/decay/sustain phases without ever transitioning to release —
156+
// we just stop emitting samples at the bound.
157+
if (useEnvelope && csElapsed + 1 >= noteCentiseconds) {
158+
if (hold) {
159+
samples.push({ pitchOffset, amplitude: clamp(amplitude, 0, 126), phase });
160+
return samples;
161+
}
162+
if (phase !== "release") {
163+
phase = "release";
164+
if (releaseStartedAt < 0) releaseStartedAt = csElapsed;
165+
}
149166
}
150167

151168
// Envelope tick: pitch step *and* amplitude envelope advance every T cs.
@@ -237,9 +254,13 @@ export function formatBasic(env: Envelope): string {
237254
return `ENVELOPE ${parts.join(",")}`;
238255
}
239256

240-
/** Format a BBC BASIC `SOUND` statement. */
241-
export function formatSound(channel: number, amplitude: number, pitch: number, duration: number): string {
242-
return `SOUND ${channel},${amplitude},${pitch},${duration}`;
257+
/**
258+
* Format a BBC BASIC `SOUND` statement. `hold=true` emits duration -1, which
259+
* is BBC BASIC syntax for "envelope keeps running until interrupted".
260+
*/
261+
export function formatSound(channel: number, amplitude: number, pitch: number, duration: number, hold = false): string {
262+
const dur = hold ? -1 : duration;
263+
return `SOUND ${channel},${amplitude},${pitch},${dur}`;
243264
}
244265

245266
/**

src/main.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ const SOUND_FIELDS: { key: keyof SoundParams; code: string; label: string; min:
6161
const env: Envelope = { ...DEFAULT_ENVELOPE };
6262
const sound: SoundParams = { ...DEFAULT_SOUND };
6363

64+
let holdEnabled = false;
65+
6466
// Capture before refresh() rewrites the URL via history.replaceState (which
6567
// strips presence-only params like `play`).
6668
const autoplayRequested = new URLSearchParams(location.search).has("play");
@@ -70,10 +72,14 @@ const canvas = document.getElementById("viz") as HTMLCanvasElement;
7072
const envelopeLine = document.getElementById("envelope-line") as HTMLInputElement;
7173
const soundLine = document.getElementById("sound-line") as HTMLInputElement;
7274
const envNInput = document.getElementById("env-n") as HTMLInputElement;
75+
const holdInput = document.getElementById("hold") as HTMLInputElement;
7376
const pitchGrid = document.getElementById("pitch-grid") as HTMLElement;
7477
const ampGrid = document.getElementById("amp-grid") as HTMLElement;
7578
const soundGrid = document.getElementById("sound-grid") as HTMLElement;
7679

80+
// Sync the checkbox to whatever loadFromUrlParams() may have set above.
81+
holdInput.checked = holdEnabled;
82+
7783
const envInputs = new Map<keyof Envelope, HTMLInputElement>();
7884
const soundInputs = new Map<keyof SoundParams, HTMLInputElement>();
7985

@@ -103,10 +109,10 @@ function setEnvelopeNumber(n: number): void {
103109
}
104110

105111
function refresh(skipLine?: "envelope" | "sound"): void {
106-
currentSamples = expand(env, sound.amplitude, sound.duration);
112+
currentSamples = expand(env, sound.amplitude, sound.duration, holdEnabled);
107113
render(canvas, currentSamples, sound.pitch, playheadFraction());
108114
if (skipLine !== "envelope") envelopeLine.value = formatBasic(env);
109-
if (skipLine !== "sound") soundLine.value = formatSound(sound.channel, sound.amplitude, sound.pitch, sound.duration);
115+
if (skipLine !== "sound") soundLine.value = formatSound(sound.channel, sound.amplitude, sound.pitch, sound.duration, holdEnabled);
110116
updateUrlParams();
111117
// Any state change clears the active preset; loadPreset re-sets it after
112118
// its own refresh() call.
@@ -123,6 +129,7 @@ function updateUrlParams(): void {
123129
env.aa, env.ad, env.as, env.ar, env.ala, env.ald].join(",");
124130
const soundValues = [sound.channel, sound.amplitude, sound.pitch, sound.duration].join(",");
125131
const params = new URLSearchParams({ env: envValues, sound: soundValues });
132+
if (holdEnabled) params.set("hold", "1");
126133
history.replaceState(null, "", `?${params.toString()}`);
127134
}
128135

@@ -142,6 +149,7 @@ function loadFromUrlParams(): void {
142149
const parsed = parseSound(soundStr);
143150
if (parsed) Object.assign(sound, parsed);
144151
}
152+
if (params.has("hold")) holdEnabled = true;
145153
}
146154

147155
function animatePlayhead(): void {
@@ -253,6 +261,17 @@ soundLine.addEventListener("input", () => {
253261
return;
254262
}
255263
soundLine.classList.remove("invalid");
264+
// BBC BASIC duration -1 means "play forever". Map that to our loop flag
265+
// and keep a reasonable per-cycle duration so the visualisation has
266+
// something to show.
267+
if (parsed.duration < 0) {
268+
holdEnabled = true;
269+
holdInput.checked = true;
270+
parsed.duration = sound.duration > 0 ? sound.duration : 20;
271+
} else {
272+
holdEnabled = false;
273+
holdInput.checked = false;
274+
}
256275
Object.assign(sound, parsed);
257276
setEnvelopeNumber(sound.amplitude);
258277
for (const f of SOUND_FIELDS) {
@@ -273,6 +292,10 @@ function setActivePreset(idx: number | null): void {
273292
function loadPreset(p: Preset, idx: number): void {
274293
Object.assign(env, p.env);
275294
Object.assign(sound, p.sound);
295+
// Apply the preset's hold flag (defaults to false). UG #8/#9 set it
296+
// because their envelopes don't naturally terminate.
297+
holdEnabled = p.hold === true;
298+
holdInput.checked = holdEnabled;
276299
setEnvelopeNumber(sound.amplitude);
277300
for (const f of ENV_FIELDS) {
278301
const input = envInputs.get(f.key);
@@ -300,18 +323,37 @@ PRESETS.forEach((p, idx) => {
300323
presetButtons.push(btn);
301324
});
302325

303-
document.getElementById("play")!.addEventListener("click", () => {
326+
function playOnce(): void {
327+
// The sample stream itself is long when holdEnabled (expand returns a
328+
// ~15 s buffer with the envelope's pitch and amp continuing to evolve),
329+
// so audio playback is just a single play() call — no setTimeout-based
330+
// re-trigger. Mirrors BBC SOUND duration -1 semantics: envelope keeps
331+
// running, doesn't restart from the top.
304332
play(currentSamples, sound.pitch);
305333
animatePlayhead();
306-
});
334+
}
307335

308-
document.getElementById("stop")!.addEventListener("click", () => {
336+
function stopAll(): void {
309337
stop();
310338
if (playheadRaf !== null) {
311339
cancelAnimationFrame(playheadRaf);
312340
playheadRaf = null;
313341
}
314342
render(canvas, currentSamples, sound.pitch, null);
343+
}
344+
345+
document.getElementById("play")!.addEventListener("click", () => {
346+
playOnce();
347+
});
348+
349+
document.getElementById("stop")!.addEventListener("click", () => {
350+
stopAll();
351+
});
352+
353+
holdInput.addEventListener("change", () => {
354+
holdEnabled = holdInput.checked;
355+
// Reflect in the SOUND BASIC line and URL.
356+
refresh();
315357
});
316358

317359
document.getElementById("share-link")!.addEventListener("click", async (e) => {
@@ -337,7 +379,7 @@ document.getElementById("run-emulator")!.addEventListener("click", () => {
337379
// jsbeeb (bbc.xania.org) takes URL-encoded BASIC via embedBasic, with
338380
// &autorun to type RUN after tokenising. Two numbered lines are enough:
339381
// ENVELOPE registers the envelope, SOUND queues the note.
340-
const program = `10 ${formatBasic(env)}\n20 ${formatSound(sound.channel, sound.amplitude, sound.pitch, sound.duration)}\n`;
382+
const program = `10 ${formatBasic(env)}\n20 ${formatSound(sound.channel, sound.amplitude, sound.pitch, sound.duration, holdEnabled)}\n`;
341383
const url = `https://bbc.xania.org/?embedBasic=${encodeURIComponent(program)}&autorun`;
342384
window.open(url, "_blank", "noopener,noreferrer");
343385
});

src/presets.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export interface Preset {
1212
description: string;
1313
env: Envelope;
1414
sound: SoundPreset;
15+
/** True for envelopes that don't naturally terminate — sets the Hold
16+
* checkbox so the audio keeps the envelope running indefinitely. */
17+
hold?: boolean;
1518
}
1619

1720
// `T` in 1..127 auto-repeats the pitch envelope for the duration of the
@@ -240,12 +243,14 @@ export const PRESETS: Preset[] = [
240243
env: { n: 1, t: 1, pi1: -26, pi2: -36, pi3: -45, pn1: 255, pn2: 255, pn3: 255,
241244
aa: 127, ad: 0, as: 0, ar: 0, ala: 126, ald: 0 },
242245
sound: { channel: 1, amplitude: 1, pitch: 1, duration: 1 },
246+
hold: true,
243247
},
244248
{
245249
name: "UG #9 ramp",
246250
description: "BBC User Guide example — three-section pitch envelope with large excursions (rise +100, drop -200, rise +200 per loop). Net +100 per loop produces an upward-drifting sweep over a long held-loud tone.",
247251
env: { n: 2, t: 3, pi1: 2, pi2: -4, pi3: 4, pn1: 50, pn2: 50, pn3: 50,
248252
aa: 127, ad: 0, as: 0, ar: 0, ala: 126, ald: 0 },
249253
sound: { channel: 1, amplitude: 2, pitch: 1, duration: 10 },
254+
hold: true,
250255
},
251256
];

src/style.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,18 @@ canvas {
198198
display: flex;
199199
gap: 8px;
200200
margin: 12px 0;
201+
align-items: center;
202+
}
203+
.hold-toggle {
204+
display: inline-flex;
205+
align-items: center;
206+
gap: 4px;
207+
font-size: 0.85rem;
208+
color: #c9d1d9;
209+
cursor: pointer;
210+
user-select: none;
201211
}
212+
.hold-toggle input { cursor: pointer; }
202213

203214
.presets {
204215
display: flex;

0 commit comments

Comments
 (0)