# Handel *Dixit Dominus* Rehearsal Player
## Modular Player v1 FINAL — architecture and extension guide

**Status:** frozen production architecture; operational guide/current lock updated through Movement 8 FINAL  
**Scope:** Movements 1–8 of Handel, *Dixit Dominus*, HWV 232  
**Date:** 8 Oct 2026  
**Purpose:** one reusable rehearsal-player engine that can accept additional movements without cloning or editing movement-specific JavaScript.

---

## 1. Design decision

The accepted architecture has **one player engine and one visual shell**.

Movement-specific differences are data, not code.

The player is divided into four layers:

1. **Shared engine** — playback state, Web Audio graph, looping, seeking, tempo control, replay, autoplay-next, mixer rendering.
2. **Shared presentation** — common CSS and a generic HTML shell.
3. **Movement configuration** — title, stems, calibration, credits, and optional gain rules.
4. **Score/audio assets** — `movement-meta.json` plus the rendered MP3 stems.

The central rule is:

> Adding a movement must not require forking `player.js`.

If a new movement needs different stems, balance, credits, or score-dependent gain behaviour, those differences belong in `movement.json` or score metadata.

---

## 2. Canonical directory layout

The frozen modular player lives under:

```text
/home/ajp/music/dixit-dominus/_modular/
```

Structure:

```text
_modular/
├── shared/
│   ├── player.js
│   └── player.css
├── movements.json
├── mvt1/
│   ├── index.html
│   └── movement.json
├── mvt2/
│   ├── index.html
│   └── movement.json
└── mvt3/
    ├── index.html
    └── movement.json
```

The existing score metadata and audio remain in their movement asset locations and are referenced by URL.

Examples:

```text
/music/dixit-dominus/movement-meta.json
/music/dixit-dominus/audio/*.mp3

/music/dixit-dominus/virgam-virtutis/movement-meta.json
/music/dixit-dominus/virgam-virtutis/audio/*.mp3

/music/dixit-dominus/mvt3/movement-meta.json
/music/dixit-dominus/mvt3/audio/*.mp3
```

The modular tree therefore contains the **player application and movement configuration**, while the rendered musical assets remain canonical in their existing locations.

---

## 3. One shared state machine

The player has one explicit playback state model:

```text
loading
  ↓
ready / paused
  ↓
playing
  ↓
ended
```

Looping temporarily uses a `loopwait` state.

The important invariant is that every user route into playback uses the same state transition.

Both:

```text
Play button
Space key
```

call the same `togglePlayback()` function.

This prevents divergent behaviour between mouse and keyboard controls.

### End-of-movement behaviour

When a movement finishes:

- the playhead remains visibly at 100%;
- the state becomes `ended`;
- the button becomes **↻ Replay**;
- no automatic rewind occurs.

When Replay or Space is pressed from `ended`:

```text
position = 0
startAt(0)
```

If autoplay-next is enabled and another movement exists, the player navigates to that movement instead.

At the end of the final installed movement, the player stays at the end and offers Replay.

---

## 4. Shared engine responsibilities

`shared/player.js` owns all generic behaviour:

- load `movement.json`;
- load the global movement manifest;
- load score timing metadata;
- load and decode all configured audio stems;
- create the Web Audio graph;
- create mixer rows dynamically;
- apply per-stem hidden calibration;
- apply optional score-dependent gain rules;
- play / pause / replay;
- score-bar seeking;
- timeline dragging;
- previous/next bar;
- looping by whole score bars;
- 500 ms loop pause;
- pitch-preserving tempo control;
- lazy loading of SoundTouch;
- movement dropdown;
- autoplay-next;
- shared keyboard controls;
- end-of-track state.

None of these behaviours should be reimplemented inside an individual movement.

---

## 5. Shared visual shell

`shared/player.css` defines the common visual language used by all movements:

- movement selector and autoplay control;
- title/subtitle;
- playback panel;
- custom timeline and playhead;
- current time / total time;
- current score bar;
- previous/next bar controls;
- mixer panel;
- Mute / Max / Reset 70% controls;
- Tempo panel;
- Loop panel;
- credits/details panel;
- dark/light mode behaviour;
- responsive layout.

