6.102
6.102 — Software Construction
Spring 2025

Code for Reading 19

Download

You can also download a ZIP file containing this code.

After downloading and unpacking the ZIP file:

  • npm install to install package dependencies;
  • npm run to see a list of the ways this code can be run.
    • For example, if npm run shows that there are scripts called foo and bar, you can run the code with either npm run foo or npm run bar.
    • Two conventional script names are test and start. If the code includes one of those script names, then you can omit run and just say npm test or npm start.
Seems to be necessary to have a triple-backtick block at the start of this page,
otherwise all the pre/code tags below aren't syntax highlighted.
So create a hidden one.

Pitch.ts

import assert from 'node:assert';

/**
 * Pitch is an immutable type representing the frequency of a musical note.
 * Standard music notation represents pitches by letters: A, B, C, ..., G.
 * Pitches can be sharp or flat, or whole octaves up or down from these
 * primitive generators.
 * 
 * <p> For example:
 * <br> new Pitch('C') makes middle C
 * <br> new Pitch('C').transpose(1) makes C-sharp
 * <br> new Pitch('E').transpose(-1) makes E-flat
 * <br> new Pitch('C').transpose(OCTAVE) makes high C
 * <br> new Pitch('C').transpose(-OCTAVE) makes low C
 */
 export class Pitch {

    private constructor(
        private readonly value: number
    ) {
        this.checkRep();
    }

    /*
     * Rep invariant: value is an integer.
     *
     * Abstraction function AF(value):
     *   AF(0),...,AF(11) map to middle C, C-sharp, D, ..., A, A-sharp, B.
     *   AF(i+12n) maps to n octaves above middle AF(i)
     *   AF(i-12n) maps to n octaves below middle AF(i)
     *
     * Safety from rep exposure:
     *   all fields are private and immutable.
     */

    private checkRep(): void {
        assert(Number.isInteger(this.value));
    }

    private static readonly SCALE: {[letter:string]: number} = {
            'A': 9,
            'B': 11,
            'C': 0,
            'D': 2,
            'E': 4,
            'F': 5,
            'G': 7,
    };

    private static readonly VALUE_TO_STRING: Array<string> = [
            "C", "^C", "D", "^D", "E", "F", "^F", "G", "^G", "A", "^A", "B"
    ];
    
    /**
     * Middle C.
     */
    public static readonly MIDDLE_C: Pitch = Pitch.make('C');

    /**
     * Number of pitches in an octave.
     */
    public static readonly OCTAVE: number = 12;

    /**
     * Make a Pitch named `letter` in the middle octave of the piano keyboard.
     * For example, new Pitch('C') constructs middle C.
     * 
     * @param letter must be in {'A',...,'G'}
     * @returns pitch of the given note in the middle octave
     */
    public static make(letter: string): Pitch {
        const value = Pitch.SCALE[letter];
        if (value === undefined) { throw new Error(letter + " must be in the range A-G"); }
        return new Pitch(value);
    }

    /**
     * @param semitonesUp must be integer
     * @returns pitch made by transposing this pitch by semitonesUp semitones;
     *          for example, middle C transposed by 12 semitones is high C, and
     *          E transposed by -1 semitones is E flat
     */
    public transpose(semitonesUp: number): Pitch {
        if (Math.floor(semitonesUp) !== semitonesUp) {
            throw new Error(semitonesUp + ' must be integer');
        }
        return new Pitch(this.value + semitonesUp);
    }

    /**
     * @param that Pitch to compare `this` with
     * @returns number of semitones between this and that; i.e., n such that
     *          that.transpose(n).equalValue(this)
     */
    public difference(that: Pitch): number {
        return this.value - that.value;
    }

    /**
     * @param that Pitch to compare `this` with
     * @returns true iff this and that represent the same pitch, must be an
     *          equivalence relation (reflexive, symmetric, and transitive)
     */
    public equalValue(that: Pitch): boolean {
        return this.value === that.value;
    }

    /**
     * @returns this pitch in abc music notation
     * @see "http://abcnotation.com/examples/"
     */
    public toString(): string {
        let suffix = "";
        let v = this.value;

        while (v < 0) {
            suffix += ",";
            v += Pitch.OCTAVE;
        }

        while (v >= Pitch.OCTAVE) {
            suffix += "'";
            v -= Pitch.OCTAVE;
        }

        return Pitch.VALUE_TO_STRING[v] + suffix;
    }
}

