# @hiyve/react-music-performance

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.

## Quick Start

```tsx
import { TakeRecorder } from '@hiyve/react-music-performance';

function Practice() {
  return (
    <TakeRecorder
      score={{ name: 'Minuet in G' }}
      onTakeCompleted={(bundle) => save(bundle)}
      onError={(error) => console.error(error)}
    />
  );
}
```

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.

### Custom UI with hooks

```tsx
import { useTakeRecorder } from '@hiyve/react-music-performance';

function CustomRecorder() {
  const recorder = useTakeRecorder({ profile: 'instrument', onTakeCompleted: save });

  return (
    <button onClick={recorder.isRecording ? recorder.stop : recorder.start}>
      {recorder.isRecording ? 'Stop' : 'Record'}
    </button>
  );
}
```

### Grading a take against a score

```tsx
import { gradeTake } from '@hiyve/music-performance';
import { TakeRecorder, TakeReport, useScore } from '@hiyve/react-music-performance';

function Practice() {
  const { score, takeScore, loadFile, error } = useScore();
  const [report, setReport] = useState(null);

  return (
    <>
      <input type="file" accept=".musicxml,.xml" onChange={(e) => e.target.files?.[0] && void loadFile(e.target.files[0])} />
      {error && <p>{error.message}</p>}
      <TakeRecorder
        score={takeScore}
        onTakeCompleted={(bundle) => score && setReport(gradeTake(score, bundle.take))}
      />
      {report && <TakeReport report={report} />}
    </>
  );
}
```

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

### Live feedback while playing

```tsx
import { MusicXmlPlayer } from '@hiyve/react-music-notation';
import { TakeRecorder, TakeReport, liveNoteStyles, useLivePerformance, useScore } from '@hiyve/react-music-performance';

const loadEngine = () => import('opensheetmusicdisplay');

function Practice({ scoreUrl }: { scoreUrl: string }) {
  const { score, takeScore } = useScore();
  const live = useLivePerformance({ score, source: 'audio' }); // 'midi' for a MIDI instrument

  return (
    <>
      <TakeRecorder
        score={takeScore}
        onRecordingStarted={live.reset}
        onAudioChunk={live.pushAudio}
        onMidiEvent={live.pushMidi}
        onTakeCompleted={() => live.finish()}
      />
      <MusicXmlPlayer
        variant="inline"
        fileUrl={scoreUrl}
        loadEngine={loadEngine}
        noteStyles={liveNoteStyles(live.snapshot)}
        position={live.position}
      />
      {live.report && <TakeReport report={live.report} />}
    </>
  );
}
```

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

```tsx
const metronome = useMetronome({ bpm: 100, beatsPerBar: 4, countInBars: 1 });
const live = useLivePerformance({ score });

<TakeRecorder
  onRecordingStarted={({ nowMs }) => {
    live.reset();
    const started = 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.

```tsx
import { useRef, useState } from 'react';
import { MusicXmlPlayer } from '@hiyve/react-music-notation';
import type { MusicXmlPlayerHandle } from '@hiyve/react-music-notation';
import {
  LiveStrip,
  PacingSwitch,
  PracticeSettingsButton,
  RecordButton,
  TakeScoreCard,
  defaultPracticeSettings,
  practiceRecorderOptions,
  takeResultView,
  useAudioInputs,
  useMidiInputs,
  usePracticeSession,
  useScore,
  useScoreUrl,
  useTakeHistory,
} from '@hiyve/react-music-performance';
import type { PacingMode } from '@hiyve/react-music-performance';