Each movement's `index.html` is intentionally generic.

It should not contain movement-specific JavaScript.

---

## 6. `movements.json`

The global manifest defines order and navigation.

Schema/example (abbreviated; the production manifest now contains Movements 1–8):

```json
[
  {
    "movement": 1,
    "title": "Dixit Dominus",
    "url": "/music/dixit-dominus/_modular/mvt1/"
  },
  {
    "movement": 2,
    "title": "Virgam virtutis tuae",
    "url": "/music/dixit-dominus/_modular/mvt2/"
  },
  {
    "movement": 3,
    "title": "Tecum principium",
    "url": "/music/dixit-dominus/_modular/mvt3/"
  }
]
```

Movement order in this file determines autoplay-next order.

To add a movement, add one manifest entry. No navigation code changes are required.

---

## 7. `movement.json` contract

Each movement has one configuration file.

Typical structure:

```json
{
  "movement": 3,
  "title": "Tecum principium",
  "meta_url": "/music/dixit-dominus/mvt3/movement-meta.json",
  "stems": [
    {
      "key": "soprano",
      "label": "Soprano I",
      "file": "/music/dixit-dominus/mvt3/audio/soprano1.mp3",
      "calibration": 1.0,
      "default_percent": 70
    },
    {
      "key": "piano",
      "label": "Piano",
      "file": "/music/dixit-dominus/mvt3/audio/piano.mp3",
      "calibration": 9.0,
      "default_percent": 70
    }
  ],
  "master_gain": 1.0,
  "compressor": null,
  "loop_pause_ms": 500,
  "calnote": "...",
  "credits": [
    "..."
  ]
}
```

### Required movement fields

`movement`
: integer movement number.

`title`
: displayed movement title.

`meta_url`
: URL of the score-derived timing metadata.

`stems`
: array of audio channels.

### Stem fields

`key`
: stable internal identifier.

`label`
: human-readable mixer label.

`file`
: URL of the MP3 stem.

`calibration`
: hidden multiplicative gain that compensates for raw render-level differences.

`default_percent`
: visible mixer starting point, normally 70.

The visible slider and hidden calibration remain deliberately separate.

A user's 70% does not mean the same raw numerical gain for every voicebank or accompaniment stem.

---

## 8. Movement-specific accepted calibration

### Movement 1 — *Dixit Dominus*

Seven stems:

- Soprano I
- Soprano II
- Alto
- Tenor
- Bass
- Soloists
- Piano

The hidden calibration values were migrated from the accepted v14 player into `movement.json`.

Movement 1 also retains score-dependent gain behaviour.

### Movement 2 — *Virgam virtutis tuae*

Accepted calibration:

```text
Alto   1.50
Piano  3.59
```

These are the production values from the final Movement-2 player.

### Movement 3 — *Tecum principium*

Accepted calibration:

```text
Soprano I  1.00
Piano      9.00
```

The high piano calibration compensates for the deliberately conservative raw répétiteur render; the frozen piano master itself is not modified.


### Movement 6 — *Dominus a dextris tuis*

Accepted final calibration, all with visible mixer default 70%:

```text
Soprano I   0.5000
Soprano II  0.5000
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       1.7382
```

Movement 6 deliberately has no solo-vs-chorus gain switch. The first 80 bars
remain restrained; the natural tutti expansion begins at bar 81 through the
written increase in vocal and orchestral forces.

### Movement 7 — *Judicabit*

Accepted final calibration, all with visible mixer default 70%:

```text
Soprano I   0.5357
Soprano II  0.5357
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       2.3179
```

Score/MIDI timing ends at 146.400 s; the accepted piano/player endpoint is
148.402 s.

### Movement 8 — *Conquassabit*

Accepted final calibration, all with visible mixer default 70%:

```text
Soprano I   0.5357
Soprano II  0.5357
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       1.5852
```

Movement 8 uses the accepted randomized-note-timing Alex Bass render. This is a
master-audio/source decision, not a player gain rule. The exact accepted timing
offsets are preserved in the canonical Movement-8 USTX.

