@@ -61,6 +61,8 @@ const SOUND_FIELDS: { key: keyof SoundParams; code: string; label: string; min:
6161const env : Envelope = { ...DEFAULT_ENVELOPE } ;
6262const 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`).
6668const autoplayRequested = new URLSearchParams ( location . search ) . has ( "play" ) ;
@@ -70,10 +72,14 @@ const canvas = document.getElementById("viz") as HTMLCanvasElement;
7072const envelopeLine = document . getElementById ( "envelope-line" ) as HTMLInputElement ;
7173const soundLine = document . getElementById ( "sound-line" ) as HTMLInputElement ;
7274const envNInput = document . getElementById ( "env-n" ) as HTMLInputElement ;
75+ const holdInput = document . getElementById ( "hold" ) as HTMLInputElement ;
7376const pitchGrid = document . getElementById ( "pitch-grid" ) as HTMLElement ;
7477const ampGrid = document . getElementById ( "amp-grid" ) as HTMLElement ;
7578const soundGrid = document . getElementById ( "sound-grid" ) as HTMLElement ;
7679
80+ // Sync the checkbox to whatever loadFromUrlParams() may have set above.
81+ holdInput . checked = holdEnabled ;
82+
7783const envInputs = new Map < keyof Envelope , HTMLInputElement > ( ) ;
7884const soundInputs = new Map < keyof SoundParams , HTMLInputElement > ( ) ;
7985
@@ -103,10 +109,10 @@ function setEnvelopeNumber(n: number): void {
103109}
104110
105111function 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
147155function 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 {
273292function 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
317359document . 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} ) ;
0 commit comments