function PracticePage({ loadEngine }: { loadEngine: NotationEngineLoader }) {
  const { score, name, musicxml } = useScore();
  const url = useScoreUrl(musicxml);
  const playerRef = useRef<MusicXmlPlayerHandle>(null);
  const [mode, setMode] = useState<PacingMode>('free');
  const [settings, setSettings] = useState(defaultPracticeSettings);
  const history = useTakeHistory();
  const midiInputs = useMidiInputs();
  const audioInputs = useAudioInputs();
  const session = usePracticeSession({
    score,
    musicxml,
    title: name,
    mode,
    bpm: 100,
    strictness: settings.strictness,
    instrument: settings.instrument,
    liveFromMidi: settings.liveFromMidi,
    ...practiceRecorderOptions(settings),
    playerRef,
    onTakeGraded: history.save,
  });
  return (
    <>
      {url && (
        <MusicXmlPlayer
          ref={playerRef}
          fileUrl={url}
          loadEngine={loadEngine}
          noteStyles={session.noteStyles}
          position={session.position}
          transportActions={<RecordButton recorder={session.recorder} />}
          toolbarActions={
            <>
              <PacingSwitch mode={mode} onChange={setMode} disabled={session.recorder.isRecording} />
              <PracticeSettingsButton
                settings={settings}
                onChange={(next) => setSettings((current) => ({ ...current, ...next }))}
                recorder={session.recorder}
                midiInputs={midiInputs}
                audioInputs={audioInputs}
                paced={mode !== 'free'}
              />
            </>
          }
          onPlaybackEnded={session.handlePlaybackEnded}
        />
      )}
      {session.live.snapshot && <LiveStrip snapshot={session.live.snapshot} expectClicks={mode === 'metronome'} note={session.takeNote} />}
      {session.latest && <TakeScoreCard view={takeResultView(session.latest)} saved />}
    </>
  );
}
```

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.

```tsx
const [settings, setSettings] = useState(defaultSightReadingSettings);
const exercise = useMemo(() => generateSightReading({ ...settings, tempoBpm: 80 }), [settings]);
useEffect(() => {
  loadText(exercise.musicxml, exercise.title);
}, [exercise, loadText]);
const session = usePracticeSession({ …, exercise: { code: exercise.code, musicxml: exercise.musicxml } });

<SightReadingPanel settings={settings} onChange={setSettings} locked={session.recorder.isRecording} />
<ExerciseCodeChip code={exercise.code} />
```

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.

```tsx
const history = useTakeHistory();
const [picked, setPicked] = useState<{ row: SavedTakeResult; stored?: StoredTakeScore } | null>(null);
const view = picked ? savedTakeResultView(picked.row, picked.stored) : session.latest ? takeResultView(session.latest) : null;

{view && <TakeScoreCard view={view} saved={picked !== null} onTryAgain={() => tryAgain(view)} />}
{view?.musicxml && <MarkedScore view={view} loadEngine={loadEngine} />}
<SavedTakesTable
  rows={history.results}
  selectedId={picked?.row.id ?? null}
  onOpen={(row) => void history.getStoredScore(row.id).then((stored) => setPicked({ row, stored }))}
  onTryAgain={(target) => tryAgain(target)}
  onClear={history.clear}
/>
```

### Saving takes to a room's file storage — the `/room` entry

```tsx
import { TakeRecorder } from '@hiyve/react-music-performance';
import { useSaveTake } from '@hiyve/react-music-performance/room';

function Practice() {
  const { saveTake, isSaving, lastSaved } = useSaveTake({ location: '/Takes' });

  return (
    <>
      <TakeRecorder onTakeCompleted={(bundle) => void saveTake(bundle)} />
      {isSaving && <p>Saving…</p>}
      {lastSaved && <p>Saved as {lastSaved.takeFileId}</p>}
    </>
  );
}
```

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

## Try it

```bash
pnpm --filter @hiyve/react-music-performance playground
```

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 }` |
| `takeResultView(take)` / `savedTakeResultView(row, stored?)` | What `TakeScoreCard` and `MarkedScore` show of the latest take, or of one kept |
| `savedTakeResultFrom(take)` | A graded take as it is kept between visits, or `null` when there was nothing to grade from |
| `takeGradeOf(take)` | A take's grade as it goes into its record when saved with `useSaveTake` |
| `withoutTimingMarks(markings)` | The same markings without the place each note was played |
| `practiceRecorderOptions(settings)` | The recorder options a `PracticeSettings` stands for, for `usePracticeSession` |
| `tryAgainTargetOf(row)` | A saved take as something to have another go at |
| `instrumentProfiles` / `instrumentFamilies` | How each family of instrument is listened to, and the families in order |
| `defaultSightReadingSettings()` / `newSightReadingSeed()` | The settings to start with, and a fresh seed |
| `recoverSightReadingSettings(title, musicxml)` | The settings of a sight-reading take saved before exercises had codes, or `null` |
| `sightReadingClefBase` / `sightReadingRanges` / `sightReadingRhythmsByLevel` | The bottom of each clef's range, the ranges offered, and the rhythms each level allows |
| `scoreTone(value, colors)` / `scorePraise(value, labels)` | The colour and the word a score earns |
| `formatWhenDefault(date)` / `formatElapsedShort(ms)` | "Today 6:17 pm" and `m:ss` |

