# @hiyve/music-theory

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.

## Installation

```bash
npm install @hiyve/music-theory
```

## Quick start

```ts
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.

## Questions

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.

### By ear

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`.

```ts
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
```

- A note comes with a `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`.
- No two answers offered sound alike, since the ear cannot tell them apart: never F♯ with G♭, or an augmented 4th with a diminished 5th. Each sound keeps one name among the choices.
- A question asked by sight has `byEar: false` and no `reference`. Asking by ear changes nothing about drills asked by sight: the same seed asks the same questions as before.

### Answered by playing

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.

### Settings

`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.

### Building blocks

| 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 |

## Drills

| 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 |

## Sound

```ts
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 |

### Instrument samples

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`.

## Pitches

| 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 |

## Defaults

`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:

- the lists `intervalNames`, `chordQualities` and `scaleTypes`
- `clefRanges`: each clef's staff, and out to its second ledger line either side, as MIDI numbers

At 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.

## Requirements

- No runtime dependencies. ESM and CommonJS builds.
- `NoteSynth` needs Web Audio (a browser); everything else runs anywhere.
