Linked modulators (also known as "modulator-to-modulator" connections) are a feature of the SoundFont 2 specification that allows one modulator's output to be used as the input source for another modulator. This enables complex modulation routings that would be impossible with simple generator-targeting modulators alone.
Note: Sobanth was one of the first (if not the first) software SoundFont synthesizers to implement linked modulator support, predating FluidSynth's failed implementation attempt. As noted in the FluidSynth issue tracker:
"One more comment: unless I missed something in the spec, then linked modulators are a little 'under specified'. Meaning that there is no single correct way to implement linked modulators."
This document describes how Sobanth implements this "under-specified" SF2 feature.
The SoundFont 2.04 specification defines linked modulators through:
-
Source Modulator (
sfModSrcOper): When the source index is127(MOD_LINK_SRC), the modulator takes its input from another modulator's output. -
Destination Modulator (
sfModDestOper): When bit 15 ($8000/MOD_LINK_DEST) is set, the destination is another modulator (identified by the lower 15 bits) rather than a generator.
The specification leaves several aspects under-specified:
- Evaluation order of linked modulators
- Handling of circular dependencies
- How to resolve modulator IDs within zones
Before diving into the linked modulator implementation, it's important to understand that Sobanth does not use SF2's native instrument/preset zone structure internally. Instead, Sobanth converts all loaded SoundFonts into its own recursive layer tree system, which is more flexible and powerful.
In SF2, sounds are organized as:
- Presets containing preset zones
- Instruments containing instrument zones
- Each zone has generators and modulators
Sobanth converts this flat two-level hierarchy into a unified recursive tree of TPresetLayer nodes:
type TPresetLayer=class(TSynthClass)
public
Parent:TPresetLayer; // Parent layer in tree
Layers:TPresetLayerList; // Child layers (recursive)
Selectors:TPresetLayerSelectorList; // Conditions for layer activation
Generators:TPresetLayerGeneratorList; // SF2 generators
Modulators:TPresetLayerModulatorList; // SF2 modulators
OrderedModulators:TPresetLayerModulatorList; // Topologically sorted
Crossfades:TPresetLayerCrossFades; // CC-controlled crossfades
VolEnvelope:TPresetLayerEnvelope; // Custom volume envelope
ModEnvelope:TPresetLayerEnvelope; // Custom modulation envelope
Mod2Envelope:TPresetLayerEnvelope; // Additional modulation envelope
CuePoints:TPresetLayerCuePoints; // Sample cue points
Sample:TSample; // Associated sample
HasLinkedModulators:boolean; // Quick check flag
end;Layers use selectors to define when they are active. This generalizes SF2's key/velocity ranges:
type TPresetLayerSelectorType=
(
lstNONE=0,
lstBANKRANGE=1, // MIDI bank range
lstPROGRAMRANGE=2, // MIDI program range
lstNOTERANGE=3, // Key range (SF2 keyRange)
lstVELOCITYRANGE=4, // Velocity range (SF2 velRange)
lstCONTROLLER7BITRANGE=5, // 7-bit CC range
lstCONTROLLER14BITRANGE=6,// 14-bit CC range
lstCHANNELRANGE=7, // MIDI channel range
lstPITCHBENDRANGE=8, // Pitch bend range
lstCHANNELPRESSURERANGE=9,// Channel pressure range
lstKEYPRESSURERANGE=10 // Polyphonic key pressure range
);The layer tree system provides capabilities beyond SF2:
- Crossfades: Controller-based layer crossfading (e.g., velocity crossfades, CC-controlled morphing)
- Custom Envelopes: Multi-point envelopes with loop support, beyond SF2's ADSR
- Cue Points: Sample cue point support for complex playback
- Recursive Nesting: Unlimited layer depth for complex instrument structures
- Flexible Selectors: Layers can be activated by any MIDI parameter, not just key/velocity
When loading a SoundFont, Sobanth maps the SF2 structure to the layer tree:
- A root layer
RootPresetLayeras the top-level container for all presets - Each SF2 preset becomes a child
TPresetLayerofRootPresetLayerwith bank/program selectors - Each SF2 instrument zone referenced via preset zones creates a child
TPresetLayerwith its generators and modulators - Each SF2 preset zone's generators and modulators are then merged into its referenced instrument zone layers
- Key/velocity ranges become
lstNOTERANGE/lstVELOCITYRANGEselectors - Generators and modulators are preserved with their ID groups for proper linking
This architecture allows Sobanth to support both SF2 files and more advanced instrument formats using the same internal representation, while keeping the full compliance with the SF2 specification at the same time.
There is no array of bank/program presets in the Sobanth design, as all presets are simply children of the root layer, allowing for easy navigation and management. The whole bank/program structure concept in the UI is just virtual for the user as a visual aspect, with cached lookup arrays mapping bank/program numbers to layer tree nodes for presenting presets in a familiar way. This approach allows also the possibility to have multiple presets with the same bank/program numbers without any conflicts, when multiple SF2 files are loaded simultaneously. Exactly that flexibility is exploited by Sobanth when loading multiple SF2 files for flexible on-the-fly loading and unloading of instruments and whole SF2 files.
A work-in-progress SFX (SoundFont eXtended) file format is planned to fully expose these extended capabilities, allowing sound designers to take advantage of the layer tree system's full potential beyond the limitations of the SF2 specification.
const { Modulation source types }
MOD_LINK_SRC=127; // Source is another modulator's output
{ Modulation destination types }
MOD_LINK_DEST=$8000; // Destination is a modulator (bit flag)
MOD_LINK_DEST_MASK=$7fff; // Mask to extract destination modulator IDEach modulator (TPresetLayerModulator) contains fields to support linking:
type TPresetLayerModulator=class(TSynthClass)
private
fName:UTF8String;
fIndex:Int32;
fID:Int32; // Modulator ID for linking
fIDGroup:Int32; // ID group for zone-level matching
fDest:Int32; // Destination (generator or modulator)
fSrc1:Int32; // Primary source
fFlags1:Int32; // Primary source flags
fSrc2:Int32; // Secondary source
fFlags2:Int32; // Secondary source flags
fAmount:double; // Modulation amount
fLinkedDestinationModulator:Int32; // Index of destination modulator (-1 if none)
fLinkedSourceModulators:TPresetLayerModulatorList; // List of modulators feeding into this one
fProcessed:boolean; // Flag for topological sort
public
// Properties...
property LinkedDestinationModulator:Int32 read fLinkedDestinationModulator;
property LinkedSourceModulators:TPresetLayerModulatorList read fLinkedSourceModulators;
end;The preset layer tracks whether linked modulators are present:
type TPresetLayer=class(TSynthClass)
public
Modulators:TPresetLayerModulatorList; // All modulators
OrderedModulators:TPresetLayerModulatorList; // Topologically sorted (for linked mods)
HasLinkedModulators:boolean; // Quick check flag
procedure SetupLinkedModulators; // Build dependency graph and sort
end;When a SoundFont is loaded, SetupLinkedModulators is called to build the modulator dependency graph:
procedure TPresetLayer.SetupLinkedModulators;
type TStackItem=record
Modulator:TPresetLayerModulator;
Phase:Int32;
end;
var Index,OtherIndex,
DestinationModulator,DestinationModulatorID,DestinationModulatorIDGroup,
StackPointer:Int32;
Modulator,OtherModulator:TPresetLayerModulator;
StackItems:array of TStackItem;
begin
HasLinkedModulators:=false;
// Clear previous linking data
for Index:=0 to Modulators.Count-1 do begin
Modulator:=Modulators[Index];
Modulator.fLinkedSourceModulators.Clear;
Modulator.fProcessed:=false;
end;
// Build linked source lists
for Index:=0 to Modulators.Count-1 do begin
Modulator:=Modulators[Index];
// Check if destination is another modulator (bit 15 set)
if (Modulator.fDest>0) and ((Modulator.fDest and MOD_LINK_DEST)<>0) then begin
DestinationModulator:=-1;
DestinationModulatorID:=Modulator.fDest and MOD_LINK_DEST_MASK;
DestinationModulatorIDGroup:=Modulator.fIDGroup;
// Find the destination modulator by ID within the same ID group
for OtherIndex:=0 to Modulators.Count-1 do begin
OtherModulator:=Modulators[OtherIndex];
if (Index<>OtherIndex) and
(OtherModulator.fIDGroup=DestinationModulatorIDGroup) and
(OtherModulator.fID=DestinationModulatorID) then begin
DestinationModulator:=OtherIndex;
break;
end;
end;
Modulator.fLinkedDestinationModulator:=DestinationModulator;
// Add this modulator to the destination's source list
if (DestinationModulator>=0) and (DestinationModulator<Modulators.Count) then begin
Modulators[DestinationModulator].fLinkedSourceModulators.Add(Modulator);
HasLinkedModulators:=true;
end;
end;
end;
// ... Phase 2: Topological sort follows
end;If linked modulators are present, Sobanth performs a topological sort to ensure correct evaluation order. This guarantees that source modulators are always evaluated before the modulators they feed into:
OrderedModulators.Clear;
if HasLinkedModulators then begin
SetLength(StackItems,Modulators.Count);
// Initialize stack with all modulators
for Index:=0 to Modulators.Count-1 do begin
StackItems[Index].Modulator:=Modulators[Index];
StackItems[Index].Phase:=0;
end;
StackPointer:=Modulators.Count;
// Iterative depth-first traversal
while StackPointer>0 do begin
dec(StackPointer);
Modulator:=StackItems[StackPointer].Modulator;
case StackItems[StackPointer].Phase of
0:begin // First visit
if not Modulator.fProcessed then begin
Modulator.fProcessed:=true;
// Push all source modulators onto stack (process them first)
for Index:=0 to Modulator.fLinkedSourceModulators.Count-1 do begin
inc(StackPointer);
if length(StackItems)<=StackPointer then begin
SetLength(StackItems,(StackPointer+1)*2);
end;
StackItems[StackPointer-1].Modulator:=Modulator.LinkedSourceModulators[Index];
StackItems[StackPointer-1].Phase:=0;
end;
// Push self back with Phase=1 (to be added after sources)
inc(StackPointer);
if length(StackItems)<=StackPointer then begin
SetLength(StackItems,(StackPointer+1)*2);
end;
StackItems[StackPointer-1].Modulator:=Modulator;
StackItems[StackPointer-1].Phase:=1;
end;
end;
1:begin // Post-order: add to ordered list
OrderedModulators.Add(Modulator);
end;
end;
end;
end;The result is OrderedModulators containing all modulators in an order where each modulator appears after all the modulators that feed into it.
During voice processing, modulators are evaluated using the ordered list. The ModulatorSourceValues array stores intermediate results:
procedure TVoice.ModulateAll;
var Index,Generator:Int32;
CurrentLayer:TPresetLayer;
Modulator:TPresetLayerModulator;
Modulators:TPresetLayerModulatorList;
begin
// Clear generator modulation values
for Index:=Low(TGenWorkValues) to High(TGenWorkValues) do begin
GenModValues[Index]:=0.0;
end;
CurrentLayer:=PresetLayer;
while assigned(CurrentLayer) do begin
// Use ordered list if linked modulators exist
if CurrentLayer.HasLinkedModulators then begin
Modulators:=CurrentLayer.OrderedModulators;
// Allocate and clear source values array
if length(ModulatorSourceValues)<Modulators.Count then begin
SetLength(ModulatorSourceValues,Modulators.Count);
end;
if Modulators.Count>0 then begin
FillChar(ModulatorSourceValues[0],Modulators.Count*SizeOf(Double),0);
end;
end else begin
Modulators:=CurrentLayer.Modulators;
end;
// Evaluate each modulator
for Index:=0 to Modulators.Count-1 do begin
Modulator:=Modulators[Index];
// Check if destination is another modulator
if (Modulator.fDest>0) and ((Modulator.fDest and MOD_LINK_DEST)<>0) then begin
// Add output to destination modulator's source value
if CurrentLayer.HasLinkedModulators and
(Modulator.fLinkedDestinationModulator>=0) and
(Modulator.fLinkedDestinationModulator<Modulators.Count) then begin
ModulatorSourceValues[Modulator.fLinkedDestinationModulator]:=ModulatorSourceValues[Modulator.fLinkedDestinationModulator]+GetModulatorValue(Modulator);
end;
end else begin
// Add output to generator
Generator:=Modulator.fDest;
if (Generator>=Low(TGenWorkValues)) and (Generator<=High(TGenWorkValues)) then begin
GenModValues[Generator]:=GenModValues[Generator]+GetModulatorValue(Modulator);
end;
end;
end;
CurrentLayer:=CurrentLayer.Parent;
end;
// Update all generator parameters
for Index:=Low(TGenWorkValues) to High(TGenWorkValues) do begin
UpdateParameter(Index);
end;
end;When a modulator has MOD_LINK_SRC (127) as its source, the value is retrieved from the ModulatorSourceValues array:
function TVoice.GetModulatorSrcValue(const aModulatorIndex,aSrc,aFlags:Int32;
const aSecond:boolean):Double;
begin
// ... other source types ...
case aSrc of
MOD_LINK_SRC:begin
// Get value from source modulators that feed into this one
if (aModulatorIndex>=0) and (aModulatorIndex<length(ModulatorSourceValues)) then begin
result:=ModulatorSourceValues[aModulatorIndex];
end else begin
if aSecond then begin
result:=Range; // Secondary source defaults to 1.0 (normalized)
end else begin
result:=0.0; // Primary source defaults to 0.0
end;
end;
end;
// ... other source types ...
end;
// Apply transform (linear/concave/convex, unipolar/bipolar, positive/negative)
// ...
end;When parsing SF2 modulator records, Sobanth converts the destination field:
procedure AddModulator(...);
var Dest:Int32;
begin
Dest:=aModulator.ModDstOper;
// Convert SF2 link flag to internal format
if (Dest>0) and ((Dest and $8000)<>0) then begin
Dest:=(Dest and $7fff) or MOD_LINK_DEST;
end;
// ... create modulator with this destination ...
end;Sobanth uses "ID groups" to ensure modulators are linked within the correct scope:
- Group 0: Default modulators (DMOD chunk or hardcoded SF2 defaults)
- Group 1: Global instrument modulators
- Group 2: Instrument zone modulators
- Group 3: Global preset modulators
- Group 4: Preset zone modulators
This ensures a modulator in an instrument zone only links to other modulators in the same instrument zone, not to preset modulators with the same ID.
-
Iterative Topological Sort: Uses an iterative stack-based approach instead of recursion to avoid stack overflow with deeply nested modulator chains.
-
Dynamic Array Sizing: The
ModulatorSourceValuesarray is sized dynamically based on the modulator count. -
Summing Multiple Sources: If multiple modulators target the same destination modulator, their outputs are summed.
-
Transform Application: The standard SF2 transforms (linear/concave/convex × unipolar/bipolar × positive/negative) are applied to linked source values just like any other source.
-
Fast Path: When
HasLinkedModulatorsisfalse, the simpler unordered evaluation path is used for better performance.
A common use case for linked modulators is having the mod wheel control how much an LFO affects vibrato:
Mod Wheel (CC1) → [Modulator A] → [Modulator B] → vibLfoToPitch
↓ amount uses MOD_LINK_SRC
targets Modulator B (via MOD_LINK_DEST)
In this setup:
- Modulator A takes CC1 as input and outputs to Modulator B
- Modulator B uses
MOD_LINK_SRC(127) as its source, multiplies by the LFO amount - The result modulates
vibLfoToPitch
Sobanth's linked modulator implementation provides:
- Full support for the SF2.04 modulator linking specification
- Correct topological ordering for dependency resolution
- ID group-based scoping for proper zone isolation
- Efficient runtime evaluation with fast-path optimization
This implementation predates FluidSynth's support attempt and represents one of the earliest complete implementations of this "under-specified" SF2 feature.