Skip to content

Instantly share code, notes, and snippets.

@BeRo1985
Last active January 3, 2026 12:12
Show Gist options
  • Select an option

  • Save BeRo1985/132ca45e397ebf0bc63af180683c8285 to your computer and use it in GitHub Desktop.

Select an option

Save BeRo1985/132ca45e397ebf0bc63af180683c8285 to your computer and use it in GitHub Desktop.
Linked Modulators in Sobanth: Implementing SF2's Under-Specified Feature

Linked Modulators in Sobanth

Overview

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.

SF2 Specification Background

The SoundFont 2.04 specification defines linked modulators through:

  1. Source Modulator (sfModSrcOper): When the source index is 127 (MOD_LINK_SRC), the modulator takes its input from another modulator's output.

  2. 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

Sobanth's Layer Tree Architecture

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.

Layer Tree vs. SF2 Zones

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;

Selectors

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
      );

Extended Features

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

SF2 Import Mapping

When loading a SoundFont, Sobanth maps the SF2 structure to the layer tree:

  • A root layer RootPresetLayer as the top-level container for all presets
  • Each SF2 preset becomes a child TPresetLayer of RootPresetLayer with bank/program selectors
  • Each SF2 instrument zone referenced via preset zones creates a child TPresetLayer with 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/lstVELOCITYRANGE selectors
  • 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.

Sobanth's Implementation

Constants

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 ID

Modulator Data Structure

Each 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;

Preset Layer Support

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;

Phase 1: Building the Dependency Graph

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;

Phase 2: Topological Sorting

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.

Phase 3: Runtime Evaluation

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;

Retrieving Linked Source Values

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;

Loading Linked Modulators from SF2

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;

ID Groups

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.

Key Design Decisions

  1. Iterative Topological Sort: Uses an iterative stack-based approach instead of recursion to avoid stack overflow with deeply nested modulator chains.

  2. Dynamic Array Sizing: The ModulatorSourceValues array is sized dynamically based on the modulator count.

  3. Summing Multiple Sources: If multiple modulators target the same destination modulator, their outputs are summed.

  4. Transform Application: The standard SF2 transforms (linear/concave/convex × unipolar/bipolar × positive/negative) are applied to linked source values just like any other source.

  5. Fast Path: When HasLinkedModulators is false, the simpler unordered evaluation path is used for better performance.

Example: Modulation Wheel to LFO to Vibrato Depth

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

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment