diff --git a/driver/fluidsyX.cpp b/driver/fluidsyX.cpp index 285e55b..842589f 100644 --- a/driver/fluidsyX.cpp +++ b/driver/fluidsyX.cpp @@ -958,6 +958,26 @@ struct FluidsyXApp { }; std::vector pendingSngEvents; +#if FLUID_VERSION_AT_LEAST(2, 6, 0) + /** Per-voice callback data, keyed by user-controlled voice ID (ctx.voiceId). + * Entries are inserted in dummy_preset_noteon() when a voice is started, + * and removed in voiceStateCallback() when the voice finishes. */ + struct VoiceCallbackData { + FluidsyXApp* app; + int macroId; + unsigned int voiceId; /**< Key in voiceCallbacks — needed for self-removal on FINISHED */ + }; + std::map voiceCallbacks; + + /** Invoked from the FluidSynth synthesis thread when a voice enters its + * release phase (NOTEOFF) or finishes playing (FINISHED). + * Sets the appropriate received flag and, for indefinite waits only, + * schedules an immediate timer to resume the blocked macro. */ + static void voiceStateCallback(fluid_voice_t* voice, + enum fluid_voice_callback_reason reason, + void* data); +#endif + /* ── lifecycle helpers ── */ [[nodiscard]] bool initFluidSynth(); @@ -1016,6 +1036,11 @@ struct FluidsyXApp { void killVoice(fluid_synth_t* synth, MacroExecContext& ctx); /** Kick off the next pending timer step for all active macros */ void scheduleNextTimerStep(); + + /** Helper: schedule an immediate sequencer timer to resume macro \p macroId. + * Called from voiceStateCallback (synthesis thread) and the NoteOff handler + * (sequencer thread) when an indefinite wait condition is satisfied. */ + void scheduleImmediateResume(int macroId); }; /* ═══════════════════ Dummy preset callbacks ═══════════════════ */ @@ -1143,6 +1168,34 @@ static int dummy_preset_noteon(fluid_preset_t* preset, fluid_synth_t* synth, app->applyAdsrToVoice(voice, ctx, false); +#if FLUID_VERSION_AT_LEAST(2, 6, 0) + /* Register a voice-state callback so the macro can react to the true + * noteoff (voice enters release) and voice finish (sample end) events. + * + * Registration is done here, BEFORE fluid_synth_start_voice(), because + * this is the only synchronous opportunity: dummy_preset_noteon is called + * from within fluid_synth_start() before the voice becomes active in the + * DSP loop. Voice callbacks can only fire from the audio/synthesis thread + * during fluid_synth_process(), which cannot run until after + * fluid_synth_start_voice() returns. There is therefore no risk of a + * premature callback. + * + * std::map (tree-based) guarantees that inserting new entries never + * invalidates references or pointers to existing entries, so &cbData + * remains valid for the lifetime of the entry. */ + { + int macroId = ctx.selfId; + unsigned int vid = static_cast(fluid_voice_get_id(voice)); + if (macroId >= 0 && vid != 0) { + auto& cbData = app->voiceCallbacks[vid]; + cbData.app = app; + cbData.macroId = macroId; + cbData.voiceId = vid; + fluid_voice_set_callback(voice, FluidsyXApp::voiceStateCallback, &cbData); + } + } +#endif + fluid_synth_start_voice(synth, voice); return FLUID_OK; } @@ -1175,9 +1228,11 @@ bool FluidsyXApp::initFluidSynth() { fluid_settings_setint(settings.get(), "audio.period-size", 256); fluid_settings_setint(settings.get(), "synth.verbose", 0); fluid_settings_setnum(settings.get(), "synth.gain", 0.9); + fluid_settings_setnum(settings.get(), "synth.reverb.active", 1); + fluid_settings_setstr(settings.get(), "synth.reverb.engine", "lex"); fluid_settings_setnum(settings.get(), "synth.reverb.level", 0.8); fluid_settings_setnum(settings.get(), "synth.reverb.room-size", 0.7); - fluid_settings_setnum(settings.get(), "synth.reverb.width", 1); + fluid_settings_setnum(settings.get(), "synth.reverb.width", 2); fluid_settings_setnum(settings.get(), "synth.reverb.damp", 0); // Use FluidSynth's linear portamento mode via the portamento-time setting. fluid_settings_setstr(settings.get(), "synth.portamento-time", "linear"); @@ -1770,12 +1825,12 @@ unsigned int SoundMacro::CmdWaitTicks::DoFluid(MacroExecContext& ctx, fluid_voic if(ticksOrMs != 0) { // Step 1 – Keyoff pre-check (synthmacros.c:76-86): If the Keyoff flag is set and if a keyoff has already been received and if the sustain pedal is not held: return 0 - if(v) - { - if (ctx.waitingKeyoff && ctx.keyoffReceived && !fluid_voice_is_sustained(v) && !fluid_voice_is_sostenuto(v)) { - ctx.waitingKeyoff = false; - return 0; - } + // Note: when v==nullptr (voice already released), treat as "not sustained". + if (ctx.waitingKeyoff && ctx.keyoffReceived && + (!v || (!fluid_voice_is_sustained(v) && !fluid_voice_is_sostenuto(v)))) { + ctx.waitingKeyoff = false; + ctx.pc++; + return 0; } // If the Keyoff flag is not set: the "wake on keyoff" flag is cleared (cFlags &= ~4). ctx.waitingKeyoff = keyOff; @@ -1784,6 +1839,7 @@ unsigned int SoundMacro::CmdWaitTicks::DoFluid(MacroExecContext& ctx, fluid_voic if (ctx.waitingSampleEnd && ctx.sampleEndReceived) { ctx.waitingKeyoff = false; ctx.waitingSampleEnd = false; + ctx.pc++; return 0; } // If the Sampleend flag is not set: the "wake on sampleend" flag is cleared (cFlags &= ~0x40000). @@ -2893,6 +2949,11 @@ int FluidsyXApp::enqueueSoundMacro(const SoundMacro* sm, int step, ctx.appData = this; ctx.macStartTime = startTick; + /* Pre-assign the macroId so that dummy_preset_noteon() can embed it in + * the voice callback before the context is inserted into activeMacros. */ + int macroId = nextMacroId++; + ctx.selfId = macroId; + /* Walk through commands that execute instantly (no delay). * When a command introduces a delay, schedule a timer event and stop. */ unsigned int tick = startTick; @@ -2902,7 +2963,6 @@ int FluidsyXApp::enqueueSoundMacro(const SoundMacro* sm, int step, if (ctx.inIndefiniteWait) { /* Indefinite wait – store context but do NOT schedule a timer. * The macro will be resumed by an external event (keyoff, sampleEnd). */ - int macroId = nextMacroId++; activeMacros[macroId] = ctx; return macroId; } @@ -2910,7 +2970,6 @@ int FluidsyXApp::enqueueSoundMacro(const SoundMacro* sm, int step, safetyCounter++; if (d > 0 && !ctx.ended) { /* Store context in the stable map and schedule a timer callback */ - int macroId = nextMacroId++; activeMacros[macroId] = ctx; FluidEventPtr tevt(new_fluid_event(), &delete_fluid_event); @@ -2921,6 +2980,11 @@ int FluidsyXApp::enqueueSoundMacro(const SoundMacro* sm, int step, return macroId; } } +#if FLUID_VERSION_AT_LEAST(2, 6, 0) + /* Macro completed synchronously without waiting — however the voice (if + * any) may still be playing. The voiceCallbacks entry remains valid; + * the FINISHED callback will erase it when the voice actually stops. */ +#endif return -1; } @@ -3068,6 +3132,69 @@ void FluidsyXApp::timerCallback(unsigned int time, fluid_event_t* event, app->activeMacros.erase(it); } +void FluidsyXApp::scheduleImmediateResume(int macroId) { + FluidEventPtr resumeEvt(new_fluid_event(), &delete_fluid_event); + fluid_event_set_source(resumeEvt.get(), callbackSeqId); + fluid_event_set_dest(resumeEvt.get(), callbackSeqId); + fluid_event_timer(resumeEvt.get(), reinterpret_cast(static_cast(macroId))); + fluid_sequencer_send_now(sequencer.get(), resumeEvt.get()); +} + +#if FLUID_VERSION_AT_LEAST(2, 6, 0) +void FluidsyXApp::voiceStateCallback(fluid_voice_t* /*voice*/, + int reason, + void* data) { + auto* cbData = static_cast(data); + if (!cbData) + return; + FluidsyXApp* app = cbData->app; + int macroId = cbData->macroId; + if (!app) + return; + + auto it = app->activeMacros.find(macroId); + + switch (reason) { + case FLUID_VOICE_CALLBACK_NOTEOFF: + /* The voice has entered its true release phase (not sustained/sostenutoed). + * Treat this the same as receiving a MIDI NoteOff for the macro. */ + if (it != app->activeMacros.end()) { + MacroExecContext& ctx = it->second; + if (!ctx.keyoffReceived) { + ctx.keyoffReceived = true; + /* Only schedule an immediate resume for indefinite waits; for + * timed waits the existing timer will fire and check the flag. */ + if (ctx.inIndefiniteWait && ctx.waitingKeyoff) { + ctx.inIndefiniteWait = false; + ctx.waitingKeyoff = false; + app->scheduleImmediateResume(macroId); + } + } + } + break; + + case FLUID_VOICE_CALLBACK_FINISHED: + /* The voice has finished playing and is being removed from the DSP loop. + * This is the closest proxy for "sample end" that FluidSynth exposes. */ + if (it != app->activeMacros.end()) { + MacroExecContext& ctx = it->second; + if (!ctx.sampleEndReceived) { + ctx.sampleEndReceived = true; + if (ctx.inIndefiniteWait && ctx.waitingSampleEnd) { + ctx.inIndefiniteWait = false; + ctx.waitingSampleEnd = false; + app->scheduleImmediateResume(macroId); + } + } + } + /* Erase the per-voice callback data regardless of whether the macro + * is still alive – the voice no longer exists after this callback. */ + app->voiceCallbacks.erase(cbData->voiceId); + break; + } +} +#endif + /* ═══════════════════ SNG → FluidSynth sequencer scheduling ═══════════════════ */ double FluidsyXApp::scheduleSongEvents(const uint8_t* sngData, size_t /*sngSize*/) { diff --git a/include/amuse/FluidsyXMacroContext.hpp b/include/amuse/FluidsyXMacroContext.hpp index d6270d6..f9c4973 100644 --- a/include/amuse/FluidsyXMacroContext.hpp +++ b/include/amuse/FluidsyXMacroContext.hpp @@ -40,6 +40,11 @@ struct MacroExecContext { unsigned int voiceId = 0; uint8_t triggerNote = 60; /**< Original SNG note (before keymap/layer transpose), used for NoteOff matching */ + /** Key of this context in FluidsyXApp::activeMacros. Pre-assigned in + * enqueueSoundMacro() so that dummy_preset_noteon() can register a voice + * callback carrying the macroId before the context is inserted into the map. */ + int selfId = -1; + /* ── Pending SoundMacro commands ── * Commands executed before CmdStartSample creates the voice are stored * here. We cannot allocate the voice earlier (e.g. in FluidsyXApp::enqueueSoundMacro()), because fluidsynth wants to know