k-l-lambda commited on
Commit
e805c10
·
1 Parent(s): b48cc75

feat(player): forward MIDI control-change events to FluidSynth

Browse files

score-player now dispatches 'controller' events to the audio backend, and
LilyFluidAudio.controlChange forwards them to FluidSynth's sequencer so
sustain pedal (CC64), volume (CC7), pan, expression, etc. play back instead
of being dropped.

stopAllNotes also lifts the hold pedals (CC64/66/67) on every channel before
silencing, so a pedal-down whose pedal-off was dropped from the look-ahead
window (removeAllEvents on stop/seek) can't leave notes hanging forever.

Files changed (2) hide show
  1. web/fluid-audio.js +26 -2
  2. web/score-player.js +1 -0
web/fluid-audio.js CHANGED
@@ -7,7 +7,7 @@
7
  *
8
  * Shaped to match the subset of music-widgets' MidiAudio API that score-player.js
9
  * drives: empty / loadPlugin / resume / noteOn / noteOff / programChange /
10
- * stopAllNotes. score-player.js sends noteOn/noteOff with an absolute
11
  * performance.now()-based timestamp; we forward to FluidSynth's Sequencer via
12
  * sendEventAt(event, dtMs, isAbsolute=false) ("play in dtMs ms"), preserving the
13
  * look-ahead timing without reconciling against the sequencer's own tick origin.
@@ -195,9 +195,32 @@
195
  if (legacyReady && legacy) legacy.programChange(channel, program);
196
  }
197
 
 
 
 
 
 
 
 
 
 
 
198
  function stopAllNotes () {
199
  if (seq) seq.removeAllEvents(); // drop the in-flight look-ahead window
200
- if (synth) { for (var ch = 0; ch < 16; ++ch) synth.midiAllSoundsOff(ch); }
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
202
  }
203
 
@@ -215,6 +238,7 @@
215
  noteOn: noteOn,
216
  noteOff: noteOff,
217
  programChange: programChange,
 
218
  stopAllNotes: stopAllNotes,
219
  // Lightweight diagnostics: backend/AudioContext state. (A scheduled note with
220
  // no thrown error does NOT prove audio output — for that, tap node with an
 
7
  *
8
  * Shaped to match the subset of music-widgets' MidiAudio API that score-player.js
9
  * drives: empty / loadPlugin / resume / noteOn / noteOff / programChange /
10
+ * controlChange / stopAllNotes. score-player.js sends noteOn/noteOff with an absolute
11
  * performance.now()-based timestamp; we forward to FluidSynth's Sequencer via
12
  * sendEventAt(event, dtMs, isAbsolute=false) ("play in dtMs ms"), preserving the
13
  * look-ahead timing without reconciling against the sequencer's own tick origin.
 
195
  if (legacyReady && legacy) legacy.programChange(channel, program);
196
  }
197
 
198
+ // Forward a MIDI control-change event (sustain pedal CC64, volume CC7, pan CC10,
199
+ // expression CC11, etc.) to FluidSynth, which honours them per-channel. Verovio
200
+ // emits these from MEI <pedal>/dynamics, so e.g. sustain-pedal spans play back
201
+ // correctly. The legacy MIDI.js fallback has no CC support, so CC is dropped
202
+ // while it is the active backend (only during the brief soundfont load).
203
+ function controlChange (channel, control, value, timestamp) {
204
+ if (seq) { seq.sendEventAt({ type: 'controlchange', channel: channel, control: control, value: value }, delayFromNow(timestamp), false); return; }
205
+ if (legacyReady && legacy && legacy.controlChange) legacy.controlChange(channel, control, value, timestamp);
206
+ }
207
+
208
  function stopAllNotes () {
209
  if (seq) seq.removeAllEvents(); // drop the in-flight look-ahead window
210
+ if (synth) {
211
+ for (var ch = 0; ch < 16; ++ch) {
212
+ // Reset the hold pedals BEFORE silencing: removeAllEvents() above may
213
+ // have discarded a not-yet-played pedal-off (CC64=0) that was waiting
214
+ // in the look-ahead window, leaving the channel stuck "sustain on".
215
+ // Any note played afterwards would then hang forever under the phantom
216
+ // pedal. Explicitly lift sustain (CC64), sostenuto (CC66) and soft
217
+ // (CC67) so the channel starts clean.
218
+ synth.midiControl(ch, 64, 0);
219
+ synth.midiControl(ch, 66, 0);
220
+ synth.midiControl(ch, 67, 0);
221
+ synth.midiAllSoundsOff(ch);
222
+ }
223
+ }
224
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
225
  }
226
 
 
238
  noteOn: noteOn,
239
  noteOff: noteOff,
240
  programChange: programChange,
241
+ controlChange: controlChange,
242
  stopAllNotes: stopAllNotes,
243
  // Lightweight diagnostics: backend/AudioContext state. (A scheduled note with
244
  // no thrown error does NOT prove audio output — for that, tap node with an
web/score-player.js CHANGED
@@ -371,6 +371,7 @@
371
  if (e.data.subtype === 'noteOn') A.noteOn(e.data.channel, e.data.noteNumber, e.data.velocity, ts);
372
  else if (e.data.subtype === 'noteOff') A.noteOff(e.data.channel, e.data.noteNumber, ts);
373
  else if (e.data.subtype === 'programChange') A.programChange(e.data.channel, e.data.programNumber);
 
374
  }
375
  }
376
  updateHighlightsThrottled(state.currentTime);
 
371
  if (e.data.subtype === 'noteOn') A.noteOn(e.data.channel, e.data.noteNumber, e.data.velocity, ts);
372
  else if (e.data.subtype === 'noteOff') A.noteOff(e.data.channel, e.data.noteNumber, ts);
373
  else if (e.data.subtype === 'programChange') A.programChange(e.data.channel, e.data.programNumber);
374
+ else if (e.data.subtype === 'controller' && A.controlChange) A.controlChange(e.data.channel, e.data.controllerType, e.data.value, ts);
375
  }
376
  }
377
  updateHighlightsThrottled(state.currentTime);