Implementing QBasic PLAY Statement Implementing QBasic PLAY Statementqbasic audiocontext webaudio

In this post, we'll explore how the PLAY statement is implemented in my Basic interpreter .

QBasic's PLAY statement interprets a string containing musical commands and translates them into computer-generated sounds. See a selection of Christmas carols played using PLAY statements in this demo: CAROLS.BAS (by Greg Rismoen).

We'll use the Web Audio API to generate sounds and regular expressions to parse the PLAY commands.

Implementation

The following PLAY commands are supported:

CommandDescription OnSets the current octave (from 0 to 6). Example: PLAY "O3"<Down one octave (cannot be below zero). Example: PLAY "<<" 'goes down two octaves.>Up one octave (cannot be above 6). Example: PLAY ">>" ' goes up two octaves.A, B, C, D, E, F or GThe notes in the current octave.+ or #Can be added to a note for a sharp note. Example: PLAY "C#"-Can be added to a note for a flat note. Example: PLAY "C-"NnPlays a note n by number(n can be between 0 to 84 in the 7 octaves, where 0 is a rest). Example: PLAY "N42"LnSets length of a note (n can be 1 to 64 where 1 is a whole note and 4 is a quarter of a note etc.). Example: PLAY "L4"MSEach note plays 3/4 of length set by L (staccato)MNEach note plays 7/8 of length set by L (normal)MLEach note plays full length set by L (legato)PnPause in the duration of n quarternotes (n can be 1 to 64) corresponding to L, Example: PLAY "P32"TnTempo sets number of L4 quarter notes per minute (n can be 32 to 255 where 120 is the default). Example: PLAY "T180".Period after a note plays 1½ times the note length determined by L * T...Two periods plays 1-3/4 times the note length determined by L * T.MFPlay music in the foreground (each note must be completed before another can start).MBPlay music in the background while program code execution continues.

Building Notes

To use the Audio API AudioContext oscillator, we first need to create a map of notes and their corresponding frequencies. We need to do this for each octave. The frequency calculation uses the formula freq = 2 ** ((n - 48 + 3) / 12) * 440; where n is the note number. Our middle note is A at 440Hz. There are 12 evenly spaced notes in each octave.

type Octave = Record<string, number>;

function buildNotes() {
	const result = [];
	const keys = [
		'C',
		'C#',
		'D',
		'D#',
		'E',
		'F',
		'F#',
		'G',
		'G#',
		'A',
		'A#',
		'B',
	];
	const keys3 = ['', 'C+', '', 'D+', '', '', 'F+', '', 'G+', '', 'A+', ''];
	const keys2 = [
		'',
		'D-',
		'',
		'E-',
		'F-',
		'',
		'G-',
		'',
		'A-',
		'',
		'B-',
		'C-',
	];
	for (let n = 0, current: Octave = {}; n < 88; n++) {
		const i = n % 12;
		if (i === 0) {
			current = {};
			result.push(current);
		}
		const freq = 2 ** ((n - 48 + 3) / 12) * 440;
		PLAY_NOTES.push(freq);
		current[keys[i]] = freq;
		if (keys2[i]) current[keys2[i]] = freq;
		if (keys3[i]) current[keys3[i]] = freq;
	}
	return result;
}

The frequencies for each notes are stored in PLAY_NOTES:

const PLAY_NOTES = [
	32.70319566257483, 34.64782887210902, 36.70809598967594, 38.89087296526011,
	41.20344461410874, 43.653528929125486, 46.24930283895431,
	48.999429497718666, 51.91308719749314, 55, 58.27047018976124,
	61.7354126570155, 65.40639132514966, 69.29565774421803, 73.41619197935188,
	77.78174593052022, 82.40688922821748, 87.30705785825097, 92.49860567790861,
	97.99885899543733, 103.82617439498628, 110, 116.54094037952248,
	123.47082531403103, 130.8127826502993, 138.59131548843604,
	146.8323839587038, 155.56349186104043, 164.81377845643496,
	174.61411571650194, 184.99721135581723, 195.99771799087463,
	207.65234878997256, 220, 233.08188075904496, 246.94165062806206,
	261.6255653005986, 277.1826309768721, 293.6647679174076, 311.12698372208087,
	329.6275569128699, 349.2282314330039, 369.99442271163446,
	391.99543598174927, 415.3046975799451, 440, 466.1637615180899,
	493.8833012561241, 523.2511306011972, 554.3652619537442, 587.3295358348151,
	622.2539674441618, 659.2551138257398, 698.4564628660078, 739.9888454232689,
	783.9908719634986, 830.6093951598903, 880, 932.3275230361799,
	987.7666025122483, 1046.5022612023945, 1108.7305239074883,
	1174.6590716696303, 1244.5079348883235, 1318.5102276514797,
	1396.9129257320155, 1479.9776908465378, 1567.981743926997,
	1661.2187903197805, 1760, 1864.6550460723597, 1975.533205024496,
	2093.004522404789, 2217.461047814977, 2349.31814333926, 2489.015869776647,
	2637.0204553029594, 2793.825851464031, 2959.9553816930757,
	3135.9634878539946, 3322.437580639561, 3520, 3729.3100921447194,
	3951.066410048992, 4186.009044809578, 4434.922095629954, 4698.63628667852,
	4978.031739553294,
];

And the Map of Note names to Frequencies in PLAY_DATA:

[{"C":32.70319566257483,"C#":34.64782887210902,"D-":34.64782887210902,"C+":34.64782887210902,"D":36.70809598967594,"D#":38.89087296526011,"E-":38.89087296526011,"D+":38.89087296526011,"E":41.20344461410874,"F-":41.20344461410874,"F":43.653528929125486,"F#":46.24930283895431,"G-":46.24930283895431,"F+":46.24930283895431,"G":48.999429497718666,"G#":51.91308719749314,"A-":51.91308719749314,"G+":51.91308719749314,"A":55,"A#":58.27047018976124,"B-":58.27047018976124,"A+":58.27047018976124,"B":61.7354126570155,"C-":61.7354126570155},{"C":65.40639132514966,"C#":69.29565774421803,"D-":69.29565774421803,"C+":69.29565774421803,"D":73.41619197935188,"D#":77.78174593052022,"E-":77.78174593052022,"D+":77.78174593052022,"E":82.40688922821748,"F-":82.40688922821748,"F":87.30705785825097,"F#":92.49860567790861,"G-":92.49860567790861,"F+":92.49860567790861,"G":97.99885899543733,"G#":103.82617439498628,"A-":103.82617439498628,"G+":103.82617439498628,"A":110,"A#":116.54094037952248,"B-":116.54094037952248,"A+":116.54094037952248,"B":123.47082531403103,"C-":123.47082531403103},{"C":130.8127826502993,"C#":138.59131548843604,"D-":138.59131548843604,"C+":138.59131548843604,"D":146.8323839587038,"D#":155.56349186104043,"E-":155.56349186104043,"D+":155.56349186104043,"E":164.81377845643496,"F-":164.81377845643496,"F":174.61411571650194,"F#":184.99721135581723,"G-":184.99721135581723,"F+":184.99721135581723,"G":195.99771799087463,"G#":207.65234878997256,"A-":207.65234878997256,"G+":207.65234878997256,"A":220,"A#":233.08188075904496,"B-":233.08188075904496,"A+":233.08188075904496,"B":246.94165062806206,"C-":246.94165062806206},{"C":261.6255653005986,"C#":277.1826309768721,"D-":277.1826309768721,"C+":277.1826309768721,"D":293.6647679174076,"D#":311.12698372208087,"E-":311.12698372208087,"D+":311.12698372208087,"E":329.6275569128699,"F-":329.6275569128699,"F":349.2282314330039,"F#":369.99442271163446,"G-":369.99442271163446,"F+":369.99442271163446,"G":391.99543598174927,"G#":415.3046975799451,"A-":415.3046975799451,"G+":415.3046975799451,"A":440,"A#":466.1637615180899,"B-":466.1637615180899,"A+":466.1637615180899,"B":493.8833012561241,"C-":493.8833012561241},{"C":523.2511306011972,"C#":554.3652619537442,"D-":554.3652619537442,"C+":554.3652619537442,"D":587.3295358348151,"D#":622.2539674441618,"E-":622.2539674441618,"D+":622.2539674441618,"E":659.2551138257398,"F-":659.2551138257398,"F":698.4564628660078,"F#":739.9888454232689,"G-":739.9888454232689,"F+":739.9888454232689,"G":783.9908719634986,"G#":830.6093951598903,"A-":830.6093951598903,"G+":830.6093951598903,"A":880,"A#":932.3275230361799,"B-":932.3275230361799,"A+":932.3275230361799,"B":987.7666025122483,"C-":987.7666025122483},{"C":1046.5022612023945,"C#":1108.7305239074883,"D-":1108.7305239074883,"C+":1108.7305239074883,"D":1174.6590716696303,"D#":1244.5079348883235,"E-":1244.5079348883235,"D+":1244.5079348883235,"E":1318.5102276514797,"F-":1318.5102276514797,"F":1396.9129257320155,"F#":1479.9776908465378,"G-":1479.9776908465378,"F+":1479.9776908465378,"G":1567.981743926997,"G#":1661.2187903197805,"A-":1661.2187903197805,"G+":1661.2187903197805,"A":1760,"A#":1864.6550460723597,"B-":1864.6550460723597,"A+":1864.6550460723597,"B":1975.533205024496,"C-":1975.533205024496},{"C":2093.004522404789,"C#":2217.461047814977,"D-":2217.461047814977,"C+":2217.461047814977,"D":2349.31814333926,"D#":2489.015869776647,"E-":2489.015869776647,"D+":2489.015869776647,"E":2637.0204553029594,"F-":2637.0204553029594,"F":2793.825851464031,"F#":2959.9553816930757,"G-":2959.9553816930757,"F+":2959.9553816930757,"G":3135.9634878539946,"G#":3322.437580639561,"A-":3322.437580639561,"G+":3322.437580639561,"A":3520,"A#":3729.3100921447194,"B-":3729.3100921447194,"A+":3729.3100921447194,"B":3951.066410048992,"C-":3951.066410048992},{"C":4186.009044809578,"C#":4434.922095629954,"D-":4434.922095629954,"C+":4434.922095629954,"D":4698.63628667852,"D#":4978.031739553294,"E-":4978.031739553294,"D+":4978.031739553294}]