Score/MIDI timing ends at 90.000 s; the accepted FluidR3/player endpoint is
92.704 s.

---

## 9. Score metadata

`movement-meta.json` contains timing information derived from the score. Audio
length is never the authority for musical bar timing.

For movements whose audio ends exactly with the score, `duration_seconds` can
match the score duration directly. Movement 5 established the general case in
which the **transport playback endpoint** extends slightly beyond the last
printed bar to carry a final consonant.

Core fields therefore support:

```json
{
  "duration_seconds": 72.190,
  "score_duration_seconds": 72.000,
  "release_tail_seconds": 0.190,
  "bar_count": 30,
  "bars": [
    {
      "bar": 30,
      "start_seconds": 69.6,
      "end_seconds": 72.0,
      "beats": 4,
      "beat_unit": 4
    }
  ],
  "tempo_events": [],
  "time_signatures": []
}
```

Interpretation:

- `bars[*].start_seconds/end_seconds` are exact score-derived bar boundaries;
- `score_duration_seconds` is the end of the final written score bar;
- `duration_seconds` is the player's transport endpoint;
- `release_tail_seconds` documents any deliberate post-score audio tail.

If no post-score tail exists, `duration_seconds` and
`score_duration_seconds` may be identical.

The player uses score bar boundaries for:

- bar display;
- ±1 bar controls;
- whole-bar looping;
- loop visualization;
- score-position-dependent gain rules.

It uses `duration_seconds` for the overall transport/timeline endpoint and
natural-completion state.

Never create a fictitious extra bar merely to accommodate a renderer release.
Movement 5 remains 30 bars ending at 72.000 s even though playback continues to
72.190 s.

---

## 10. Declarative gain rules

The shared player can alter a stem's hidden gain according to score-defined time ranges.

This exists for musical reasons, not as a substitute for fixing bad audio.

### Dedicated solo passages

Movement 1 contains dedicated Soloists audio while some complete-part renders also contain the same written solo notes.

During those passages, the duplicate chorus stem is suppressed so the solo is heard only from the dedicated Soloists stem.

The timing comes from:

```text
solo_ranges_by_voice
```

in Movement-1 metadata.

### Soprano I / Soprano II written unisons

The accepted behaviour is **not** hard suppression.

During the metadata ranges:

```text
soprano_unison_ranges
```

both Soprano channels remain audible.

Each is attenuated to:

```text
0.75 × normal channel gain
```

approximately −2.5 dB per channel.

Musical rationale:

> When two choral parts share the written line, both singers still need to be represented, but neither needs to project at the same individual level as an isolated part.

Current Movement-1 rule:

```json
"gain_rules": {
  "suppress_voice_during_solos": true,
  "suppress_s2_in_unison": false,
  "explicit": [
    {
      "stem": "s1",
      "ranges_from_meta": "soprano_unison_ranges",
      "factor": 0.75
    },
    {
      "stem": "s2",
      "ranges_from_meta": "soprano_unison_ranges",
      "factor": 0.75
    }
  ]
}
```

This pattern is intentionally generic: future movements can add gain rules without altering `player.js`.

---

## 11. Audio architecture

At normal speed the player uses Web Audio directly:

```text
decoded AudioBuffer
      ↓
BufferSource per stem
      ↓
GainNode per stem
      ↓
mix bus
      ↓
master gain
      ↓
optional compressor
      ↓
destination
```

All stems start against the same AudioContext clock.

This gives sample-locked multi-stem synchronization.

### Tempo changes

At 100%:

```text
mix bus → master
```

At altered tempo:

```text
mix bus → SoundTouch AudioWorklet → master
```

SoundTouch is loaded lazily.

Therefore ordinary 100% playback does not depend on the external SoundTouch module.

Pitch remains unchanged when rehearsal tempo is altered.

### Asset cache identity when replacing accepted stems

The shared loader fetches movement metadata/configuration freshly, but stem
audio is intentionally cache-friendly. Consequently, replacing an MP3 on disk
under the **same URL** can leave a browser playing an older cached stem.