## usePracticeSession Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `score` | `Score \| null` | **required** | The score being practised; nothing records until one is set |
| `musicxml` | `string \| null` | **required** | The score's MusicXML, kept with each take |
| `title` | `string \| null` | **required** | The piece's name, kept with each take |
| `mode` | `PacingMode` | **required** | `'free'`, `'metronome'` or `'playback'` |
| `bpm` | `number` | **required** | For the metronome and the clock the take is judged against |
| `strictness` | `LiveStrictness` | `'normal'` | How closely a note must sit on its beat under a set tempo |
| `instrument` | `InstrumentFamily` | `'keyboard'` | How the microphone is listened to |
| `liveFromMidi` | `boolean` | `false` | Follow MIDI instead of the microphone while recording |
| `profile`, `captureMidi`, `midiInputId`, `audioDeviceId` | as `useTakeRecorder` | — | How the take is recorded; `practiceRecorderOptions(settings)` gives them from a `PracticeSettings` |
| `playerRef` | `RefObject<MusicXmlPlayerHandle \| null>` | — | The score player, for playback pacing: its clock anchors the take and Play is pressed for the performer |
| `exercise` | `{ code, musicxml } \| null` | — | The sight-reading exercise on the card, so a take of it keeps the code |
| `countInBars` | `number` | `1` | Bars of clicks before the first beat |
| `maxTakeMs` | `number` | 10 minutes | A take ends on its own at this length |
| `idleEndMs` | `number` | 2 minutes | A free take that has heard nothing for this long ends on its own |
| `endGraceMs` | `number` | `1000` | How long after the piece ends the take keeps recording, so the last note rings out |
| `onTakeGraded` | `(take: TakeResult) => void` | — | Every finished take, graded |
| `onError` | `(error: Error) => void` | — | Microphone, MIDI, audio and grading failures |
| `labels` | `Partial<PracticeSessionLabels>` | — | The status lines: count-in, anchored, piece ended, track ended, idle ended, limit ended |

### Return value

| Field | Type | Description |
|-------|------|-------------|
| `recorder` | `UseTakeRecorderResult` | The recorder behind the session, for `RecordButton` |
| `live` | `UseLivePerformanceResult` | The live session, for `LiveStrip` |
| `metronome` | `UseMetronomeResult` | The metronome, for a beat display |
| `latest` | `TakeResult \| null` | The most recent finished take, graded |
| `takeNote` | `string \| null` | What the take is doing: counting in, anchoring, ending |
| `noteStyles` | `ReadonlyMap<string, MusicXmlNoteStyle>` | Live verdicts while a take runs, the final grade's markings once it is in — for the score player |
| `position` | `MusicXmlPlayerPosition \| null` | Where the score player's position line belongs, or `null` while the score's own playback leads |
| `beatsPerBar` | `number` | Beats in a bar of the score |
| `handlePlaybackEnded` | `() => void` | Wire to the score player's `onPlaybackEnded` |
| `clearLatest` | `() => void` | Forget the latest take |

## useTakeHistory Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `key` | `string` | `'hiyve-music-performance-takes'` | Where the list is kept in this browser; an app with several lists gives each its own |
| `limit` | `number` | `100` | How many takes are kept, newest first |
| `scoreStore` | `string` | `'hiyve-music-performance'` | Where marked scores are kept in this browser |

Returns `results` (newest first), `save(take)`, `update(id, patch)`, `clear()` and `getStoredScore(id)`. Nothing leaves the browser.

## TakeScoreCard Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `view` | `TakeResultView` | **required** | From `takeResultView` or `savedTakeResultView` |
| `saved` | `boolean` | `false` | The take is kept in history |
| `onSave` | `() => void` | — | Keep the take; the button shows when given |
| `onTryAgain` | `() => void` | — | Have another go at the same piece; the button shows when given |
| `onBackToLatest` | `() => void` | — | Shown for a take opened from history while there is a latest take to go back to |
| `actions` | `ReactNode` | — | Buttons of your own in place of the usual row |
| `compact` | `boolean` | `false` | A smaller card, for a popup |
| `sx` | `SxProps<Theme>` | — | Root styling |
| `labels` | `Partial<TakeScoreCardLabels>` | — | Every string, the praise words included, plus `formatWhen` |
| `colors` | `Partial<TakeScoreCardColors>` | — | `pitch`, `timing`, `right`, `wrong`, `missing`, `good`, `fair`, `poor` |