The PLAY function

The PLAY function acts as the conductor, receiving instructions (a string of musical commands) and the virtual machine's state (VM object) as input. It orchestrates these elements to generate sounds.

The VM object stores the function's current state (tempo, note duration, octave, etc.) for consistent playback across calls.

PLAY_STATE = {
	tempo: 120, // Notes per minute
	length: 1, // Current note duration
	lengthMod: 0.875, // 0.75 for stacatto, 0.875 for normal, and 1 for legato
	octave: 3,
};

Obtaining the Audio Context:

The getAudioContext function retrieves or creates an AudioContext instance. This context handles audio processing in the browser. It's cached for reuse across calls.

...
	getAudioContext() {
		if (this.audioContext) return this.audioContext;
		const AudioContext = window.AudioContext || window.webkitAudioContext;
		const audioCtx = new AudioContext();
		audioCtx.createGain();
		return (this.audioContext = audioCtx);
	}

The TIME variable holds the offset time from the current moment when the note should start playing. It essentially schedules playback for a specific point in time.

The BG variable determines playback behavior. If BG is true (set by MB or MF commands, not shown here), the function won't wait for the note to finish before continuing execution. This allows for background music or overlapping notes.

The createOscillator method on the AudioContext creates an oscillator object. This oscillator generates a continuous sound wave at a specific frequency. The type property of the oscillator defines the sound's character. Here, we set it to "sine" for a smooth, pure tone. The connect method connects the oscillator to the AudioContext's output (destination). This establishes the path for the generated sound wave to reach the speakers.