Instrument.ts


/**
 * Instrument represents a musical instrument.
 * 
 * These instruments are the 128 standard General MIDI Level 1 instruments.
 * See: http://www.midi.org/about-midi/gm/gm1sound.shtml
 */
 export enum Instrument {
    // Order is important in this enumeration because an instrument's
    // position must correspond to its MIDI program number.

    PIANO,
    BRIGHT_PIANO,
    ELECTRIC_GRAND,
    HONKY_TONK_PIANO,
    ELECTRIC_PIANO_1,
    ELECTRIC_PIANO_2,
    HARPSICHORD,
    CLAVINET,

    // ... omitting 120 more instruments here ...
}

Music.ts

import { SequencePlayer } from './SequencePlayer.js';

/**
 * Music represents a piece of music played by multiple instruments.
 */
export interface Music {

    /**
     * Total duration of this piece in beats.
     */
    duration: number;

    /**
     * Play this piece.
     * 
     * @param player player to play on
     * @param atBeat when to play
     */
    play(player: SequencePlayer, atBeat:number): void;

    /**
     * @param that Music to compare `this` with
     * @returns true iff this and that represent the same Music expression.
     *    Must be an equivalence relation (reflexive, symmetric, and transitive).
     */
    equalValue(that: Music): boolean;
}

MusicLanguage.ts

import assert from 'node:assert';

import { Pitch } from './Pitch.js';
import { Instrument } from './Instrument.js';
import { Note } from './Note.js';
import { Rest } from './Rest.js';
import { Concat } from './Concat.js';
import { Music } from './Music.js';

/**
 * MusicLanguage defines functions for constructing and manipulating Music expressions.
 */

////////////////////////////////////////////////////
// Factory functions
////////////////////////////////////////////////////

/**
 * Make Music from a string using a variant of abc notation
 *    (see http://abcnotation.com/examples/).
 * 
 * <p> The notation consists of whitespace-delimited symbols representing
 * either notes or rests. The vertical bar | may be used as a delimiter
 * for measures; notes() treats it as a space.
 * Grammar:
 * <pre>
 *     notes ::= symbol*
 *     symbol ::= . duration          // for a rest
 *              | pitch duration      // for a note
 *     pitch ::= accidental letter octave*
 *     accidental ::= empty string    // for natural,
 *                  | _               // for flat,
 *                  | ^               // for sharp
 *     letter ::= [A-G]
 *     octave ::= '                   // to raise one octave
 *              | ,                   // to lower one octave
 *     duration ::= empty string      // for 1-beat duration
 *                | /n                // for 1/n-beat duration
 *                | n                 // for n-beat duration
 *                | n/m               // for n/m-beat duration
 * </pre>
 * <p> Examples (assuming 4/4 common time, i.e. 4 beats per measure):
 *     C     quarter note, middle C
 *     A'2   half note, high A
 *     _D/2  eighth note, middle D flat
 * 
 * @param notes string of notes and rests in simplified abc notation given above
 * @param instr instrument to play the notes with
 * @returns the music in notes played by instr
 */
export function notes(notes: string, instr: Instrument): Music {
    let music: Music = rest(0);
    for (const sym of notes.split(/[\s|]+/)) {
        if (sym !== '') {
            music = concat(music, parseSymbol(sym, instr));
        }
    }
    return music;
}

