React hooks and components for music theory drills: a note, an interval, a chord, a key signature or a scale on the staff to name — or heard first and named by ear — the answers to choose from — or the answer played on an instrument — the question played once answered, and the score at the end. The questions come from @hiyve/music-theory; the staff is engraved by the notation engine you supply, as with @hiyve/react-music-notation.
npm install @hiyve/react-music-theory @hiyve/music-theory @hiyve/react-music-notation
Peer dependencies: react, react-dom, @mui/material, @mui/icons-material, @emotion/react, @emotion/styled.
import { useState } from 'react';
import { defaultTheoryDrillSettings, generateTheoryQuestion, newSeed } from '@hiyve/music-theory';
import { useDrill, DrillCard, DrillScoreCard, TheoryDrillPanel } from '@hiyve/react-music-theory';
const loadEngine = () => import('opensheetmusicdisplay');
function TheoryDrill() {
const [settings, setSettings] = useState(defaultTheoryDrillSettings);
const [length, setLength] = useState(10);
const [seed, setSeed] = useState(() => newSeed());
const drill = useDrill({ generate: (random) => generateTheoryQuestion(settings, random), length, seed });
return (
<>
<TheoryDrillPanel settings={settings} onChange={setSettings} length={length} onLengthChange={setLength} onStart={() => setSeed(newSeed())} locked={!drill.finished && drill.summary.answered > 0} />
{drill.round ? (
<DrillCard round={drill.round} index={drill.index} total={drill.total} summary={drill.summary} loadEngine={loadEngine} onAnswer={drill.answer} onNext={drill.next} />
) : (
<DrillScoreCard summary={drill.summary} onAgain={() => drill.restart()} onAnother={() => setSeed(newSeed())} />
)}
</>
);
}
A drill is drawn from its seed, so "Try the same one again" asks the same questions and a new seed asks new ones. Each question is played as an answer is chosen — a chord at once, a melodic interval or a scale note by note — so it is heard as well as seen, and the card keeps the score as the drill goes. Answers show by name: "Major 3rd", "Half-diminished 7th", "Dorian", "E♭ major".
Turn on the panel's By ear switch — or set byEar: true in a drill's settings — and every question of notes, intervals, chords and scales is heard before it is seen. DrillCard plays it as it appears, with the staff hidden and a button to hear it again; a note is played after a C, to name it from ("First you hear C, then the question"). Key signatures are read — there is nothing in a signature to hear — so the switch is not offered for them. Once answered, the staff shows and the question plays again, each note lit as it sounds. No two answers offered sound alike, so F♯ and G♭, or an augmented 4th and a diminished 5th, are never both there.
The first question plays as the card appears, after the click that started the drill; if the browser keeps the sound from starting, the "Hear it again" button plays it.
Turn on the panel's Play the answer switch — or set playAnswer: true in a drill's settings, for any kind — and each question is answered on an instrument instead of by choosing. DrillCard says what to play ("Play or sing this note. Then the Major 3rd above it.", "Play the Minor chord in first inversion, with this note at the bottom."), shows and sounds the note to start from — a note to read and play is shown alone, giving nothing away — and once answered shows the notes to play marked as a graded take's are — each coloured by how it was played, with the note played in a wrong one's place drawn beside it. With By ear on too, the question is heard and played back.
The listening itself is PlayDrillCard, from @hiyve/react-music-performance/theory: it hears the playing through the microphone or a MIDI instrument, with the same note following as a practice take, and judges it note by note. A card of your own can listen any other way through DrillCard's answerArea, noteStyles and onSoundingChange; without an answerArea, a question set to be played is answered by choosing, like any other.
| Hook | Description |
|---|---|
useDrill({ generate, length?, seed? }) |
A drill in a component: drill, round, question, index, total, length (the questions it was drawn with), finished, summary, and answer(choice, correct?), next(), finish(), restart({ seed?, length? }). finish() ends the drill early, its summary of the rounds answered; restart() then asks all of it again. The drill starts anew whenever seed or length change; the time each answer takes is recorded with it. correct is for an answer judged elsewhere — one played on an instrument |
useNoteSynth({ gain?, durationMs?, samples?, sampleWaitMs?, onError? }) |
Sound notes — on recorded samples when samples names a host, otherwise with the built-in voice: play(midi | midi[], durationMs?, listener?) at once, playInOrder(midis, stepMs?, durationMs?, listener?) one after another, preload(midis), supported, and needsGesture. needsGesture is true when the browser held the sound back until a tap, as iOS can, so nothing was heard; play again from a tap. Both plays return a function that stops them early; the optional NoteSynthListener (from @hiyve/music-theory) is told as each note starts and stops sounding, to light it on the staff. The sound starts on the first play — call it from a click, as browsers require — and stops when the component goes, after a ringing note finishes; without Web Audio nothing plays |
| Component | Description |
|---|---|
TheoryDrillPanel |
The settings of a drill: which drill — notes, intervals, chords, key signatures or scales — whether it is asked by ear (all but key signatures) and answered by playing, the clef, and that drill's own options, the choices, the questions, and a Start button |
TheoryDrillGuide |
What the exercise chosen in the panel is and how it goes — numbered steps, as By ear and Play the answer are set — in plain words for anyone new to music; with a Start button when onStart is given. Made for the space beside the panel before an exercise starts |
DrillCard |
One round: where it is in the drill and the score so far, the staff — or, asked by ear, the sound first and the staff once answered — the answers, right or wrong once answered — with the note played as the choice is made and a button to hear it again — and the button to the next round. Answered by playing: what to play, the note to start from, and a panel of yours that listens. When the staff cannot be drawn, a question read off it waits, with a button to try again; when the browser holds the sound back until a tap, a question asked by ear asks for one |
DrillScoreCard |
How the drill went: the share right as a ring with a word on it, the count, the time per answer, the questions missed, and buttons for the same drill again or another |
StaffSnippet |
A question's one-bar MusicXML, engraved and nothing else, centred in its box, with any of its notes lit. A staff that cannot be drawn says so, with a button to try again |
AnswerChoices |
The answers to choose from, as buttons, shown as formatChoice writes them; the right one shows green and a wrong choice red once answered |
TheoryDrillGuide| Prop | Type | Default | Description |
|---|---|---|---|
settings |
TheoryDrillSettings |
required | The settings chosen in the panel: the guide follows them |
onStart |
() => void |
— | Start the exercise; the button shows when given |
panelLabels |
Partial<TheoryDrillPanelLabels> |
— | The panel's labels, when changed, so the guide names the exercise as the panel does |
sx, labels |
MUI sx; Partial<TheoryDrillGuideLabels> — the steps are one to a line |
<TheoryDrillPanel settings={settings} onChange={setSettings} length={length} onLengthChange={setLength} onStart={start} />
{!started && <TheoryDrillGuide settings={settings} onStart={start} />}
The default words are short and plain, for anyone new to music: "Two notes. How far apart are they?", "Play or sing your answer." drillHint(settings, labels) is the one line the panel and the guide both show for what an exercise asks.
TheoryDrillPanel| Prop | Type | Default | Description |
|---|---|---|---|
settings |
TheoryDrillSettings |
required | Every drill's settings and the drill asked, from @hiyve/music-theory (defaultTheoryDrillSettings) |
onChange |
(next: TheoryDrillSettings) => void |
required | A setting changed. Each drill keeps its own settings; a change of clef, the By ear switch or the Play the answer switch moves them all (key signatures are always read). Answered by playing, there are no choices to count |
length / onLengthChange |
number / (length) => void |
required | Questions in a drill |
lengthChoices |
number[] |
[5, 10, 20] |
Lengths offered |
onStart |
() => void |
— | The Start button, shown when given |
locked |
boolean |
false |
A drill is running: the settings hold still |
names |
TheoryNamesOverrides |
— | The names the intervals, chords and scales are listed by |
sx, labels |
MUI sx; Partial<TheoryDrillPanelLabels> |
Each drill's options: notes — on the staff or out to the ledger lines, sharps and flats; intervals — which intervals, together or one note after the other, sharps and flats; chords — which qualities, inversions, sharps and flats; key signatures — up to how many sharps or flats, major or minor keys or both; scales — which scales, sharps and flats. For intervals, chords and scales, "sharps and flats" lets the notes they are built on be sharps and flats too. A list always keeps at least one entry.
DrillCard| Prop | Type | Default | Description |
|---|---|---|---|
round |
DrillRound |
required | The round being asked, from useDrill |
index / total |
number |
required | Where the round is in the drill |
loadEngine |
NotationEngineLoader |
required | Supplies the notation engine for the staff |
onAnswer |
(choice: string, correct?: boolean) => void |
required | A choice was made — or an answer played, with whether it was right — as useDrill's answer takes it |
onNext |
() => void |
required | Move on |
onFinish |
() => void |
— | End the drill early, keeping what was answered (useDrill's finish): a Finish button beside the score, until the last round is answered |
summary |
DrillSummary |
— | useDrill's summary: given, the card shows the running count of right and wrong answers and a bar for how far through the drill it is |
sound |
boolean |
true |
Play the question as a choice is made, with a "Hear it again" button once answered. Each note lights up on the staff while it sounds — one after another for a scale or a melodic interval, together for a chord. A question asked by ear is also played as it appears, its staff hidden until answered; with sound off, or without Web Audio, it shows its staff like any other |
samples |
SampleSource |
— | Play it on recorded samples — a piano, say — instead of the built-in voice; the question's notes download as it appears. See below |
onError |
(error: Error) => void |
— | The staff failed to draw or the sound failed to start |
children |
ReactNode |
— | Anything to show under the staff |
answerArea |
ReactNode |
— | For a question answered by playing: shown in place of the answer buttons — a panel that listens and calls onAnswer(played, correct). PlayDrillCard from @hiyve/react-music-performance/theory is one |
noteStyles |
ReadonlyMap<string, MusicXmlNoteStyle> |
— | Answered by playing: how the notes to play were played, drawn on the staff once answered — the note styles MusicXmlPlayer takes, keyed by musicXmlNoteKey; a graded take's (reportNoteStyles from @hiyve/react-music-performance) show the note played in a wrong one's place beside it |
onSoundingChange |
(sounding: boolean) => void |
— | Told as the card starts and stops sounding — the question, the note to start from — so a microphone can wait for it to fall quiet |
names |
TheoryNamesOverrides |
— | The names answers are shown by |
sx, labels, colors |
MUI sx; Partial<DrillCardLabels>; Partial<DrillColors> |
DrillScoreCard| Prop | Type | Default | Description |
|---|---|---|---|
summary |
DrillSummary |
required | From useDrill or drillSummary |
onAgain / onAnother |
() => void |
— | The buttons, shown when given |
actions |
ReactNode |
— | Buttons of your own in place of the usual row |
names |
TheoryNamesOverrides |
— | The names the questions missed are listed by, each with its notes |
sx, labels, colors |
MUI sx; Partial<DrillScoreCardLabels>; Partial<DrillColors> |
drillPraise(accuracy, labels) and drillTone(accuracy, colors) give the word and the colour a score earns.
StaffSnippet| Prop | Type | Default | Description |
|---|---|---|---|
musicxml |
string |
required | A question's musicxml |
loadEngine |
NotationEngineLoader |
required | Supplies the notation engine |
height |
number |
176 |
Height of the box the staff is centred in, in pixels |
zoom |
number |
1.1 |
Size of the staff relative to the engine's own |
highlight |
readonly number[] |
— | Notes to light, by their place in the question's notes: lowest first in a chord or harmonic interval, left to right otherwise |
highlightColor |
string |
'primary.main' |
Colour of a lit note: MUI palette path or CSS colour |
noteStyles |
ReadonlyMap<string, MusicXmlNoteStyle> |
— | Note styles as MusicXmlPlayer takes them — a colour, and the note played in a wrong one's place drawn beside it — drawn by @hiyve/react-music-notation's own note styling |
onError |
(error: Error) => void |
— | The engine or the score failed to load |
onStatusChange |
(status: StaffStatus) => void |
— | Told as the staff is drawn: 'drawing', then 'drawn', or 'failed' when it cannot be. A failed staff says so in its box, with a button to try again |
labels |
Partial<StaffSnippetLabels> |
— | unavailable and retry |
AnswerChoices| Prop | Type | Default | Description |
|---|---|---|---|
choices |
readonly string[] |
required | As the question gives them |
chosen |
string | null |
null |
The choice made |
answer |
string | null |
null |
The right answer, once the round is answered |
onChoose |
(choice: string) => void |
required | A choice was made |
disabled |
boolean |
false |
Nothing can be chosen |
formatChoice |
(choice: string) => string |
— | How a choice is shown on its button |
sx, labels, colors |
MUI sx; Partial<AnswerChoicesLabels>; right and wrong |
By default the notes play on a small built-in voice that needs no download. To hear a real piano, give DrillCard (or useNoteSynth) a sample host laid out like the midi-js-soundfonts collection:
const PIANO = { baseUrl: 'https://cdn.example.com/soundfonts/FluidR3_GM/' };
<DrillCard samples={PIANO} … />
The SDK ships no samples: you choose the host, and that set's licence applies — FluidR3_GM, for one, is Creative Commons Attribution 3.0 and asks to be credited, while 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. Each note is about 25 KB and is downloaded once, while its question is on screen. A note that cannot be fetched in time plays on the built-in voice, so a note always sounds. The layout and the SampleSource fields are described in the @hiyve/music-theory README.
Answers are codes (M3, halfDiminished7, dorian, D:major); the components show them by name. defaultTheoryNames holds the English names — intervals, chords and scales by code, and keyMajor / keyMinor templates with {tonic} — and names overrides any of them, name by name:
<DrillCard names={{ intervals: { P8: 'Perfect 8th' }, keyMinor: '{tonic}m' }} … />
Notes are named by their letters ("F♯"). names.note names them another way, such as solfège, wherever a note is named: in the answers, the key names, the note to start from, and the questions missed:
const SOLFEGE: Record<string, string> = { C: 'Do', D: 'Re', E: 'Mi', F: 'Fa', G: 'Sol', A: 'La', B: 'Si' };
<DrillCard names={{ note: (name) => SOLFEGE[name[0]] + name.slice(1) }} … />
For views of your own:
answerLabel(kind, answer, names) gives the name of any answer.noteLabel(pitch, names, octave?) gives a note's name.questionNotesText(question, names?) gives the notes behind a question ("C4 E4", "D F A").mergeTheoryNames(overrides?) fills in the rest.Every component takes labels. The drill components also take colors, as MUI palette paths or CSS colours:
right, wrong, good, fair, poorplaying: a note on the staff while it soundsstaff: behind the staff, which is drawn in black (default white)The text on a right or wrong answer is picked to read on its colour. The defaults are exported (defaultDrillCardLabels, defaultTheoryDrillPanelLabels, defaultDrillColors, …) with merge functions (mergeDrillCardLabels, mergeTheoryDrillPanelLabels, mergeTheoryDrillGuideLabels, mergeDrillColors, …). withClef(settings, clef), withByEar(settings, byEar), withPlayAnswer(settings, playAnswer), noteIdRangeOf(settings) and withNoteIdRange(settings, clef, range) are the helpers behind the panel's clef, by-ear, play and range choices.
loadEngine, as for @hiyve/react-music-notation's player. The public opensheetmusicdisplay build is enough: the drills only draw, and play their notes with their own voice.samples, the sample host must allow your page to fetch from it (CORS) and be allowed by your connect-src.
@hiyve/react-music-theory — React hooks and components for music theory drills: a note on the staff to name, the answers to choose from, and the score at the end.
Remarks
Example