React hooks and components for practising from a score, on @hiyve/music-performance: record a take, follow the performer on the sheet music as they play, grade the take, keep it, and write sight-reading exercises.
React hooks and a component for recording a take — unprocessed microphone audio plus MIDI from a connected instrument, on one clock — so a performance can be saved and analysed against a score later.
Press Record, play, press Stop. The finished take arrives in onTakeCompleted as a TakeBundle — a JSON record plus a WAV blob — and the component offers both as downloads.
useScore reads a .musicxml file (compressed .mxl is not supported yet), gradeTake compares the take's MIDI with the score, and TakeReport shows the result: accuracy, counts of correct / wrong / missing / extra notes, the tempo the performer held against the score's, and a per-note table with early/late timing.
useLivePerformance runs a LiveSession (from @hiyve/music-performance) on the audio — or the MIDI — as it is captured. Playing freely, every note is compared with the next one written and the session moves on, right or wrong; each expected note gets a verdict in snapshot.verdicts, keyed by score-note id: a quick provisional one about 150 ms after the attack, then a confirmed one about 300 ms after. snapshot.cursor and snapshot.next say where the performer is, snapshot.tempoBpm what tempo they are holding, snapshot.extras what was heard that the score does not explain. finish() grades the whole session as a PerformanceReport for TakeReport.
By default the session follows the performer's own tempo. To judge against a set one — a metronome's — add useMetronome and tell the live hook where the first beat falls and what the click sounds like, so a click the microphone hears through speakers is found during the count-in and taken out of the audio before it is read (snapshot.clickOffsetMs says how late the clicks arrive, and the beat is judged where they were heard; through headphones none is found and nothing is touched):
<TakeRecorder onRecordingStarted={({ nowMs }) => { live.reset(); conststarted=metronome.start(); // a one-bar count-in, then the piece if (started) { live.setTempo({ bpm:100, startTakeMs:nowMs() + (started.firstBeatPerformanceMs-performance.now()), nowMs, click: { ...started.click, countInBeats:started.countInBeats }, }); } }} onAudioChunk={live.pushAudio} onTakeCompleted={() => { metronome.stop(); live.finish(); }} />
Timing is then early or late against the beat, a note whose beat passes unplayed turns orange at once (and green if it arrives late), and the position line glides at the beat. How closely a note must sit on its beat is the strictness — 'relaxed', 'normal' (the default) or 'strict', windows of 150, 80 and 40 ms (timingStrictness): inside the window a note is simply on time; outside it the score shows where it was played, and the longer window also means a beat has to pass by more before an unplayed note is called missing. Without a set tempo timing is not judged at all — a note is right or wrong by its pitch. The clicks are noise rather than tones, so a microphone that hears them does not take them for notes — but headphones keep them out of the recording altogether.
On the score itself, liveNoteStyles(snapshot, options?) turns the verdicts into the noteStyles the MusicXmlPlayer from @hiyve/react-music-notation colours its note heads by — green, red and orange by default (the TakeReport colours), fainter while a verdict is still provisional — and, under a set tempo, gives every note played outside the strictness window (strictness, or an explicit markTimingAfterMs) a mark where it was actually played, beside the engraved head. live.position is the player's position line: on the note expected next while following the performer, and under a set tempo a line gliding at the beat, which the player animates between renders (livePositionQuarters(snapshot) gives the plain note position for other views). The same score file must be given to both: useScore for the engine that follows, and fileUrl for the engine that draws.
A practice page in one hook
usePracticeSession is recording, following and grading in one. Give it the score and how the take is paced, hand its styles and position to the score player, put the toolbar controls beside the player's own, and the take ends when the piece does — graded as surely as it can be: MIDI, then the audio, then real time.
The instrument chosen in the settings decides how the microphone is listened to (instrumentProfiles: keyboard, sustained, voice). A take ends on its own at ten minutes, and a free take that has heard nothing for two minutes ends too; maxTakeMs and idleEndMs change that.
Sight-reading exercises
SightReadingPanel is the settings of an exercise as a form; the page writes the tune with generateSightReading and loads it like any score. Every exercise has a code — its settings and seed in sixteen characters — shown in the panel and, with ExerciseCodeChip, beside the score. A take of the exercise keeps the code when the session is told which exercise is on the card, so a saved take can be tried again from its code later.
To try a saved take again, parseSightReadingCode(row.exercise) gives the settings back; a take saved before exercises had codes can still be found from its title and stored tune with recoverSightReadingSettings.
Results and history
useTakeHistory keeps graded takes in this browser — the numbers in a list, the marked score alongside. TakeScoreCard shows a take's score, MarkedScore its score marked note by note, and SavedTakesTable the takes kept, each with Try again.
useSaveTake uploads the WAV and the JSON record to the performer's file storage and links the two through their metadata, so either file leads to the other. It needs <HiyveProvider> with a signed-in user; no room connection is required. It is the one part of the package that needs @hiyve/react, and lives behind the /room entry so everything else works without it.
Opens a page with the recorder wired to your real MIDI keyboard and microphone, and the score drawn by @hiyve/react-music-notation's player. With the public opensheetmusicdisplay build the player renders and steps; to have its playback, tempo slider and per-instrument mixer as well — and to pace a take with the score's own playback — drop the engine build with audio into packages/react-music-performance/dev/public/vendor/opensheetmusicdisplay.min.js. That folder is git-ignored: the build is licensed to the app, not the SDK, and is never committed. The playground says which engine it found.
Features
Picks a MIDI input (one, all, or none), a microphone, and a capture profile
Records audio unprocessed by default — no echo cancellation, noise suppression, or automatic gain
Shows which processing flags the browser actually honoured, and warns when it kept some on
Live level meter, elapsed time, and MIDI event counter
Measures the take's own MIDI-to-audio offset from the notes played and stores it as take.alignment (shown after each take)
Hands audio chunks and MIDI events out as they arrive, so useLivePerformance can follow the score while recording
Downloads the take as WAV and JSON
Releases the microphone and MIDI ports if unmounted mid-take
usePracticeSession: a take that ends when the piece does, graded as surely as it can be, with the controls, the live readout and the score card to go with it
Sight-reading exercises with a code that makes the same tune again anywhere
Takes kept in the browser with their marked scores, and tried again from the list
Components
Component
Description
TakeRecorder
Device pickers, record/stop, level meter, processing-flag chips, and download buttons
TakeReport
A graded take: summary chips and a per-note table of verdicts and timing
RecordButton
Record and stop, with the time and the microphone level while a take runs; for a score player's transportActions
PacingSwitch
Free, metronome or the score's own playback; for a score player's toolbarActions
PracticeSettingsButton
A gear opening the instrument, microphone, MIDI input, capture profile, what is followed and the timing strictness
LiveStrip
What the session makes of a take as it goes: counts, where it is, whether clicks are being taken out, details a click away
TakeScoreCard
A take's score: the ring, a word on it, the numbers as pills, and Save / Try again / Back
ScoreRing
A ring filled to a score, with the percentage inside
ExerciseCodeChip
A sight-reading exercise's code; a click copies it
MarkedScore
The score as it was played, every note coloured by the final grade, with a switch for the timing marks
SavedTakesTable
The takes kept, newest first, each with Try again
SightReadingPanel
The settings of a sight-reading exercise, with its code
Hooks
Hook
Description
useTakeRecorder(options)
The capture flow: start(), stop(), status, elapsed time, MIDI count, level, applied settings, and the finished bundle
useMidiInputs()
Connected MIDI inputs, refreshed when devices change
useAudioInputs()
Connected microphones, refreshed when devices change
useSaveTake(options) — from /room
Store takes in the performer's file storage: saveTake(bundle), isSaving, lastSaved, error
useScore(options)
Load a MusicXML file or text: score, name, takeScore (for TakeRecorder), loadFile, loadText, error
useLivePerformance(options)
Follow the score while recording: pushAudio / pushMidi (wire to the recorder), snapshot with a verdict per note and the cursor, setTempo() to judge against a set tempo, finish() → report
useMetronome(options)
A click track with a count-in: start() (returns when the first beat falls), stop(), beat, bar
usePracticeSession(options)
Recording, following and grading in one: recorder, live, metronome, latest (the graded take), takeNote, noteStyles and position for the score player, handlePlaybackEnded, clearLatest
useTakeHistory(options)
The takes kept in this browser: results, save(take), update(id, patch), clear(), getStoredScore(id)
useScoreUrl(musicxml)
MusicXML text as a URL the score player can fetch, released a while after it changes
TakeReport Props
Prop
Type
Default
Description
report
PerformanceReport
required
From gradeTake or matchPerformance
showTable
boolean
true
Show the per-note table under the summary
onTimeThresholdMs
number
30
Deviations under this read as "on time"
sx
SxProps<Theme>
—
Root styling
labels
Partial<TakeReportLabels>
—
Label overrides (every string, plus formatNote)
colors
Partial<TakeReportColors>
—
Verdict colours: correct, wrong, missing, extra
Utilities
Function
Description
downloadTakeAudio(bundle)
Save the WAV through the browser's download flow; returns false if the take has no audio
downloadTakeJson(bundle)
Save the JSON record
toTakeFiles(bundle)
The take as File objects (<id>.wav, <id>.take.json) for your own upload path
liveNoteStyles(snapshot, options?)
Note-head styles for the score viewer (MusicXmlPlayer's noteStyles), one per judged note, coloured by verdict, fainter while provisional, and under a set tempo with a mark where a note outside the strictness window was played; a wrong note shows the note that was played in its place; options: colors, strictness ('normal'), markTimingAfterMs, playedNotes (true)
timingStrictness / strictnessLevel(name?)
What each strictness level allows: { toleranceMs, missAfterMs } for relaxed (150 / 600), normal (80 / 400) and strict (40 / 250)
reportNoteStyles(report, options?)
The same styles for a finished grade — gradeTake, gradeTakeFromAudio or gradeTakeFinal's report — to show the score with its markings once the take is in; marks timing only given bpm, the set tempo the take was played to; a wrong note shows the note that was played in its place unless playedNotes is false
playedPitchOf(playedMidi, expected)
How the note played in a wrong note's place is written beside it: its own letter, or the expected note sharpened when above and flattened when below, with a natural sign when it is the expected note's letter with the accidental off
livePositionQuarters(snapshot)
Where the score viewer's position mark belongs: the note expected next, or the beat under a set tempo
noteKeyOf(note)
The score viewer's key for a score note
gradeFinishedTake(options)
Grade a finished take as surely as it can be — MIDI, then audio, then real time — timed against the beat as it was heard under a set tempo: { final, report, scored, markings }
ScoreRing takes value (0–1 or null), size (150), label ("score") and the good / fair / poor colours; ExerciseCodeChip takes code, sx and labels (copy, copied).
MarkedScore Props
Prop
Type
Default
Description
view
TakeResultView
required
The take shown; nothing is drawn without its music, markings and source
loadEngine
NotationEngineLoader
required
Loads the notation engine for the score viewer
showTimingMarks
boolean
true
Draw a mark where each early or late note was played; given, the switch follows it
A PracticeSettings holds profile, midiSelection ('all', 'none' or an input id), audioSelection ('default' or a device id), liveFromMidi, strictness and instrument; defaultPracticeSettings is the place to start and practiceRecorderOptions turns one into recorder options.
useSaveTake Options (/room entry)
Option
Type
Default
Description
location
string
'/Takes'
Storage folder
audioResourceType
string
'audio'
Resource type for the WAV
takeResourceType
string
'data'
Resource type for the JSON record
userId
string
signed-in user
Upload as this user when the file list has not been loaded yet
retry
SaveRetryOptions
3 retries on network errors
Retry behaviour
onSaved
(saved: SavedTake) => void
—
Both files stored and linked
onError
(error: Error) => void
—
Save failed after retries
The stored JSON record carries metadata (takeId, audioFileId, scoreName, durationMs, noteOnCount, midiToAudioOffsetMs, score, gradeSource) and the WAV carries takeId and takeFileId. saveTake(bundle, { grade: takeGradeOf(take) }) writes the take's grade into the record itself, so the file says how the take went.
TakeRecorder Props
Prop
Type
Default
Description
score
TakeScoreReference
—
The score the take is performed against, stored with the take
defaultProfile
'voice' | 'music' | 'instrument'
'instrument'
Initial capture profile
defaultMidiInputId
string
all inputs
Initially selected MIDI input
defaultAudioDeviceId
string
browser default
Initially selected microphone
notes
string
—
Free-text notes stored with the take
showMidiSelector
boolean
true
Show the MIDI picker
showAudioSelector
boolean
true
Show the microphone picker
showProfileSelector
boolean
true
Show the profile picker
enableDownload
boolean
true
Offer download buttons after a take
showAlignment
boolean
true
Show the measured MIDI-to-audio offset after a take
onRecordingStarted
(take: { nowMs: () => number }) => void
—
Capture is running; nowMs reads the take's clock
onAudioChunk
(chunk: AudioChunk) => void
—
Every captured audio chunk as it arrives, for useLivePerformance
onMidiEvent
(event: MidiEvent) => void
—
Every MIDI event as it arrives, for useLivePerformance
onTakeCompleted
(bundle: TakeBundle) => void
—
The finished take
onError
(error: Error) => void
—
Microphone, MIDI, and audio-context failures
sx
SxProps<Theme>
—
Root styling
labels
Partial<TakeRecorderLabels>
—
Label overrides
colors
Partial<TakeRecorderColors>
—
Color overrides
icons
Partial<TakeRecorderIcons>
—
Icon overrides
useTakeRecorder Options
Option
Type
Default
Description
profile
'voice' | 'music' | 'instrument'
'instrument'
How the microphone is opened
captureMidi
boolean
true
Record MIDI when the browser supports it
midiInputId
string
all inputs
Record from one input only
audioDeviceId
string
browser default
Microphone to use
score
TakeScoreReference
—
Stored with the take
notes
string
—
Stored with the take
alignTake
boolean
true
Measure the MIDI-to-audio offset from the take's notes and store it as take.alignment
maxDurationMs
number
no limit
Stop the take on its own at this length. A take holds every sample until it ends — about 11 MB a minute per channel at 48 kHz, and as much again to build the take — so one left running fills memory until the browser refuses it
onRecordingStarted
(take: { nowMs: () => number }) => void
—
Capture is running; nowMs reads the take's clock
onAudioChunk
(chunk: AudioChunk) => void
—
Every captured audio chunk as it arrives: channels, sampleRate, frame, timeMs
onMidiEvent
(event: MidiEvent) => void
—
Every MIDI event as it arrives
onTakeCompleted
(bundle: TakeBundle) => void
—
The finished take
onError
(error: Error) => void
—
Failures
Return value
Property
Type
Description
status
'idle' | 'starting' | 'recording' | 'stopping'
Lifecycle
isRecording
boolean
status === 'recording'
start
() => Promise<void>
Start capture — call from a click so audio is allowed to run
stop
() => Promise<TakeBundle | null>
Stop and build the take
elapsedMs
number
Time since recording started
nowMs
() => number
The take's clock, exact; 0 when not recording
midiEventCount
number
Events recorded so far
level
number
Microphone level 0–1
settings
AudioCaptureSettings | null
What the browser applied
comparison
CaptureSettingsComparison | null
Which flags were honoured
bundle
TakeBundle | null
The most recent take
error
Error | null
The most recent failure
isMidiSupported / isAudioSupported
boolean
Browser capability
useLivePerformance Options
Option
Type
Default
Description
score
Score | null
—
The score to follow; nothing happens until one is set
source
'audio' | 'midi'
'audio'
Which input to listen to; the other is ignored so nothing is counted twice
Analysis, quick-look, follower and chord-window settings
strictSequence
boolean
true
Without a set tempo, compare every note with the next one written and move on, right or wrong — nothing is held for the next note, and a rolled chord is one chord. Under a set tempo the follower always searches, so a performer who stops is picked up at the beat they rejoin
onUpdate
(update: LiveUpdate) => void
—
Every change, with the attack concerned and a full snapshot
onError
(error: Error) => void
—
A listener threw, or audio could not be consumed
Return value
Property
Type
Description
snapshot
LiveSnapshot | null
Cursor, next event, verdicts by note id, extras, attacks, tempo; null before the first input
isActive
boolean
Input has arrived and finish() has not been called
report
PerformanceReport | null
The graded session, once finished
tempo
LiveTempoSetting | null
The set tempo the next session is judged against
position
MusicXmlPlayerPosition | null
For MusicXmlPlayer's position: the note expected next, or under a set tempo a line gliding at the beat
Judge the session against a set tempo, or follow the performer again. startTakeMs is the take-clock time of a known score position — the first beat, or atQuarters quarter notes in, for a backing track's clock read once it runs. Applies to the next session, and to the current one while it has placed nothing yet. heardLateMs shifts the clock by how much later than scheduled the beat is heard — the output's latency, which useMetronome's start() reports — so playing to what you hear is on time; strictness sets how long a beat may pass unplayed; with nowMs (the recorder's clock) the beat keeps moving between notes; with click (the metronome's, from start()) clicks the microphone hears are taken out of the audio
pushAudio
(chunk: AudioChunk) => void
Wire to onAudioChunk
pushMidi
(event: MidiEvent) => void
Wire to onMidiEvent
finish
() => PerformanceReport | null
End and grade the session; the next input starts a new one
reset
() => void
Discard the session and its results
estimateQuartersAt
(timeMs: number) => number | null
Where the running tempo places a stream time, for a smooth cursor
A session starts with the first input after a score is set and after each finish() or reset(), so one hook serves take after take; changing the score, region or source discards the current one.
useMetronome Options
Option
Type
Default
Description
bpm
number
—
Quarter notes per minute
beatsPerBar
number
4
Beats in a bar; the first is accented
countInBars
number
1
Bars of clicks before the first beat of the piece
accentFirstBeat
boolean
true
Accent the first beat of each bar
volume
number
0.5
Click level, 0–1
onError
(error: Error) => void
—
Audio could not be started
Return value
Property
Type
Description
isRunning
boolean
Clicks are sounding
beat / bar
number | null
The beat within the bar (1-based) and the bar as each click sounds; bars are negative during the count-in
start
() => MetronomeStart | null
Start the clicks (from a user gesture); returns when the first count-in click and the first beat of the piece fall on the performance.now() clock, how many clicks the count-in has, the click itself and the output's latency for setTempo, or null without Web Audio
stop
() => void
Silence the clicks
isSupported
boolean
Whether this browser has Web Audio
Customization
Every visible string is a label; pass a partial object to override any of them.
@mui/material and @mui/icons-material v9 with Emotion
@hiyve/music-performance 0.1 or later and @hiyve/react-music-notation 0.2 or later (with a notation engine of your own); @hiyve/utilities and @hiyve/rtc-client
A browser with getUserMedia and AudioWorklet; Web MIDI for MIDI capture (not available in Safari)
TakeRecorder, useTakeRecorder, useMidiInputs and useAudioInputs work without HiyveProvider; the component opens its own microphone track, so use it when no call is in progress.
useSaveTake, from the /room entry, must be inside <HiyveProvider> with a signed-in user. @hiyve/react is needed for that entry only.
MarkedScore draws the score with @hiyve/react-music-notation's player, so it needs a notation engine through loadEngine, as the player does.