/* Parse a symbol into a Note or a Rest. */
function parseSymbol(symbol: string, instr: Instrument): Music {
    const m = symbol.match(/^(?<pitch>[^\/0-9]+)(?<numerator>[0-9]+)?(?<denominator>\/[0-9]+)?$/);
    
    if ( ! (m && m.groups)) { throw new Error("couldn't understand " + symbol); }
    const { pitch, numerator, denominator } = m.groups;
    assert(pitch);

    let duration = 1.0;
    if (numerator) { duration *= parseInt(numerator); }
    if (denominator) { duration /= parseInt(denominator.substring(1)); }

    if (pitch === ".") {
        return rest(duration);
    } else {
        return note(duration, parsePitch(pitch), instr);
    }
}

/* Parse a symbol into a Pitch. */
function parsePitch(symbol: string): Pitch {
    if (symbol.endsWith("'")) {
        return parsePitch(symbol.substring(0, symbol.length-1)).transpose(Pitch.OCTAVE);
    } else if (symbol.endsWith(",")) {
        return parsePitch(symbol.substring(0, symbol.length-1)).transpose(-Pitch.OCTAVE);
    } else if (symbol.startsWith("^")) {
        return parsePitch(symbol.substring(1)).transpose(1);
    } else if (symbol.startsWith("_")) {
        return parsePitch(symbol.substring(1)).transpose(-1);
    } else if (symbol.length != 1) {
        throw new Error("can't understand " + symbol);
    } else {
        return Pitch.make(symbol.charAt(0));
    }
}

/**
 * @param duration duration in beats, must be >= 0
 * @param pitch pitch to play
 * @param instrument instrument to use
 * @returns pitch played by instrument for duration beats
 */
export function note(duration: number, pitch: Pitch, instrument: Instrument ): Music {
    return new Note(duration, pitch, instrument);
}

/**
 * @param duration duration in beats, must be >= 0
 * @returns rest that lasts for duration beats
 */
export function rest(duration: number): Music  {
    return new Rest(duration);
}

////////////////////////////////////////////////////
// Producers
////////////////////////////////////////////////////

/**
 * @param m1 first piece of music
 * @param m2 second piece of music
 * @returns m1 followed by m2
 */
export function concat(m1: Music, m2: Music): Music {
    return new Concat(m1, m2);
}

Note.ts

import assert from 'node:assert';
import { Music } from './Music.js';
import { SequencePlayer } from './SequencePlayer.js';
import { Pitch } from './Pitch.js';
import { Instrument } from './Instrument.js';

/**
 * Note represents a note played by an instrument.
 */
export class Note implements Music {

    /**
     * Make a Note played by instrument for duration beats.
     * 
     * @param duration duration in beats, must be >= 0
     * @param pitch pitch to play
     * @param instrument instrument to use
     */
    public constructor(
        public readonly duration: number,
        public readonly pitch: Pitch,
        public readonly instrument: Instrument
    ) {
        this.checkRep();
    }

    private checkRep(): void {
        assert(this.duration >= 0);
    }

    /**
     * Play this note.
     */
    public play(player: SequencePlayer, atBeat: number): void {
        player.addNote(this.instrument, this.pitch, atBeat, this.duration);
    }

    /**
     * @inheritdoc
     */
    public equalValue(that: Music): boolean {
        return (that instanceof Note) 
                && this.duration === that.duration
                && this.instrument === that.instrument
                && this.pitch.equalValue(that.pitch);
    }

    public toString(): string {
        return this.pitch.toString() + this.duration;
    }
}

Rest.ts

import assert from 'node:assert';
import { Music } from './Music.js';
import { SequencePlayer } from './SequencePlayer.js';

/**
 * Rest represents a pause in a piece of music.
 */
export class Rest implements Music {

    /**
     * Make a Rest that lasts for duration beats.
     * 
     * @param duration duration in beats, must be >= 0
     */
    public constructor(
        public readonly duration: number
    ) {
        this.checkRep();
    }

    private checkRep(): void {
        assert(this.duration >= 0);
    }

    /**
     * Play this rest.
     */
    public play(player: SequencePlayer, atBeat: number):void {
        return;
    }

