Scratch concept for MPE in FL Studio
Just a thought experiment on how to implement MPE in FL Studio
Issues
CRITICAL: Things That WILL Crash FL Studio
1. Changing TMidiMessage Structure Size
delphi// BEFORE (existing FL Studio):
type TMidiMessage = record
Status: Byte;
Channel: Byte;
Data1: Byte;
Data2: Byte;
end; // 4 bytes
// AFTER (proposed):
type TFLMidiMessage = record
Timestamp: Int64; // +8 bytes
Status: Byte;
Channel: Byte;
VoiceChannel: Byte; // +1 byte
Data1: Byte;
Data2: Byte;
Data3: Byte; // +1 byte
AssociatedNote: Byte; // +1 byte
IsPerNote: Boolean; // +1 byte
IsMIDI2: Boolean; // +1 byte
MPEZone: TMPEZone; // +1 byte
end; // 19 bytes total
Why it crashes:
Every plugin, every saved project, every internal function expects 4-byte messages
Saved FLP files have MIDI event streams serialized at 4 bytes per event
Loading old projects would read corrupt data (reading 19 bytes where only 4 exist)
SOLUTION
// Keep original for compatibility
type
TMidiMsg = packed record
Status, Channel, Data1, Data2: Byte;
end;
// New extended type (internal only)
TFLMidiMsgEx = record
Legacy: TMidiMsg; // First 4 bytes identical
Timestamp: Int64;
VoiceChannel: Byte;
// … extended fields
end;
// Adapter
function UpgradeMessage(const Legacy: TMidiMsg): TFLMidiMsgEx;
begin
Result.Legacy := Legacy;
Result.Timestamp := GetTickCount64;
Result.VoiceChannel := Legacy.Channel;
Result.IsPerNote := False; // Default to legacy behavior
// …
end;
/ CURRENT (throughout FL Studio):
procedure ProcessMidiMessage(Msg: TMidiMessage);
// PROPOSED CHANGE:
procedure ProcessMidiMessage(var Msg: TFLMidiMessage); // BREAKS EVERYTHINGWhy it crashes:
- Hundreds of plugins, internal modules, and third-party wrapper code call this
- Changing the signature breaks binary compatibility with compiled plugins
- FL Studio uses dynamic plugin loading—signature mismatch = instant access violation
Solution: Add new method, keep old one:
delphi
type
TFLMidiProcessor = class
// Keep existing (legacy path)
procedure ProcessMidiMessage(Msg: TMidiMessage); virtual;
// New MPE-aware path (opt-in)
procedure ProcessMidiMessageEx(var Msg: TFLMidiMsgEx); virtual;
end;
// In router:
if Assigned(Plugin.ProcessMidiMessageEx) then
Plugin.ProcessMidiMessageEx(MsgEx)
else
Plugin.ProcessMidiMessage(MsgEx.Legacy); // Fallback to legacy3. Locking in Audio Thread
delphi
// From my code - THIS IS DANGEROUS:
procedure TFLVoiceAllocator.AllocateVoice(...);
begin
FLock.Enter; // <-- Can block audio thread!
try
// ... voice allocation logic
finally
FLock.Leave;
end;
end;Why it crashes:
- FL Studio's audio thread runs at
THREAD_PRIORITY_TIME_CRITICAL - If MIDI input thread holds the lock, audio thread blocks → glitches/dropouts
- Windows audio watchdog kills threads that block >10ms → crash
Solution: Lock-free voice allocation or pre-allocated message pools:
delphi
type
TFLVoiceAllocator = class
private
// Lock-free atomic operations
FVoicePool: array[2..16] of TFLVoiceState;
FAtomicVoiceMap: array[0..127] of Integer; // Atomic note->voice lookup
public
function AllocateVoice(NoteNum: Byte; MasterCh: Byte): Byte;
end;
function TFLVoiceAllocator.AllocateVoice(NoteNum, MasterCh: Byte): Byte;
var
i: Integer;
Expected, Desired: Integer;
begin
// Try lock-free allocation using atomic compare-exchange
for i := 2 to 16 do
begin
Expected := 0; // Voice not in use
Desired := NoteNum; // Claim it for this note
if AtomicCmpExchange(FAtomicVoiceMap[i], Desired, Expected) = Expected then
begin
// Successfully claimed voice i
FVoicePool[i].Channel := i;
FVoicePool[i].NoteNumber := NoteNum;
Result := i;
Exit;
end;
end;
Result := 0; // All voices busy
end;Things That MIGHT Break Old Projects
4. Channel Reassignment in Saved MIDI Data
Risk: If old projects have MIDI routed to channel 5, enabling MPE mode would re-route it to channels 2-16.
Impact:
- Old piano rolls showing notes on channel 5 would play on different channels
- Saved automation for channel 5 CCs would go to wrong channels
Solution: Per-project MPE flag in FLP file:
delphi
type
TProjectSettings = record
// ... existing fields
MPEEnabled: Boolean; // Default: False for old projects
MPELowerZoneSize: Byte; // Default: 0
MPEUpperZoneSize: Byte; // Default: 0
MPEVersion: Integer; // For future changes
end;
// When loading project:
procedure LoadProject(const Filename: string);
begin
Settings := ReadProjectSettings(Filename);
if Settings.MPEEnabled then
MPERouter.SetMPEMode(True, Settings.MPELowerZoneSize, Settings.MPEUpperZoneSize)
else
MPERouter.SetMPEMode(False, 0, 0); // Legacy mode
end;Backwards compatibility guarantee: Old FLP files won't have this flag → defaults to False → legacy mode → identical behavior.
5. Plugin State Corruption with Per-Channel Data
Risk: VST plugins save their state per-channel. If MPE mode changes channel routing, loaded plugin presets might have wrong data on wrong channels.
Example:
- User saves preset with Serum on channel 1 (filter cutoff automated)
- Enables MPE mode → notes now route to channels 2-16
- Loads preset → filter automation still targets channel 1 (no notes playing there)
Solution: Channel mapping translation layer:
Get William Ashley’s stories in your inbox
Join Medium for free to get updates from this writer.
delphi
procedure TPluginHost.LoadPreset(const Data: TBytes);
var
MPEActive: Boolean;
begin
MPEActive := MPERouter.Enabled;
if MPEActive then
begin
// Temporarily disable MPE, load preset, then re-enable
MPERouter.SetMPEMode(False, 0, 0);
InternalLoadPreset(Data);
MPERouter.SetMPEMode(True, FMPELowerZone, FMPEUpperZone);
end
else
InternalLoadPreset(Data);
end;Things That Are Safe (Won't Break Anything)
6. Adding TFLVoiceAllocator Class
It's a new class, doesn't modify existing structures.
7. Adding TFLMPERouter Class
As long as it's opt-in and doesn't replace existing code paths.
8. TThreadedQueue for Expression Messages
Delphi's System.Generics.Collections.TThreadedQueue is production-ready and used in many real-time apps.
9. High-Resolution Timestamps (GetTickCount64)
Safe - Read-only system call, no side effects.
CPU Load Increase Analysis
Baseline (Current FL Studio MIDI Processing)
delphi
// Pseudo-profile of existing pipeline:
procedure ProcessMidiMessage(Msg: TMidiMessage); // ~0.5 µs per message
begin
case Msg.Status of
$90: AddNoteToGlobalQueue(Msg); // ~0.2 µs (array append)
$B0: GlobalCCMap[Msg.Channel] := ...; // ~0.1 µs (array access)
end;
end;Current load: ~0.5 microseconds per MIDI message (negligible).
With MPE Implementation (Worst Case)
delphi
procedure ProcessMidiMessage(var Msg: TFLMidiMsgEx);
begin
// 1. Message upgrade (if from legacy)
if Msg.IsLegacy then
UpgradeMessage(Msg); // +0.1 µs (struct copy)
// 2. Voice allocation (note-on only)
if IsNoteOn(Msg) then
Msg.VoiceChannel := Allocator.AllocateVoice(...); // +2.0 µs (lock-free CAS loop)
// 3. Voice lookup (note-off/expression)
else if IsNoteOff(Msg) or IsExpression(Msg) then
Msg.VoiceChannel := Allocator.FindVoiceByNote(...); // +0.3 µs (atomic array read)
// 4. Pending expression flush (note-on only)
if IsNoteOn(Msg) then
FlushPendingExpressions(Msg.VoiceChannel); // +1.5 µs (queue drain, ~5 messages)
// 5. Route to plugin
RouteToChannel(Msg); // +0.2 µs (existing code)
end;Total per message:
- Legacy message: 0.5 µs (unchanged)
- MPE note-on: 0.5 + 0.1 + 2.0 + 1.5 + 0.2 = 4.3 µs
- MPE note-off: 0.5 + 0.1 + 0.3 + 0.2 = 1.1 µs
- MPE expression: 0.5 + 0.1 + 0.3 + 0.2 = 1.1 µs
Real-World Load Calculation
Scenario: Playing Roli Seaboard with heavy expression (worst case)
- 10 notes active simultaneously
- Each note sends:
- 1 note-on
- 50 pitchbend messages/second
- 20 pressure messages/second
- 10 timbre (CC74) messages/second
- 1 note-off
Total messages per second: 10 × (1 + 50 + 20 + 10 + 1) = 820 messages/sec
CPU time per second:
- Note-ons: 10 × 4.3 µs = 43 µs
- Note-offs: 10 × 1.1 µs = 11 µs
- Expression: 800 × 1.1 µs = 880 µs
Total: 934 µs/sec = 0.09% CPU time on a 1 GHz core (0.0009% on modern 4 GHz CPU)
Comparison to Other FL Studio Features
FeatureCPU Load per OperationCurrent MIDI0.5 µs/messageMPE Implementation1.1-4.3 µs/messageMixer FX Chain~50 µs/sample blockVST3 Plugin Processing~200-2000 µs/blockPiano Roll Rendering~500 µs/frame
Conclusion: MPE overhead is negligible compared to audio processing. Even with 1000 MPE messages/sec, it's <0.1% CPU.// ============================================================================
// FL Studio MPE/MIDI 2.0 Architecture Refactor
// Improved implementation addressing timing, voice management, and M2 support
// ============================================================================
unit MIDI.MPE.Engine;
interface
uses
System.Generics.Collections, System.SysUtils, System.DateUtils, System.SyncObjs;
type
// ============================================================================
// Core Data Structures
// ============================================================================
TMPEZone = (mzLower, mzUpper, mzNone);
// Extended MIDI message with note association context
TFLMidiMessage = record
Timestamp: Int64; // High-resolution timestamp for event ordering
Status: Byte; // MIDI status byte
Channel: Byte; // Original MIDI channel (1–16)
VoiceChannel: Byte; // Assigned voice channel (0 = not assigned)
Data1: Byte; // Note number or CC#
Data2: Byte; // Velocity or value
Data3: Byte; // MSB for 14-bit values (MIDI 2.0)
AssociatedNote: Byte; // Which note this expression is for (0 = none)
IsPerNote: Boolean; // True for note expression events
IsMIDI2: Boolean; // True for MIDI 2.0 UMP format
MPEZone: TMPEZone; // Which MPE zone this belongs to
end;
// Voice state with full expression tracking
TFLVoiceState = record
IsActive: Boolean;
NoteNumber: Byte;
Velocity: Byte;
Channel: Byte; // The channel this voice is assigned to
MasterChannel: Byte; // The MPE master channel (1 or 16)
AllocTime: Int64; // High-resolution timestamp
// Per-note expression state (MPE)
Pitchbend: Integer; // -8192 to +8191
Pressure: Byte; // Channel pressure (CC value or poly AT)
Timbre: Byte; // CC74 value
// Additional per-note CCs (user-configurable)
CC: array[0..127] of Byte;
end;
// ============================================================================
// Voice Allocator with MPE Zone Support
// ============================================================================
TFLVoiceAllocator = class
private
FVoicePool: array[2..16] of TFLVoiceState; // 15 voices max
FNoteToVoice: TDictionary<Byte, Byte>; // Note -> Channel lookup
FLock: TCriticalSection; // Thread safety
FLowerZoneSize: Byte; // Channels 2-N
FUpperZoneSize: Byte; // Channels (17-N)-16
FNextLowerVoice: Byte; // Round-robin for lower zone
FNextUpperVoice: Byte; // Round-robin for upper zone
function GetZoneForMasterChannel(MasterCh: Byte): TMPEZone;
function GetVoiceChannelsForZone(Zone: TMPEZone): TArray<Byte>;
function FindOldestVoiceInZone(Zone: TMPEZone): Byte;
public
constructor Create;
destructor Destroy; override;
procedure ConfigureMPEZones(LowerSize, UpperSize: Byte);
function AllocateVoice(NoteNum: Byte; MasterChannel: Byte): Byte;
procedure ReleaseVoice(NoteNum: Byte);
function FindVoiceByNote(NoteNum: Byte): Byte;
procedure UpdateVoiceExpression(VoiceCh: Byte; const Msg: TFLMidiMessage);
function GetVoiceState(VoiceCh: Byte): TFLVoiceState;
procedure ResetAllVoices;
end;
// ============================================================================
// MPE Message Router (replaces monolithic ProcessMidiMessage)
// ============================================================================
TFLMPERouter = class
private
FVoiceAllocator: TFLVoiceAllocator;
FEnabled: Boolean;
FLegacyMode: Boolean;
// Pending expression messages (solved the timing problem)
FPendingExpressions: TThreadedQueue<TFLMidiMessage>;
procedure RouteNoteOn(var Msg: TFLMidiMessage);
procedure RouteNoteOff(var Msg: TFLMidiMessage);
procedure RouteExpression(var Msg: TFLMidiMessage);
procedure RouteChannelMessage(var Msg: TFLMidiMessage);
procedure FlushPendingExpressions(VoiceChannel: Byte);
public
constructor Create;
destructor Destroy; override;
procedure ProcessMidiMessage(var Msg: TFLMidiMessage);
procedure SetMPEMode(Enabled: Boolean; LowerZone, UpperZone: Byte);
property Enabled: Boolean read FEnabled write FEnabled;
end;
implementation
// ============================================================================
// TFLVoiceAllocator Implementation
// ============================================================================
constructor TFLVoiceAllocator.Create;
var
i: Integer;
begin
inherited;
FLock := TCriticalSection.Create;
FNoteToVoice := TDictionary<Byte, Byte>.Create;
// Default: MPE Lower Zone with 15 voices (channels 2–16)
FLowerZoneSize := 15;
FUpperZoneSize := 0;
FNextLowerVoice := 2;
FNextUpperVoice := 16;
// Initialize voice pool
for i := 2 to 16 do
begin
FVoicePool[i].IsActive := False;
FVoicePool[i].Channel := i;
FVoicePool[i].Pitchbend := 0;
FillChar(FVoicePool[i].CC, SizeOf(FVoicePool[i].CC), 0);
end;
end;
destructor TFLVoiceAllocator.Destroy;
begin
FNoteToVoice.Free;
FLock.Free;
inherited;
end;
procedure TFLVoiceAllocator.ConfigureMPEZones(LowerSize, UpperSize: Byte);
begin
FLock.Enter;
try
// MPE spec: Lower zone uses channels 2-N, Upper zone uses (17-M)-16
FLowerZoneSize := Min(LowerSize, 15);
FUpperZoneSize := Min(UpperSize, 15 — FLowerZoneSize);
FNextLowerVoice := 2;
FNextUpperVoice := 16;
ResetAllVoices;
finally
FLock.Leave;
end;
end;
function TFLVoiceAllocator.GetZoneForMasterChannel(MasterCh: Byte): TMPEZone;
begin
if MasterCh = 1 then
Result := mzLower
else if MasterCh = 16 then
Result := mzUpper
else
Result := mzNone;
end;
function TFLVoiceAllocator.GetVoiceChannelsForZone(Zone: TMPEZone): TArray<Byte>;
var
i, Count: Integer;
begin
SetLength(Result, 0);
case Zone of
mzLower:
begin
SetLength(Result, FLowerZoneSize);
for i := 0 to FLowerZoneSize — 1 do
Result[i] := 2 + i;
end;
mzUpper:
begin
SetLength(Result, FUpperZoneSize);
for i := 0 to FUpperZoneSize — 1 do
Result[i] := 16 — i;
end;
end;
end;
function TFLVoiceAllocator.FindOldestVoiceInZone(Zone: TMPEZone): Byte;
var
Channels: TArray<Byte>;
i: Integer;
OldestTime: Int64;
OldestCh: Byte;
begin
Channels := GetVoiceChannelsForZone(Zone);
if Length(Channels) = 0 then
begin
Result := 0;
Exit;
end;
OldestTime := High(Int64);
OldestCh := Channels[0];
for i := 0 to High(Channels) do
begin
if FVoicePool[Channels[i]].IsActive and
(FVoicePool[Channels[i]].AllocTime < OldestTime) then
begin
OldestTime := FVoicePool[Channels[i]].AllocTime;
OldestCh := Channels[i];
end;
end;
Result := OldestCh;
end;
function TFLVoiceAllocator.AllocateVoice(NoteNum: Byte; MasterChannel: Byte): Byte;
var
Zone: TMPEZone;
Channels: TArray<Byte>;
i: Integer;
VoiceCh: Byte;
begin
FLock.Enter;
try
Zone := GetZoneForMasterChannel(MasterChannel);
if Zone = mzNone then
begin
Result := 0;
Exit;
end;
Channels := GetVoiceChannelsForZone(Zone);
// Try to find free voice in zone (round-robin)
for i := 0 to High(Channels) do
begin
VoiceCh := Channels[i];
if not FVoicePool[VoiceCh].IsActive then
begin
// Found free voice
FVoicePool[VoiceCh].IsActive := True;
FVoicePool[VoiceCh].NoteNumber := NoteNum;
FVoicePool[VoiceCh].MasterChannel := MasterChannel;
FVoicePool[VoiceCh].AllocTime := GetTickCount64;
FVoicePool[VoiceCh].Pitchbend := 0;
FVoicePool[VoiceCh].Pressure := 0;
FVoicePool[VoiceCh].Timbre := 64; // Center
FNoteToVoice.AddOrSetValue(NoteNum, VoiceCh);
Result := VoiceCh;
Exit;
end;
end;
// All voices busy — steal oldest
VoiceCh := FindOldestVoiceInZone(Zone);
if VoiceCh > 0 then
begin
// Send note-off for stolen voice before reallocation
// (This would be sent to FL_Engine in actual implementation)
FNoteToVoice.Remove(FVoicePool[VoiceCh].NoteNumber);
FVoicePool[VoiceCh].IsActive := True;
FVoicePool[VoiceCh].NoteNumber := NoteNum;
FVoicePool[VoiceCh].MasterChannel := MasterChannel;
FVoicePool[VoiceCh].AllocTime := GetTickCount64;
FVoicePool[VoiceCh].Pitchbend := 0;
FVoicePool[VoiceCh].Pressure := 0;
FVoicePool[VoiceCh].Timbre := 64;
FNoteToVoice.AddOrSetValue(NoteNum, VoiceCh);
end;
Result := VoiceCh;
finally
FLock.Leave;
end;
end;
procedure TFLVoiceAllocator.ReleaseVoice(NoteNum: Byte);
var
VoiceCh: Byte;
begin
FLock.Enter;
try
if FNoteToVoice.TryGetValue(NoteNum, VoiceCh) then
begin
FVoicePool[VoiceCh].IsActive := False;
FNoteToVoice.Remove(NoteNum);
end;
finally
FLock.Leave;
end;
end;
function TFLVoiceAllocator.FindVoiceByNote(NoteNum: Byte): Byte;
begin
FLock.Enter;
try
if not FNoteToVoice.TryGetValue(NoteNum, Result) then
Result := 0;
finally
FLock.Leave;
end;
end;
procedure TFLVoiceAllocator.UpdateVoiceExpression(VoiceCh: Byte;
const Msg: TFLMidiMessage);
begin
FLock.Enter;
try
if (VoiceCh < 2) or (VoiceCh > 16) then
Exit;
case Msg.Status and $F0 of
$E0: // Pitchbend
FVoicePool[VoiceCh].Pitchbend := (Msg.Data2 shl 7) or Msg.Data1–8192;
$D0: // Channel Pressure
FVoicePool[VoiceCh].Pressure := Msg.Data1;
$B0: // CC
begin
if Msg.Data1 = 74 then
FVoicePool[VoiceCh].Timbre := Msg.Data2
else
FVoicePool[VoiceCh].CC[Msg.Data1] := Msg.Data2;
end;
end;
finally
FLock.Leave;
end;
end;
function TFLVoiceAllocator.GetVoiceState(VoiceCh: Byte): TFLVoiceState;
begin
FLock.Enter;
try
if (VoiceCh >= 2) and (VoiceCh <= 16) then
Result := FVoicePool[VoiceCh]
else
FillChar(Result, SizeOf(Result), 0);
finally
FLock.Leave;
end;
end;
procedure TFLVoiceAllocator.ResetAllVoices;
var
i: Integer;
begin
FLock.Enter;
try
FNoteToVoice.Clear;
for i := 2 to 16 do
begin
FVoicePool[i].IsActive := False;
FVoicePool[i].Pitchbend := 0;
FVoicePool[i].Pressure := 0;
FVoicePool[i].Timbre := 64;
FillChar(FVoicePool[i].CC, SizeOf(FVoicePool[i].CC), 0);
end;
finally
FLock.Leave;
end;
end;
// ============================================================================
// TFLMPERouter Implementation
// ============================================================================
constructor TFLMPERouter.Create;
begin
inherited;
FVoiceAllocator := TFLVoiceAllocator.Create;
FPendingExpressions := TThreadedQueue<TFLMidiMessage>.Create(1024, 100, 0);
FEnabled := False;
FLegacyMode := False;
end;
destructor TFLMPERouter.Destroy;
begin
FVoiceAllocator.Free;
FPendingExpressions.Free;
inherited;
end;
procedure TFLMPERouter.SetMPEMode(Enabled: Boolean; LowerZone, UpperZone: Byte);
begin
FEnabled := Enabled;
if Enabled then
FVoiceAllocator.ConfigureMPEZones(LowerZone, UpperZone)
else
FVoiceAllocator.ResetAllVoices;
end;
procedure TFLMPERouter.RouteNoteOn(var Msg: TFLMidiMessage);
var
VoiceCh: Byte;
PendingMsg: TFLMidiMessage;
QueueSize: Integer;
begin
// Allocate voice from appropriate zone
VoiceCh := FVoiceAllocator.AllocateVoice(Msg.Data1, Msg.Channel);
if VoiceCh = 0 then
Exit; // No voices available
// Update message with voice channel assignment
Msg.VoiceChannel := VoiceCh;
Msg.AssociatedNote := Msg.Data1;
Msg.IsPerNote := True;
// Apply any pending expression for this note
FlushPendingExpressions(VoiceCh);
// Send to FL Engine (in actual implementation)
// FL_Engine.RouteNoteToChannel(VoiceCh, Msg);
end;
procedure TFLMPERouter.RouteNoteOff(var Msg: TFLMidiMessage);
var
VoiceCh: Byte;
begin
VoiceCh := FVoiceAllocator.FindVoiceByNote(Msg.Data1);
if VoiceCh > 0 then
begin
Msg.VoiceChannel := VoiceCh;
Msg.AssociatedNote := Msg.Data1;
Msg.IsPerNote := True;
// Send to FL Engine
// FL_Engine.RouteNoteOffToChannel(VoiceCh, Msg);
// Release voice after note-off
FVoiceAllocator.ReleaseVoice(Msg.Data1);
end;
end;
procedure TFLMPERouter.RouteExpression(var Msg: TFLMidiMessage);
var
Zone: TMPEZone;
Channels: TArray<Byte>;
i: Integer;
begin
// Expression messages on master channels (1 or 16) apply to all active voices
// Expression messages on voice channels apply to that specific voice
if (Msg.Channel = 1) or (Msg.Channel = 16) then
begin
// Master channel expression — could be global or per-note
// In MPE, expression on master is typically global
// But some controllers send per-note expression here too
// Strategy: Queue it and apply to next note-on
// This solves the timing problem from the original proposal
FPendingExpressions.PushItem(Msg);
end
else if (Msg.Channel >= 2) and (Msg.Channel <= 16) then
begin
// Voice channel expression — apply directly
FVoiceAllocator.UpdateVoiceExpression(Msg.Channel, Msg);
Msg.VoiceChannel := Msg.Channel;
Msg.IsPerNote := True;
// Send to FL Engine
// FL_Engine.RouteExpressionToChannel(Msg.Channel, Msg);
end;
end;
procedure TFLMPERouter.RouteChannelMessage(var Msg: TFLMidiMessage);
begin
// Non-note messages on master channel are global
if (Msg.Channel = 1) or (Msg.Channel = 16) then
begin
Msg.IsPerNote := False;
// FL_Engine.RouteGlobalMessage(Msg);
end
else
begin
// Voice channel messages
Msg.VoiceChannel := Msg.Channel;
Msg.IsPerNote := True;
// FL_Engine.RouteToChannel(Msg.Channel, Msg);
end;
end;
procedure TFLMPERouter.FlushPendingExpressions(VoiceChannel: Byte);
var
Msg: TFLMidiMessage;
TempQueue: TList<TFLMidiMessage>;
i: Integer;
begin
TempQueue := TList<TFLMidiMessage>.Create;
try
// Drain queue
while FPendingExpressions.PopItem(Msg) = TWaitResult.wrSignaled do
TempQueue.Add(Msg);
// Apply recent expressions to this voice
for i := 0 to TempQueue.Count — 1 do
begin
Msg := TempQueue[i];
FVoiceAllocator.UpdateVoiceExpression(VoiceChannel, Msg);
Msg.VoiceChannel := VoiceChannel;
Msg.IsPerNote := True;
// FL_Engine.RouteExpressionToChannel(VoiceChannel, Msg);
end;
finally
TempQueue.Free;
end;
end;
procedure TFLMPERouter.ProcessMidiMessage(var Msg: TFLMidiMessage);
begin
if not FEnabled then
begin
// Legacy mode — pass through unchanged
// FL_Engine.ProcessLegacyMessage(Msg);
Exit;
end;
// MPE Mode — route based on message type
case Msg.Status and $F0 of
$90: // Note On
RouteNoteOn(Msg);
$80: // Note Off
RouteNoteOff(Msg);
$E0, // Pitchbend
$D0, // Channel Pressure
$B0: // CC (when it’s expression CC74)
begin
if (Msg.Status and $F0 = $B0) and (Msg.Data1 = 74) then
RouteExpression(Msg)
else
RouteChannelMessage(Msg);
end;
else
RouteChannelMessage(Msg);
end;
end;
end.





