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:
- Shared engine — playback state, Web Audio graph, looping, seeking, tempo control, replay, autoplay-next, mixer rendering.
- Shared presentation — common CSS and a generic HTML shell.
- Movement configuration — title, stems, calibration, credits, and optional gain rules.
- Score/audio assets —
movement-meta.jsonplus 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:
/home/ajp/music/dixit-dominus/_modular/
Structure:
_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:
/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:
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:
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:
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):
[
{
"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:
{
"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:
Alto 1.50
Piano 3.59
These are the production values from the final Movement-2 player.
Movement 3 — Tecum principium
Accepted calibration:
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%:
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%:
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%:
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:
{
"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_secondsare exact score-derived bar boundaries;score_duration_secondsis the end of the final written score bar;duration_secondsis the player's transport endpoint;release_tail_secondsdocuments 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:
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:
soprano_unison_ranges
both Soprano channels remain audible.
Each is attenuated to:
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:
"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:
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%:
mix bus → master
At altered tempo:
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:
new accepted audio revision + same pathname
-> give the stem a new URL identity
The preferred lightweight implementation is a version query in movement.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:
Bars 12 to 15
means playback from the start of bar 12 through the end of bar 15.
At the loop boundary:
- audio stops;
- the player waits approximately 500 ms;
- playback restarts at the first bar of the loop.
The pause is an intentional rehearsal feature.
Default:
loop_pause_ms = 500
13. Keyboard contract
Shared keyboard behaviour:
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:
/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
_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:
{
"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.jsonfiles - 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:
- copy or branch the modular player;
- make the change;
- regression-test all installed movements;
- promote only after acceptance;
- 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.jsorplayer.csscapability 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:
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:
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:
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:
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.