    /**
     * @inheritdoc
     */
    public equalValue(that: Music): boolean {
        return (that instanceof Rest) 
                && this.duration === that.duration;
    }

    public toString(): string {
        return "." + this.duration;
    }
}

Concat.ts

import assert from 'node:assert';
import { Music } from './Music.js';
import { SequencePlayer } from './SequencePlayer.js';

/**
 * Concat represents two pieces of music played one after the other.
 */
export class Concat implements Music {

    /**
     * Make a Music sequence that plays first followed by second.
     * 
     * @param first music to play first
     * @param second music to play second
     */
    public constructor(
        public readonly first: Music,
        public readonly second: Music
    ) {
        this.checkRep();
    }

    private checkRep(): void {
    }

    /**
     * @returns duration of this concatenation
     */
    public get duration(): number {
        return this.first.duration + this.second.duration;
    }

    /**
     * Play this concatenation.
     */
    public play(player: SequencePlayer, atBeat: number): void {
        this.first.play(player, atBeat);
        this.second.play(player, atBeat + this.first.duration);
    }

    /**
     * @inheritdoc
     */
    public equalValue(that: Music): boolean {
        return (that instanceof Concat)
                && this.first.equalValue(that.first)
                && this.second.equalValue(that.second);
    }

    public toString(): string {
        return this.first + " " + this.second;
    }
}

ScaleSequence.ts

import { Instrument } from '../Instrument.js';
import { Pitch } from '../Pitch.js';
import { SequencePlayer } from '../SequencePlayer.js';
import { MidiSequencePlayer } from '../MidiSequencePlayer.js';

/*
 * Play an octave up and back down starting from middle C.
 */

const piano = Instrument.PIANO;

// create a new player
const beatsPerMinute = 120; // a beat is a quarter note, so this is 120 quarter notes per minute
const ticksPerBeat = 2;
const player: SequencePlayer = new MidiSequencePlayer(beatsPerMinute, ticksPerBeat);

// addNote(instr, pitch, startBeat, numBeats) schedules a note with pitch value 'pitch'
// played by 'instr' starting at 'startBeat' to be played for 'numBeats' beats.