This was confirmed during Movement-5 v14 deployment: the new v14 files and
longer playback metadata were correct, yet Chrome initially replayed cached
pre-v14 audio and the new terminal release was not heard.

Canonical deployment rule:

```text
new accepted audio revision + same pathname
    -> give the stem a new URL identity
```

The preferred lightweight implementation is a version query in `movement.json`:

```json
"file": "/music/dixit-dominus/mvt5/audio/soprano1.mp3?v=v14"
```

Changing the filename itself is equally valid. This is movement-specific
configuration and does **not** justify modifying `player.js`.

A hard browser refresh can be useful diagnostically, but canonical deployment
should not depend on the listener manually clearing caches.

---

## 12. Loop behaviour

Loop boundaries are whole score bars.

The selected range is inclusive.

Example:

```text
Bars 12 to 15
```

means playback from the start of bar 12 through the end of bar 15.

At the loop boundary:

1. audio stops;
2. the player waits approximately 500 ms;
3. playback restarts at the first bar of the loop.

The pause is an intentional rehearsal feature.

Default:

```text
loop_pause_ms = 500
```

---

## 13. Keyboard contract

Shared keyboard behaviour:

```text
Space        Play / Pause / Replay
Left Arrow   previous score bar
Right Arrow  next score bar
```

Keyboard controls and buttons call the same underlying player functions.

Do not create keyboard-specific playback logic.

---

## 14. Adding a new movement

The normal extension workflow is:

### A. Create the musical assets

Produce and accept the final stems.

Example:

```text
/home/ajp/music/dixit-dominus/mvt4/audio/soprano1.mp3
/home/ajp/music/dixit-dominus/mvt4/audio/piano.mp3
```

### B. Create `movement-meta.json`

It must contain accurate score-derived:

- total duration;
- bar count;
- bar start/end times;
- tempo events;
- time signatures;
- any movement-specific named ranges needed by gain rules.

### C. Create modular movement directory

```text
_modular/mvt4/
├── index.html
└── movement.json
```

`index.html` should be copied unchanged from another modular movement.

### D. Define `movement.json`

Specify:

- movement number;
- title;
- metadata URL;
- stems;
- hidden calibration;
- default visible level;
- optional master/compressor settings;
- optional gain rules;
- cache-versioned stem URLs when an accepted asset revision reuses an existing pathname;
- credits.

### E. Add the manifest entry

Append to `_modular/movements.json`:

```json
{
  "movement": 4,
  "title": "...",
  "url": "/music/dixit-dominus/_modular/mvt4/"
}
```

That is all the player code should require.

---

## 15. When a new movement appears to require code changes

Before editing `player.js`, ask:

> Is this really a new playback capability, or is it movement-specific data?

Examples that belong in configuration or metadata:

- different number of stems;
- different singer names;
- different calibration;
- different compressor use;
- a special attenuation range;
- a solo duplication range;
- a different loop-pause value;
- different credits.

Examples that may justify shared-engine work:

- a genuinely new transport mode;
- a new global mixer capability;
- a new type of score-timing representation;
- a new playback backend used by multiple movements.

If shared-engine code changes are required, all installed movements must be regression-tested.

---

## 16. Regression test before promoting a new shared engine

For at least one complex and one simple movement verify:

### Transport

- Play works.
- Pause works.
- Space behaves identically to the button.
- natural completion leaves the playhead at the end;
- button reads **↻ Replay**;
- Replay starts from bar 1.

### Navigation

- movement selector works in both directions;
- autoplay preference persists;
- autoplay advances when a next movement exists;
- final movement does not attempt a nonexistent next movement.

### Timeline

- drag-to-seek works;
- ±1 bar uses score boundaries;
- bar indicator agrees with score.

### Looping

- start/end bar fields work;
- range is inclusive;
- 500 ms pause occurs;
- leaving loop mode works cleanly.

### Mixer

- every configured stem appears;
- Reset returns visible levels to 70%;
- Mute / Unmute works;
- Max works;
- hidden calibration remains intact.

### Movement-1 musical regression

Specifically verify:

- dedicated solo passages do not double with chorus renders;
- Soprano I/II written unisons retain both voices;
- each soprano is attenuated to 0.75× during those unisons.