async PLAY(this: VM, str: string) {
	let m,
		TIME = 0,
		BG = false;
	const state = this.PLAY_STATE;
	const audioCtx = this.getAudioContext();
	const oscillator = audioCtx.createOscillator();
	oscillator.type = 'sine';
	oscillator.connect(audioCtx.destination);
	...
}

Parsing the PLAY Commands

To parse the commands we use the following Regular Expression.

const PLAY_PARSER =
	/O(\d)|([<>])|([A-G][#+-]?)(\d{0,2})([.]*)|N(\d\d?)|L(\d\d?)|M([LNSFB])|P(\d\d?)|T(\d\d\d?)/g;

We execute the regular expression in a while loop until we reach the end of the string. We use object destructuring to get the current parsed command.

The first few commands only change the state, T changes the tempo, O the octave, L the note length, etc.

Pauses (P command) are implemented by sending a zero frequency to the oscillator and adding its duration to our TIME marker.

We use the playNote function to play our string notes (C,D#,E...) and call play directly when we have an N command note.

...
	str = str.toUpperCase();
	while ((m = PLAY_PARSER.exec(str))) {
		const [, O, arrow, note, duration, dotMod, N, L, MLNSFB, P, T] = m;
		if (T) state.tempo = +T;
		else if (O) state.octave = +O + 1;
		else if (arrow === '<' && state.octave > 0) state.octave--;
		else if (arrow === '>' && state.octave < 8) state.octave++;
		else if (L) state.length = parseFloat(L);
		else if (MLNSFB === 'B') BG = true;
		else if (MLNSFB === 'F') BG = false;
		else if (MLNSFB === 'L') state.lengthMod = 1;
		else if (MLNSFB === 'N') state.lengthMod = 0.875;
		else if (MLNSFB === 'S') state.lengthMod = 0.75;
		else if (P) play(0, parseFloat(P));
		else if (note) playNote(note, parseInt(duration, 10), dotMod);
		else if (N) play(PLAY_NOTES[parseInt(N, 10)]);
	}
...

Playing Notes

The play function adds the specified frequency to the current time offset of our oscillator and calculates the note duration taking into account the current note length set by the L command, the lenghtMod set by the ML, MS and MN commands, and the dotMod set by the . command.

...
	function setFreq(freq: number, offset: number) {
		oscillator.frequency.setValueAtTime(
			freq,
			audioCtx.currentTime + TIME,
		);
		TIME += offset;
	}

	function play(frequency: number, duration = state.length, dotMod = 1) {
		const offset = (240 / state.tempo) * (1 / duration) * dotMod;
		setFreq(frequency, offset * state.lengthMod);
		if (state.lengthMod !== 1)
			setFreq(0, offset * (1 - state.lengthMod));
	}

To play a note string, i.e. A, B#, C-, we use our note map PLAY_DATA to get its frequency in the current octave. We then need to calculate the timing offset of the note based on the duration and the . command flag passed to the function.

...
	function playNote(note: string, duration: number, dotMod: string) {
		const octave = PLAY_DATA[state.octave];
		play(
			octave[note],
			duration || state.length,
			dotMod === '.' ? 1.5 : dotMod === '..' ? 1.75 : 1,
		);
	}

Wrapping Up

Finally, once all the notes have been parsed and their frequencies timed into our oscillator, we can use the oscillator.start() method to send this data to our AudioContext output and start playing the notes.

We use the TIME variable to calculate the total time the sound should take, and stop the oscillator when this time has elapsed.

If the BG command is not used, we have to wait for the oscillator to finish before continuing the program execution. To do this, we use the onended event, which should fire immediately after TIME has elapsed.

...
	const totalTime = audioCtx.currentTime + TIME;
	oscillator.start();
	oscillator.stop(totalTime);
	if (!BG) await new Promise(resolve => (oscillator.onended = resolve));
},
Back to Main Page