const numBeats = 1;
let startBeat = 0;
player.addNote(piano, Pitch.make('C'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('D'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('E'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('F'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('G'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('A'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('B'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('C').transpose(Pitch.OCTAVE), startBeat++, numBeats);
player.addNote(piano, Pitch.make('B'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('A'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('G'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('F'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('E'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('D'), startBeat++, numBeats);
player.addNote(piano, Pitch.make('C'), startBeat++, numBeats);

// play!
console.log('playing now...');
await player.start();
console.log('playing done');

ScaleMusic.ts

import { Music } from '../Music.js';
import { notes } from '../MusicLanguage.js';
import { Instrument } from '../Instrument.js';
import { playOnMidiPlayer } from '../MusicPlayer.js';

/*
 * Play an octave up and back down starting from middle C.
 */

// parse simplified abc into a Music
const scale: Music = notes("C D E F G A B C' B A G F E D C", Instrument.PIANO);

console.log(scale.toString());

// play!
console.log('playing now...');
await playOnMidiPlayer(scale);
console.log('playing done');

RowYourBoatInitial.ts

import { Music } from '../Music.js';
import { notes } from '../MusicLanguage.js';
import { Instrument } from '../Instrument.js';
import { playOnMidiPlayer } from '../MusicPlayer.js';

/*
 * Play a song!
 */

const rowYourBoat: Music =
    notes("C C C3/4 D/4 E |"       // Row, row, row your boat,
        + "E3/4 D/4 E3/4 F/4 G2 |" // Gently down the stream.
        + "C'/3 C'/3 C'/3 G/3 G/3 G/3 E/3 E/3 E/3 C/3 C/3 C/3 |"
                                   // Merrily, merrily, merrily, merrily,
        + "G3/4 F/4 E3/4 D/4 C2",  // Life is but a dream.
        Instrument.PIANO);


console.log(rowYourBoat.toString());

// play!
console.log('playing now...');
await playOnMidiPlayer(rowYourBoat);
console.log('playing done');

SequencePlayer.ts

import assert from 'node:assert';
import { Instrument } from './Instrument.js';
import { Pitch } from './Pitch.js';

/**
 * Schedules and plays a sequence of notes at given times.
 */
export interface SequencePlayer {

    /**
     * Schedule a note to be played starting at startBeat for the duration numBeats.
     * 
     * @param instr instrument for the note
     * @param pitch pitch value of the note
     * @param startBeat the starting beat (while playing, must be now or in the future)
     * @param numBeats the number of beats the note is played
     */
    addNote(instr: Instrument, pitch: Pitch, startBeat: number, numBeats: number): void;

    /**
     * Play the scheduled music.
     * 
     * @returns (a promise that) resolves after the music has played
     */
    start(): Promise<void>;

}

MidiSequencePlayer.ts

import assert from 'node:assert';

import { Instrument } from './Instrument.js';
import { Pitch } from './Pitch.js';
import { SequencePlayer } from './SequencePlayer.js';

import JZZ from 'jzz';
import JZZ_MIDI_SMF from 'jzz-midi-smf';
import type { MTrk } from 'jzz-midi-smf';

JZZ_MIDI_SMF(JZZ);

const { MIDI } = JZZ;

/**
 * Default tempo.
 */
export const DEFAULT_BEATS_PER_MINUTE = 120;

/**
 * Default MIDI ticks per beat.
 */
export const DEFAULT_TICKS_PER_BEAT = 64;

// MIDI note number representing middle C
const MIDI_NOTE_MIDDLE_C = 60;

// MIDI marker meta message type
const MIDI_META_MARKER = 6;

/**
 * @returns the MIDI note number for a pitch, defined as the number of
 *          semitones above C 5 octaves below middle C; for example,
 *          middle C is note 60
 */
function getMidiNote(pitch: Pitch): number {
    return MIDI_NOTE_MIDDLE_C + pitch.difference(Pitch.MIDDLE_C);
}

/**
 * Schedules and plays a sequence of notes using the MIDI synthesizer.
 */
export class MidiSequencePlayer implements SequencePlayer {

    // queued MIDI events before playback begins
    private readonly queued: MTrk;
    
    // MIDI output device to handle callbacks
    private readonly midiOut;
    
    // time at which playback began, iff started
    private playStartTime: number|undefined;
    
    // counter of future MIDI events
    private unplayedEvents: number = 0;
    
    // instruments that have been used (selected into MIDI channels) so far
    // maps an instrument to a MIDI channel number
    private readonly channelForInstrument: Map<Instrument, number> = new Map();
    
    // event callback functions
    private readonly callbacks: Map<number, ()=>void> = new Map();
    private readonly doneCallback = -1;

    /**
     * Make a new MIDI sequence player.
     * 
     * @param beatsPerMinute number of beats per minute
     * @param ticksPerBeat number of MIDI ticks per beat
     */
    public constructor(
        private readonly beatsPerMinute: number = DEFAULT_BEATS_PER_MINUTE,
        private readonly ticksPerBeat: number = DEFAULT_TICKS_PER_BEAT
    ) {
        this.queued = new MIDI.SMF.MTrk();
        const thisMSP = this;
        this.midiOut = JZZ.Widget({
            _receive(this: ReturnType<typeof JZZ.Widget>, msg: typeof MIDI & { ff?: number }) {
                if (msg.isSMF() && msg.ff === MIDI_META_MARKER) {
                    // meta event indicates `addEvent(..)` callback
                    const id = parseInt(msg.getText());
                    const callback = thisMSP.callbacks.get(id);
                    if (callback) {
                        thisMSP.callbacks.delete(id);
                        callback();
                    }
                }
                // send this message to the MIDI output device
                this.emit(msg);
                if (--thisMSP.unplayedEvents <= 0) {
                    // no future events, `start()` is done
                    thisMSP.callbacks.get(thisMSP.doneCallback)?.();
                }
            }
        });
        this.checkRep();
    }
    
    private checkRep() {
        assert(this.beatsPerMinute >= 0);
        assert(this.ticksPerBeat >= 0);
    }

    /** @returns milliseconds since playback began (requires currently playing) */
    private msSincePlayStart(beat: number): number {
        assert(this.playStartTime !== undefined);
        return this.ms(beat) - (performance.now() - this.playStartTime);
    }

    /** @returns beats in milliseconds */
    private ms(beats: number): number {
        return beats / this.beatsPerMinute * 60 * 1000;
    }

    /**
     * @inheritdoc
     */
    public addNote(instr: Instrument, pitch: Pitch, startBeat: number, numBeats: number): void {
        const channel = this.getChannel(instr);
        const note = getMidiNote(pitch);

        if (this.playStartTime !== undefined) {
            const startTime = this.msSincePlayStart(startBeat);
            this.midiOut.wait(startTime).noteOn(channel, note);
            // TODO: (removing 1 ms avoids some timing glitches) find a better way to stream events
            this.midiOut.wait(startTime + this.ms(numBeats) - 1).noteOff(channel, note);
        } else {
            this.queued.add(startBeat * this.ticksPerBeat, MIDI.noteOn(channel, note));
            this.queued.add((startBeat + numBeats) * this.ticksPerBeat, MIDI.noteOff(channel, note));
        }
        this.unplayedEvents += 2;
    }

    public addEvent(callback: (beat:number)=>void, atBeat: number): void {
        let id = 0;
        while (this.callbacks.has(id)) { id++; }
        this.callbacks.set(id, () => callback(atBeat));
        if (this.playStartTime !== undefined) {
            this.midiOut.wait(this.msSincePlayStart(atBeat)).smfMarker(id.toString());
        } else {
            this.queued.add(atBeat * this.ticksPerBeat, MIDI.smfMarker(id.toString()));
        }
        this.unplayedEvents++;
    }

    /**
     * @inheritdoc
     */
    public async start(): Promise<void> {
        await this.midiOut.connect(await JZZ().openMidiOut().or('cannot open MIDI output port'));
        for (const midi of this.queued) {
            this.midiOut.wait(midi.tt / this.ticksPerBeat / this.beatsPerMinute * 60 * 1000).send(midi);
        }
        this.playStartTime = performance.now();
        const { promise, resolve } = Promise.withResolvers<void>();
        this.callbacks.set(this.doneCallback, resolve);
        return promise;
    }

    /**
     * Get a MIDI channel for the given instrument, allocating one if necessary.
     * 
     * @param instr instrument
     * @returns channel for the instrument
     */
    private getChannel(instr: Instrument): number {
        // check whether this instrument already has a channel
        let channel = this.channelForInstrument.get(instr);
        if (channel === undefined) {
            channel = this.channelForInstrument.size;
            // TODO: check whether nextChannel exceeds # of possible channels for this midi device
            if (this.playStartTime !== undefined) {
                this.midiOut.send(MIDI.program(channel, instr));
            } else {
                this.queued.add(0, MIDI.program(channel, instr));
            }
            this.unplayedEvents++;
            this.channelForInstrument.set(instr, channel);
        }
        return channel;
    }
}

MusicPlayer.ts

import { SequencePlayer } from './SequencePlayer.js';
import { MidiSequencePlayer } from './MidiSequencePlayer.js';
import { Music } from './Music.js';

/**
 * Play music with the MIDI synthesizer.
 * 
 * @param music music to play
 * @returns (a promise that) resolves after the music has played
 * @throws Error if MIDI device unavailable or MIDI play fails
 */
export function playOnMidiPlayer(music: Music): Promise<void> {
    const player: SequencePlayer = new MidiSequencePlayer();
    
    // load the player with a sequence created from music (add a small delay at the beginning)
    const warmup = 0.125;
    music.play(player, warmup);
    
    // start playing
    return player.start();
}