# @hiyve/react-music-notation

Sheet music player for MusicXML scores. It renders a score, plays it back with transport, tempo and per-instrument volume controls, and can keep everyone in a room on the same note as a presenter plays, steps or clicks through the piece.

## You supply the notation engine

This package is the player: the controls, the layout and the room sync. It does **not** contain the engine that engraves and plays a score. You provide that engine through the required `loadEngine` prop, and nothing renders until you do.

Setup is three steps: install the package, add an engine, render a score.

## Setup

### 1. Install the package

```bash
npm install @hiyve/react-music-notation @hiyve/utilities @mui/material @mui/icons-material @emotion/react @emotion/styled
```

Synced playback across a room additionally needs:

```bash
npm install @hiyve/react @hiyve/react-semantic-relay
```

### 2. Add a notation engine

The player works with any [OpenSheetMusicDisplay](https://opensheetmusicdisplay.org)-compatible build. Which build you choose decides what your users get:

| | Option A: public build | Option B: a build with playback |
|---|---|---|
| Where it comes from | `opensheetmusicdisplay` on npm (BSD-3-Clause) | A build that includes the audio player classes `PlaybackManager`, `BasicAudioPlayer` and `LinearTimingSource`, such as the build OpenSheetMusicDisplay provides to its sponsors |
| Score rendering | Yes | Yes |
| Cursor and note-by-note stepping | Yes | Yes |
| Play, pause, stop | No | Yes |
| Tempo and per-instrument volume | No | Yes |
| Click a note to hear it | No | Yes |

You can start with option A and move to option B later; only the `loadEngine` function changes.

#### Option A: the public build (rendering only)

Install it:

```bash
npm install opensheetmusicdisplay
```

Create the loader once, in a module of its own:

```ts
// notationEngine.ts
export const loadEngine = () => import('opensheetmusicdisplay');
```

The player shows the score with previous-note and next-note buttons. The playback controls are hidden.

#### Option B: a build with playback

1. **Get the build.** You need one file: the minified browser build, usually named `opensheetmusicdisplay.min.js`. It is not on public npm. You obtain it from its publisher and use it under its licence terms.

2. **Serve the file from your app.** Either put it in your static folder, so that `public/vendor/opensheetmusicdisplay.min.js` is served at `/vendor/opensheetmusicdisplay.min.js`, or keep it beside your source and let your bundler emit it:

   ```ts
   const engineUrl = new URL('./vendor/opensheetmusicdisplay.min.js', import.meta.url).href;
   ```

   Load it as a script, as shown next. Do not `import` the file as a module.

3. **Create the loader once**, in a module of its own:

   ```ts
   // notationEngine.ts
   import { createScriptEngineLoader } from '@hiyve/react-music-notation';

   export const loadEngine = createScriptEngineLoader('/vendor/opensheetmusicdisplay.min.js');
   ```

`createScriptEngineLoader(url, options?)` adds the script to the page the first time a player needs it and reuses it afterwards, however many players you render.

| Option | Type | Default | Description |
|---|---|---|---|
| `globalName` | `string` | `'opensheetmusicdisplay'` | Name of the global the script defines |
| `nonce` | `string` | – | CSP nonce to set on the script element |

Either way, the engine is downloaded only when a player first mounts, so it adds nothing to your initial page load.

### 3. Render a score

```tsx
import { MusicXmlPlayer } from '@hiyve/react-music-notation';
import { loadEngine } from './notationEngine';

function ScoreViewer({ url, onClose }: { url: string; onClose: () => void }) {
  return (
    <MusicXmlPlayer
      fileUrl={url}
      fileName="Minuet in G"
      loadEngine={loadEngine}
      onClose={onClose}
      onError={(error) => console.error(error)}
    />
  );
}
```

By default the player covers the viewport with its own header. Use `variant="inline"` to fill a container you size yourself:

```tsx
<div style={{ height: 600 }}>
  <MusicXmlPlayer variant="inline" fileUrl={url} loadEngine={loadEngine} />
</div>
```

### If it does not work

Pass `onError` while you set up; the message tells you which step failed.

| What you see | Cause | Fix |
|---|---|---|
| `Failed to load the notation engine script` | The engine URL is wrong, or your Content Security Policy blocks it | Open the URL in the browser; allow its origin in `script-src` |
| `The notation engine script loaded but did not define window.opensheetmusicdisplay` | The file is not a browser build, or it defines a different global | Use the minified browser build; set `globalName` if the global differs |
| `The notation engine does not provide an OpenSheetMusicDisplay class` | `loadEngine` resolved with something that is not an engine | Return the engine module itself from `loadEngine` |
| `Failed to fetch the score: 403 Forbidden` (or another status) | The browser could not read `fileUrl` | Check the URL, its expiry and its CORS headers |
| The score shows but there is no Play button | The engine has no playback classes (option A) | Expected with the public build; use a build with playback |
| Play works but there is no sound | The instrument samples could not be downloaded | Allow the sample hosts in `connect-src`, or set `soundfont` (see [Instrument samples](#instrument-samples)) |

To check an engine in code, `engineSupportsPlayback(engine)` returns whether it can play audio.

## Features

- **Score rendering** from any MusicXML URL, engraved as SVG and re-flowed when the container resizes.
- **Playback transport**: play, pause, stop, and note-by-note stepping, with a cursor that follows the music.
- **Tempo** slider, starting from the tempo the score states.
- **Per-instrument volume and mute** for every part in the score.
- **Click a note** to move the cursor there and hear it.
- **Synced playback**: one presenter drives every follower's player, including tempo, volume and note clicks.
- **Render-only mode**: with an engine that has no audio player, the score still renders and steps; playback controls are hidden.
- **Overlay or inline** layout, with customizable labels and colours.

## Synced playback

`SyncedMusicXmlPlayer` mirrors the presenter's actions to every follower. A follower's controls are locked while it follows.

```tsx
import { SyncedMusicXmlPlayer } from '@hiyve/react-music-notation/sync';

function RoomScore({ file, url, localUserId, ownerId }) {
  return (
    <SyncedMusicXmlPlayer
      variant="inline"
      fileUrl={url}
      loadEngine={loadEngine}
      fileId={file.fileId}
      localUserId={localUserId}
      isPresenter={localUserId === ownerId}
      presenterId={ownerId}
      enabled
    />
  );
}
```

Render it under a `SemanticRelayProvider` from `@hiyve/react-semantic-relay`. Every participant must pass the same `fileId` for the same score.

Followers only accept commands sent by `presenterId`. Until `presenterId` is known they ignore every command, so pass it as soon as you have it.

### Building your own sync

`useMusicXmlPlayerSync` gives you the transport without the component, for cases where you render `MusicXmlPlayer` yourself:

```tsx
import { useRef } from 'react';
import { MusicXmlPlayer, type MusicXmlPlayerHandle } from '@hiyve/react-music-notation';
import { useMusicXmlPlayerSync } from '@hiyve/react-music-notation/sync';

function CustomSyncedScore({ url, fileId, localUserId, isPresenter, presenterId }) {
  const playerRef = useRef<MusicXmlPlayerHandle>(null);
  const { broadcast, isFollowing } = useMusicXmlPlayerSync({
    localUserId,
    isPresenter,
    presenterId,
    fileId,
    enabled: true,
    onRemoteCommand: (command) => playerRef.current?.apply(command),
  });

  return (
    <MusicXmlPlayer
      ref={playerRef}
      variant="inline"
      fileUrl={url}
      loadEngine={loadEngine}
      controlsLocked={isFollowing}
      onLocalCommand={broadcast}
    />
  );
}
```

## Opening scores from the file manager

`FileManager` from `@hiyve/react-collaboration` recognises `.musicxml` files. Register the player as their viewer:

```tsx
import { FileManager } from '@hiyve/react-collaboration';
import { MusicXmlPlayer } from '@hiyve/react-music-notation';

<FileManager
  customViewers={{
    musicxml: (_data, _file, _onClose, url) =>
      url ? <MusicXmlPlayer variant="inline" fileUrl={url} loadEngine={loadEngine} /> : null,
  }}
/>
```

## Props

### MusicXmlPlayer

| Prop | Type | Default | Description |
|---|---|---|---|
| `fileUrl` | `string` | required | URL of the MusicXML document |
| `loadEngine` | `NotationEngineLoader` | required | Supplies the notation engine |
| `fileName` | `string` | – | Title shown in the overlay header |
| `onClose` | `() => void` | – | Called when the user closes the overlay |
| `variant` | `'overlay' \| 'inline'` | `'overlay'` | Cover the viewport with a header, or fill the parent with none |
| `controlsLocked` | `boolean` | `false` | Disable every control and ignore clicks on the score |
| `onLocalCommand` | `(command: MusicXmlPlayerCommand) => void` | – | Called for each action the local user performs |
| `onError` | `(error: Error) => void` | – | Called when the engine or the score fails to load |
| `labels` | `Partial<MusicXmlPlayerLabels>` | – | Text overrides |
| `colors` | `Partial<MusicXmlPlayerColors>` | – | Colour overrides |
| `soundfont` | `MusicXmlSoundfontOptions` | – | Where playback fetches instrument samples |
| `sx` | `SxProps<Theme>` | – | Styles merged onto the root element |

The component's `ref` exposes `apply(command)`, which performs a command as if the user had done it without reporting it through `onLocalCommand`.

### SyncedMusicXmlPlayer

Takes every `MusicXmlPlayer` prop except `controlsLocked` and `onLocalCommand`, plus:

| Prop | Type | Default | Description |
|---|---|---|---|
| `localUserId` | `string` | required | The local user's id |
| `isPresenter` | `boolean` | required | True for the one participant everyone else follows |
| `enabled` | `boolean` | required | Presenter: share my actions. Follower: follow the presenter |
| `fileId` | `string` | required | Identifies the open score; the same on every client |
| `presenterId` | `string` | – | The presenter's user id. Followers ignore commands from anyone else |
| `allowUnverifiedPresenter` | `boolean` | `false` | Accept commands from any sender while `presenterId` is empty |

### useMusicXmlPlayerSync

Takes the six sync props above, plus:

| Option | Type | Description |
|---|---|---|
| `onRemoteCommand` | `(command: MusicXmlPlayerCommand) => void` | Called with each command a follower should apply |
| `onError` | `(error: Error) => void` | Called when a published command could not be delivered |

Returns:

| Field | Type | Description |
|---|---|---|
| `broadcast` | `(command: MusicXmlPlayerCommand) => void` | Publish a local action. Does nothing unless presenting with sync on |
| `isFollowing` | `boolean` | True when this client is following the presenter |

### Commands

`MusicXmlPlayerCommand` is one of:

| Command | Meaning |
|---|---|
| `{ t: 'play' }` | Start or resume playback |
| `{ t: 'pause' }` | Pause playback |
| `{ t: 'stop' }` | Stop and return to the start |
| `{ t: 'next' }` / `{ t: 'back' }` | Step one note forward or back |
| `{ t: 'tempo', bpm }` | Set the tempo |
| `{ t: 'volume', instrument, volume }` | Set an instrument's volume, `0`–`1`. `instrument` is its position in the score |
| `{ t: 'mute', instrument, muted }` | Mute or unmute an instrument |
| `{ t: 'noteAt', timestamp }` | Move to a position in the score and sound the notes there |

## Customization

### Labels

```tsx
<MusicXmlPlayer
  fileUrl={url}
  loadEngine={loadEngine}
  labels={{ title: 'Partitur', play: 'Abspielen', pause: 'Pause', stop: 'Stopp' }}
/>
```

| Label | Default |
|---|---|
| `title` | `Sheet Music` |
| `back` | `Go back` |
| `close` | `Close` |
| `previousNote` | `Previous note` |
| `nextNote` | `Next note` |
| `play` | `Play` |
| `pause` | `Pause` |
| `stop` | `Stop` |
| `tempo` | `Tempo` |
| `tempoUnit` | `BPM` |
| `volume` | `Volume` |
| `muteInstrument` | `Mute` |
| `unmuteInstrument` | `Unmute` |
| `noInstruments` | `No instruments detected` |
| `unknownInstrument` | `Unknown` |
| `loading` | `Loading sheet music…` |
| `loadError` | `Failed to load sheet music` |

`DEFAULT_LABELS` holds the defaults and `mergeLabels(overrides)` returns the defaults with your overrides applied.

### Colours

Each colour is an MUI theme palette path or any CSS colour.

```tsx
<MusicXmlPlayer
  fileUrl={url}
  loadEngine={loadEngine}
  colors={{ toolbar: 'grey.900', cursor: '#ff3366' }}
/>
```

| Colour | Default | Used for |
|---|---|---|
| `background` | `background.default` | The player surface |
| `toolbar` | `background.paper` | Header and control bar |
| `border` | `divider` | Borders under the header and control bar |
| `sheet` | `#FFFFFF` | The page the score is drawn on |
| `cursor` | `primary.main` | The playback cursor |

`DEFAULT_COLORS` holds the defaults and `mergeColors(overrides)` returns the defaults with your overrides applied.

### Instrument samples

During playback the engine downloads instrument samples. To serve them from your own host, pass `soundfont`:

```tsx
<MusicXmlPlayer
  fileUrl={url}
  loadEngine={loadEngine}
  soundfont={{
    baseUrl: 'https://cdn.example.com/soundfonts/',
    percussionBaseUrl: 'https://cdn.example.com/soundfonts/FluidR3_GM/',
  }}
/>
```

The host must be laid out like the `midi-js-soundfonts` collection.

## Requirements

- React 18 or 19, and MUI 9.
- A notation engine, supplied through `loadEngine`.
- `fileUrl` must be fetchable from the browser (CORS applies) and return uncompressed MusicXML. Compressed `.mxl` files are not supported.
- **Content Security Policy.** The score is fetched with `fetch`, so its host must be allowed by `connect-src`. With a playback engine and no `soundfont` override, samples are fetched from `https://gleitz.github.io` and `https://paulrosen.github.io`. An engine loaded with `createScriptEngineLoader` must be allowed by `script-src`.
- Browsers start audio only after a user gesture, so playback begins from a click on the player.
- `SyncedMusicXmlPlayer` and `useMusicXmlPlayerSync` must be used under a `SemanticRelayProvider` from `@hiyve/react-semantic-relay`.

## Public API

### `@hiyve/react-music-notation`

| Export | Kind | Purpose |
|---|---|---|
| `MusicXmlPlayer` | component | The score player |
| `createScriptEngineLoader` | function | Loader for an engine hosted as a script |
| `engineSupportsPlayback` | function | Whether an engine can play audio |
| `DEFAULT_LABELS`, `mergeLabels` | defaults | Label defaults and merge |
| `DEFAULT_COLORS`, `mergeColors` | defaults | Colour defaults and merge |
| `readCursorTimestamp` | function | The cursor's position in a score display, or `null` |
| `readSelectionStartTimestamp` | function | The position of the last clicked note in a score display, or `null` |
| `pollForSelectionChange` | function | Wait for a value to change, with a timeout; returns a cancel function |
| `MusicXmlPlayerProps`, `MusicXmlPlayerHandle`, `MusicXmlPlayerCommand` | types | Player props, ref handle and commands |
| `MusicXmlPlayerLabels`, `MusicXmlPlayerColors`, `MusicXmlSoundfontOptions` | types | Customization |
| `NotationEngine`, `NotationEngineLoader`, `ScriptEngineLoaderOptions` | types | The engine contract |
| `NotationDisplay`, `NotationCursor`, `NotationCursorIterator`, `NotationSheet`, `NotationTimestamp`, `NotationInstrument`, `NotationInstrumentMap`, `NotationPlaybackManager`, `NotationPlaybackListener`, `NotationAudioPlayer`, `NotationSoundfontOptions`, `NotationTimingSource`, `PollDeps` | types | The parts of an engine the player uses |

### `@hiyve/react-music-notation/sync`

| Export | Kind | Purpose |
|---|---|---|
| `SyncedMusicXmlPlayer` | component | A player the presenter drives for every follower |
| `useMusicXmlPlayerSync` | hook | The sync transport on its own |
| `MUSICXML_PLAYER_SYNC_TOPIC` | constant | The relay topic sync messages are published on |
| `SyncedMusicXmlPlayerProps`, `UseMusicXmlPlayerSyncOptions`, `UseMusicXmlPlayerSyncResult`, `MusicXmlPlayerSyncPayload` | types | Sync props, options, result and message shape |
