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.
The following PLAY commands are supported:
.
..
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 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,
};
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);
...
}
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)]);
}
...
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,
);
}
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));
},