### Tempo

- 100% works without SoundTouch;
- non-100% lazily loads the pitch-preserving engine;
- return to 100% works.

---

## 17. Freeze policy

The accepted player is:

**Modular Player v1 FINAL**

The frozen unit consists of:

- `_modular/shared/player.js`
- `_modular/shared/player.css`
- `_modular/movements.json`
- each `_modular/mvtN/index.html`
- each `_modular/mvtN/movement.json`
- the referenced `movement-meta.json` files
- the referenced MP3 stems
- the exact stem URL identities/version queries recorded in each `movement.json`

Do not modify a frozen file casually.

For future development:

1. copy or branch the modular player;
2. make the change;
3. regression-test all installed movements;
4. promote only after acceptance;
5. record a new version.

The freeze script supplied alongside this document creates a reproducibility snapshot and SHA-256 manifest without altering the working player.

---

## 18. Current production lock

At the 8 Oct 2026 Movement-8 FINAL lock:

- Movements 1–8 run on the same **Modular Player v1 FINAL** shared engine.
- No shared `player.js` or `player.css` capability change was required for
  Movements 7 or 8.
- Movement 1 remains the complex regression case for dedicated-solo duplicate
  suppression and substantial written Soprano-I/Soprano-II unison attenuation.
- Movement 2 retains accepted Alto/Piano calibration 1.50 / 3.59.
- Movement 3 retains Soprano/Piano calibration 1.00 / 9.00.
- Movement 4 chorus v12 remains production-final with its accepted S1/S2
  written-unison attenuation ranges.
- Movement 5 retains its accepted production audio/player lock and the
  distinction between score time and release-tail time.
- Movement 6 remains FINAL with répétiteur piano v2 and final hidden calibration:

```text
Soprano I   0.5000
Soprano II  0.5000
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       1.7382
```

- Movement 7 (*Judicabit*) is FINAL with chorus v20 and répétiteur piano v1.
  Score/MIDI endpoint is 146.400 s and accepted playback endpoint is 148.402 s.
  Final hidden calibration is:

```text
Soprano I   0.5357
Soprano II  0.5357
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       2.3179
```

- Movement 8 (*Conquassabit*) is FINAL with chorus v10 plus the accepted
  randomized-note-timing Bass and répétiteur piano v1.
- The canonical Movement-8 USTX now contains the exact accepted Bass timing
  offsets, and the web Bass was freshly derived from
  `dixit_mvt8_bass_v10_RNDTIME_FINAL.wav`.
- Movement-8 score/MIDI endpoint is 90.000 s; the accepted FluidR3/player
  endpoint is 92.704 s.
- Movement-8 final hidden calibration is:

```text
Soprano I   0.5357
Soprano II  0.5357
Alto        0.5357
Tenor       1.1800
Bass        1.6000
Piano       1.5852
```

- Movement-8 Bass audio URL uses a fresh cache identity after the accepted
  timing-offset render was installed.
- Randomized-note timing is a renderer/master property and belongs in the
  canonical USTX/WAV. The player must not synthesize or randomize timing.
- Substantial written vocal-doubling attenuation remains a separate declarative
  gain-rule concern where required; it is not replaced by acoustic
  decorrelation.
- Same-family orchestral exact-unison collapse remains a piano-reduction rule,
  not a player rule.
- Replay, looping, tempo control, bar navigation, autoplay, hidden calibration,
  and cache-versioned asset identity remain shared architecture.
- Movement-specific interpretation remains configuration/metadata plus accepted
  master assets, not copied JavaScript.

The architectural version remains **Modular Player v1 FINAL**.

Movement 8 reinforces the intended separation of responsibilities:

```text
score / edition decisions
        -> canonical musical source

synthetic-singer timing decorrelation
        -> canonical USTX / vocal WAV

orchestral duplicate-key reduction
        -> canonical piano MIDI

stable per-stem balance
        -> hidden player calibration

substantial score-defined doubling balance
        -> declarative movement gain rules

transport / looping / tempo / navigation
        -> shared player engine
```

Keeping those layers separate is now a production invariant.