`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 |
| `onShowTimingMarksChange` | `(show: boolean) => void` | — | The switch was flipped |
| `height` | `number \| string` | most of the window | Height of the score viewer |
| `playerColors` | `Partial<MusicXmlPlayerColors>` | — | Colours for the score viewer |
| `sx` | `SxProps<Theme>` | — | Root styling |
| `labels` | `Partial<MarkedScoreLabels>` | — | Title, legend, switch, captions |
| `colors` | `Partial<Pick<TakeScoreCardColors, 'right' \| 'wrong' \| 'missing'>>` | — | Legend colours |

## SavedTakesTable Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `rows` | `SavedTakeResult[]` | **required** | The takes kept, as `useTakeHistory` gives them |
| `selectedId` | `string \| null` | `null` | The take shown at the moment, highlighted |
| `onOpen` | `(row: SavedTakeResult) => void` | **required** | A row was chosen |
| `onTryAgain` | `(target: TryAgainTarget) => void` | — | Have another go at a take; the buttons show when given |
| `onClear` | `() => void` | — | Forget every take; the button shows when given |
| `sx` | `SxProps<Theme>` | — | Root styling |
| `labels` | `Partial<SavedTakesTableLabels>` | — | Title, columns, names, `formatWhen` |
| `colors` | `Partial<Pick<TakeScoreCardColors, 'good' \| 'fair' \| 'poor'>>` | — | Score chip colours |

## SightReadingPanel Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `settings` | `SightReadingSettings` | **required** | The exercise's settings: exactly what its code carries |
| `onChange` | `(next: SightReadingSettings) => void` | **required** | The settings changed — or a code was pasted in |
| `locked` | `boolean` | `false` | A take is running: the settings hold still |
| `measureChoices` | `number[]` | `[4, 8, 12, 16]` | Bar counts offered |
| `sx` | `SxProps<Theme>` | — | Root styling |
| `labels` | `Partial<SightReadingPanelLabels>` | — | Every string, the level names and chord rules included |

## LiveStrip Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `snapshot` | `LiveSnapshot` | **required** | From `usePracticeSession`'s `live` or `useLivePerformance` |
| `expectClicks` | `boolean` | `false` | A metronome is sounding, so clicks may reach the microphone |
| `note` | `string \| null` | — | What the take is doing: counting in, anchoring, ending |
| `sx` | `SxProps<Theme>` | — | Root styling |
| `labels` | `Partial<LiveStripLabels>` | — | Counts, states, chips, `formatNote` |
| `colors` | `Partial<Pick<TakeScoreCardColors, 'right' \| 'wrong' \| 'missing'>>` | — | Counter colours |

## PacingSwitch, RecordButton and PracticeSettingsButton Props

| Component | Props |
|-----------|-------|
| `PacingSwitch` | `mode`, `onChange(mode)`, `disabled` (false), `playbackAvailable` (true), `sx`, `labels` |
| `RecordButton` | `recorder` (from `usePracticeSession` or `useTakeRecorder`), `sx`, `labels` (`record`, `stop`, `cannotRecord`, `level`, `formatElapsed`), `colors` (`recording`) |
| `PracticeSettingsButton` | `settings` (`PracticeSettings`), `onChange(partial)`, `recorder`, `midiInputs`, `audioInputs`, `paced` (false), `enableDownload` (true), `instruments` (profile overrides), `sx`, `labels` |

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 |
| `fromQuarters` / `toQuarters` | `number` | — | Follow only a region of the score |
| `session` | `Pick<LiveSessionOptions, 'analysis' \| 'provisional' \| 'follower' \| 'chordWindowMs'>` | core defaults | 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 |
| `setTempo` | `(tempo: { bpm, startTakeMs, atQuarters?, heardLateMs?, strictness?, missAfterMs?, nowMs?, click? } \| null) => void` | 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.

```tsx
<TakeRecorder
  labels={{ record: 'Start', stop: 'Finish', midiEventCount: '{count} notes' }}
  colors={{ recording: '#c62828', levelMeter: '#2e7d32' }}
  icons={{ record: <MyIcon /> }}
/>
```

| Defaults | Merge function |
|----------|----------------|
| `defaultTakeRecorderLabels` | `mergeTakeRecorderLabels` |
| `defaultTakeRecorderColors` | `mergeTakeRecorderColors` |
| `defaultTakeRecorderIcons` | `mergeTakeRecorderIcons` |
| `defaultTakeReportLabels` | `mergeTakeReportLabels` |
| `defaultTakeReportColors` | `mergeTakeReportColors` |

## Requirements

- React 18 or 19
- `@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.
