Framework-agnostic music theory drills: questions to order — a note, an interval, a chord, a key signature or a scale on a staff to name, or heard first and named by ear, with the answers to choose from — or to play on an instrument — as plain data and MusicXML, seeded drills that ask them round by round, and a small sound engine to hear them. Works in any framework; @hiyve/react-music-theory is the React layer with the staff, the answer buttons and the score card.
npm install @hiyve/music-theory
import { defaultTheoryDrillSettings, generateTheoryQuestion, startDrill, answerDrill, nextQuestion, drillSummary } from '@hiyve/music-theory';
// Ten intervals on the treble staff, four answers to choose from each time.
const settings = { ...defaultTheoryDrillSettings, kind: 'interval' as const };
let drill = startDrill((random) => generateTheoryQuestion(settings, random), { length: 10, seed: 42 });
const { question } = drill.rounds[drill.index];
question.musicxml; // a one-bar score with the question on its staff — draw it with any MusicXML renderer
question.choices; // ['M3', 'P5', 'm3', 'P4'] — shuffled, the right one among them
question.notes; // the notes behind it, spelled: C4 and E4
question.sounds; // what it sounds like, as MIDI numbers: [60, 64]
drill = answerDrill(drill, 'M3', 1800); // judged against question.answer; the time is optional
drill = nextQuestion(drill);
drillSummary(drill); // { total, answered, correct, accuracy, meanElapsedMs, missed }
The same seed makes the same drill again, so a drill can be kept, shared or retried by its seed.
Every question has its kind, clef, answer, shuffled choices (the answer among them), the musicxml to draw, the notes it is made of, the sounds to play and whether they are heard together or inOrder (playback), whether it is asked by ear (byEar) and, if so, the reference note to name it from, and whether it is answered by playing (playAnswer) and, if so, what to play (play). Notes are always spelled by letter — a major third above E is G♯, never A♭ — and a question that would need a double sharp or flat is never asked.
| Kind | Function | Answer | Notes |
|---|---|---|---|
note |
generateNoteQuestion(settings, random) |
The note's name: "F♯", "B♭", "C" | The note |
interval |
generateIntervalQuestion(settings, random) |
m2 M2 m3 M3 P4 A4 d5 P5 m6 M6 m7 M7 P8 |
The two notes, together or one after the other |
chord |
generateChordQuestion(settings, random) |
major minor diminished augmented major7 dominant7 minor7 halfDiminished7 diminished7 |
The chord in root position or an inversion, with its root and inversion |
keySignature |
generateKeySignatureQuestion(settings, random) |
The key: D:major, F♯:minor |
The signature alone; it sounds as its tonic chord |
scale |
generateScaleQuestion(settings, random) |
major naturalMinor harmonicMinor melodicMinor dorian phrygian lydian mixolydian locrian majorPentatonic minorPentatonic |
The scale up to its octave, every accidental written, heard in order |
| any | generateTheoryQuestion(drillSettings, random) |
A question of the kind drillSettings.kind names, from that kind's settings |
random is the drill's source (createRandom(seed)); startDrill passes it. Each function throws when its settings leave nothing to ask — an interval too wide for the range, no mode chosen.
With both major and minor keys asked, a key signature's relative key — the other key it could equally be — is never offered against the right answer.
Notes, intervals, chords and scales can be asked by ear: set byEar: true in the kind's settings, and the question is meant to be heard before it is seen. Key signatures are always read — there is nothing in a signature to hear — so their settings have no byEar.
const q = generateNoteQuestion({ byEar: true, accidentals: true }, random);
q.byEar; // true
q.reference; // the C at or below the note, to play first: name the note from it
q.sounds; // then the note itself
reference: the C at or below it. Play it first, then the note, and the note is named from it. An interval, a chord or a scale is named from its own notes, so its reference is null.byEar: false and no reference. Asking by ear changes nothing about drills asked by sight: the same seed asks the same questions as before.Any kind can be answered on an instrument instead of by name: set playAnswer: true in its settings, and the question says what to play in play (PlayTask).
| Kind | Given (play.start) |
To play (play.notes) |
|---|---|---|
note |
Nothing: the note is on the staff | The note, as written |
interval |
The lower note | Both notes — one after the other, or together, as melodic says |
chord |
The lowest note: the root, or an inversion's bass | The chord, in its inversion |
keySignature |
The tonic, under the signature | The key's scale up from it: major, or natural minor |
scale |
The tonic | The scale up to its octave |
play.playback says whether the notes are played together or inOrder; play.musicxml is the notes to play as a one-bar score — to follow the playing against and to show once answered — and play.promptMusicxml what the staff shows before: the note given, or the note to read. Asked by ear as well, nothing is given or shown (start and promptMusicxml are null): the question is heard and played back. Judge the playing however suits — @hiyve/react-music-performance/theory does it from a microphone or a MIDI instrument — and record it with answerDrill(drill, played, elapsedMs, correct), where correct is how it went.
TheoryDrillSettings holds the settings of every kind and the kind asked, so a page can switch drills and find each as it was left (defaultTheoryDrillSettings).
| Kind | Settings | Defaults |
|---|---|---|
note (NoteIdSettings) |
clef, lowestMidi, highestMidi, accidentals, choiceCount, byEar, playAnswer |
treble staff (64–77), naturals, 4 choices |
interval (IntervalIdSettings) |
clef, lowestMidi, highestMidi, intervals, melodic, accidentals, choiceCount, byEar, playAnswer |
treble staff and ledger lines (57–84); M2, m3, M3, P4, P5, P8; together; from naturals |
chord (ChordIdSettings) |
clef, lowestMidi, highestMidi, qualities, inversions, accidentals, choiceCount, byEar, playAnswer |
57–84; the four triads; root position; on naturals |
keySignature (KeySignatureIdSettings) |
clef, maxAccidentals (0–7), modes, choiceCount, playAnswer |
up to 4 sharps or flats; major keys |
scale (ScaleIdSettings) |
clef, lowestMidi, highestMidi, scales, accidentals, choiceCount, byEar, playAnswer |
57–84; major and the three minors; from naturals |
Every kind is asked by sight unless byEar is set — a key signature always is — and answered by name unless playAnswer is set. The answers offered are drawn from the kind's own list — the intervals, qualities or scales chosen — so fewer are offered when fewer are chosen. accidentals lets a question start on a sharp or a flat.
| Function | Description |
|---|---|
noteIdCandidates(settings) |
Every note a note drill can show |
intervalAbove(lower, interval) / intervalSizes |
The note an interval above another, spelled; each interval's letters and semitones |
chordTones(root, quality) / invertChord(tones, inversion) / chordShapes |
A chord's tones in root position, in an inversion; each quality's shape |
keySignatureTonic(fifths, mode, clef?) / keyCode(tonic, mode) |
The tonic of the key a signature names, on the clef's staff; the key as an answer |
scaleNotes(tonic, scale) / scaleShapes |
A scale's notes up to its octave, spelled; each scale's shape |
choicesWith(random, answer, pool, count, soundOf?) |
The answer and others from a pool, shuffled, no duplicates; with soundOf (each answer's sound), no two that sound alike |
noteSnippetMusicXml({ clef, pitches, arrangement?, fifths? }) |
The one-bar score a question is drawn from: whole notes stacked as a chord ('chord', the default) or in turn ('sequence'), after a key signature when given — an accidental written only where the signature does not give it; with no notes, the clef and signature alone |
| Function | Description |
|---|---|
startDrill(generate, { length?, seed? }) |
A drill of length questions (default 10; a whole number from 1 to MAX_DRILL_LENGTH, 200), every one drawn up front from seed (default 1) |
answerDrill(drill, choice, elapsedMs?, correct?) |
The drill with the current round answered and judged; a round answered once stays answered. correct takes an answer judged elsewhere — one played — as it was judged, with choice what was played |
nextQuestion(drill) |
The drill moved on once the round is answered; after the last round it is finished. An unanswered round is not skipped |
finishDrill(drill) |
The drill ended now: the rounds answered are kept and the rest are not asked, so its summary is of what was done |
isDrillFinished(drill) |
Whether every round has been asked, or the drill was finished early |
drillRecord(drill, settings, length?) / restoreDrill(record) |
A finished drill kept small, to store, and brought back as it was finished. A record (DrillRecord) holds its version, settings, length and seed, and each answer with the round it answered. Restoring draws the questions again from the seed and gives each answer back to its own round. A record read back from storage may be anything: restoreDrill gives null for one that is not whole, is of another DRILL_RECORD_VERSION (whose questions may now be drawn differently), or whose settings cannot draw its questions. isDrillRecord(value) checks one without restoring it |
drillSummary(drill) |
{ total, answered, correct, accuracy, meanElapsedMs, missed }, so far or in the end |
checkAnswer(question, choice) |
Whether a choice is the question's right answer |
createRandom(seed) / newSeed() |
The seeded random source questions are drawn from, and a fresh seed for another drill |
import { NoteSynth } from '@hiyve/music-theory';
// Make the context from a user gesture. Without `samples`, a built-in voice plays.
const synth = new NoteSynth(new AudioContext(), {
samples: { baseUrl: 'https://cdn.example.com/soundfonts/FluidR3_GM/' }, // a piano
});
synth.play(60); // middle C, for 900 ms
synth.play([60, 64, 67]); // a chord
synth.playInOrder([60, 64], 500); // a melodic interval, half a second apart
// A question, as it is meant to be heard.
question.playback === 'inOrder' ? synth.playInOrder(question.sounds) : synth.play(question.sounds);
// Told as each note starts and stops sounding, by its place in the list — to light it on the staff.
const stop = synth.playInOrder(question.sounds, 500, undefined, {
onNoteStart: (index) => console.log('sounding', question.notes[index]),
onNoteEnd: (index) => console.log('done', question.notes[index]),
});
| Function | Description |
|---|---|
new NoteSynth(ctx, { gain?, durationMs?, samples?, sampleWaitMs?, onNeedsGesture?, onError? }) |
Sounds notes through Web Audio. When the browser holds the sound back until the page is tapped, as iOS does with sound not started from a tap, nothing is played or announced and onNeedsGesture is told: play again from a tap. onError hears an audio context that refuses to start. Sound plays on recorded samples when samples names a host, otherwise with a small built-in voice that needs no download. play(midi | midi[], durationMs?, listener?) sounds a note or a chord at once, playInOrder(midis, stepMs?, durationMs?, listener?) one after another (500 ms apart by default); both return a function that stops them early. A NoteSynthListener (onNoteStart(index), onNoteEnd(index)) is told as each note starts and stops sounding, in step with what is heard; a note cut short by the stop function ends at once. preload(midis) downloads samples ahead of time; context is the context given |
preloadSamples(source, midis) |
Download samples ahead of time, with no audio context needed — each file is fetched once per page |
sampleUrl(source, midi) |
The file a key is played from, or null when the host has none for it |
midiHz(midi) / isSoundSupported() |
The frequency of a MIDI note (A4 = 440 Hz); whether this environment has Web Audio |
The SDK ships no samples: you choose the host, and that set's licence applies. samples (SampleSource) points at a host laid out like the midi-js-soundfonts collection, with one file per key:
{baseUrl}{instrument}-{format}/{note}.{format} e.g. …/FluidR3_GM/acoustic_grand_piano-mp3/Db4.mp3
| Field | Default | Description |
|---|---|---|
baseUrl |
required | The soundfont's folder |
instrument |
'acoustic_grand_piano' |
The instrument's folder name |
format |
'mp3' |
mp3 or ogg |
lowestMidi / highestMidi |
21 / 108 (A0–C8) |
The keys the host has files for |
Black keys are written as flats (Db4). A key outside the range, a file that cannot be fetched, or a sample still downloading after sampleWaitMs (1200 ms) is played with the built-in voice instead, so a note always sounds. The FluidR3_GM set, for one, is released under Creative Commons Attribution 3.0 and asks to be credited; the University of Iowa Electronic Music Studios piano recordings may be used for any project without restrictions, once trimmed, encoded and hosted in this layout yourself. The host must allow your page to fetch from it (CORS) and be allowed by your connect-src.
| Function | Description |
|---|---|
pitch(step, alter, octave) / pitchMidi(step, alter, octave) |
A written pitch, and its MIDI number (middle C is 60) |
pitchName(pitch, { octave? }) |
"F♯", "B♭", "C" — with the octave when asked, "C4" |
spellMidi(midi, 'natural' | 'sharp' | 'flat') |
The written form of a MIDI number; null when a natural is asked for and the note has none |
spellAbove(from, letters, semitones) |
The note so many letters and semitones above another; null when it would need a double sharp or flat |
startingPitches(lowestMidi, highestMidi, accidentals) |
The notes a question may start from in a range |
noteNames(accidentals) / noteSteps |
Every name a note drill can ask for; the seven letters |
defaultTheoryDrillSettings and the settings of each kind, each with its merge function, which spreads the defaults first and the overrides after:
| Defaults | Merge |
|---|---|
defaultNoteIdSettings |
mergeNoteIdSettings |
defaultIntervalIdSettings |
mergeIntervalIdSettings |
defaultChordIdSettings |
mergeChordIdSettings |
defaultKeySignatureIdSettings |
mergeKeySignatureIdSettings |
defaultScaleIdSettings |
mergeScaleIdSettings |
defaultDrillOptions |
mergeDrillOptions, which also keeps the length to a whole number from 1 to MAX_DRILL_LENGTH |
Also exported:
intervalNames, chordQualities and scaleTypesclefRanges: each clef's staff, and out to its second ledger line either side, as MIDI numbersAt least two answers are always offered, whatever choiceCount asks for. melodicMinor is the ascending form of the melodic minor scale, with the raised sixth and seventh.
NoteSynth needs Web Audio (a browser); everything else runs anywhere.
@hiyve/music-theory — framework-agnostic music theory drills: questions to order, as data and MusicXML, with seeded drills that can be made again.
Remarks
question.play)Example