6.031
6.031 — Software Construction
Spring 2022

Code for Reading 26

Download

You can also download a ZIP file containing this code.

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 '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(this.value === Math.floor(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';

/**
 * 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 'assert';

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

/**
 * 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 'assert';
import { Music } from './Music';
import { SequencePlayer } from './SequencePlayer';
import { Pitch } from './Pitch';
import { Instrument } from './Instrument';

/**
 * 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 'assert';
import { Music } from './Music';
import { SequencePlayer } from './SequencePlayer';

/**
 * 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 'assert';
import { Music } from './Music';
import { SequencePlayer } from './SequencePlayer';

/**
 * 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';
import { Pitch } from '../Pitch';
import { SequencePlayer } from '../SequencePlayer';
import { MidiSequencePlayer } from '../MidiSequencePlayer';

/**
 * Play an octave up and back down starting from middle C.
 */
export async function main(): Promise<void> {
    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');
}

if (require.main === module) {
    main();
}

ScaleMusic.ts

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

/**
 * Play an octave up and back down starting from middle C.
 */
export async function main(): Promise<void> {

    // 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');
    
}

if (require.main === module) {
    main();
}

RowYourBoatInitial.ts

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

/**
 * Play a song.
 */
export async function main(): Promise<void> {

    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');
    
}

if (require.main === module) {
    main();
}

SequencePlayer.ts

import assert from 'assert';
import { Instrument } from './Instrument';
import { Pitch } from './Pitch';

/**
 * 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 'assert';
import { performance } from 'perf_hooks';

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

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, `play()` 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();
        return new Promise((resolve) => {
            this.callbacks.set(this.doneCallback, resolve);
        });
    }

    /**
     * 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';
import { MidiSequencePlayer } from './MidiSequencePlayer';
import { Music } from './Music';

/**
 * 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();
}