Repository navigation
custom_synth_sdk
The Polyend Tracker Custom Synth Engine SDK (synth/include/PolySynth/sdk/) lets developers create, register, and customize polyphonic real-time synthesizer engines on Polyend Tracker+ and Polyend Tracker Mini (NXP i.MX RT1064, ARM Cortex-M7 @ 600 MHz) without requiring the proprietary PolyDSP source code.
- Architecture Overview
- SDK Headers Reference
- Voice Lifecycle & Audio Rendering Model
-
Step-by-Step Tutorial: Creating a Custom Synth Engine
- Step 1: Add an
InstrumentTypeIdEnum Value - Step 2: Define Your Parameter Enum
- Step 3: Implement Your Voice Class
- Step 4: Define Parameters, UI Sections & Default Macros
- Step 5: Register the Engine in
core/synth/registry.cpp - Step 6: Add UI Badges & Wire into the Tracker UI
- Step 7: Build & Flash
.ptfFirmware
- Step 1: Add an
- Patch Metadata, Curves, Macros & UI Layout Reference
- DSP Utilities & Value Formatters
- Real-Time Audio, CPU & Memory Guidelines
- Reference Implementation:
CHIP(ChiptuneSynthVoice)
The Tracker synthesizer subsystem uses a hybrid architecture that combines precompiled factory libraries with an open-source C++20 SDK:
┌─────────────────────────────────────────────────────────────────────────┐
│ Tracker Application │
│ ┌─────────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ core/synth/Synth.cpp │ │ SynthEditor.cpp / SynthPatchEditor │ │
│ └────────────┬────────────┘ └──────────────────┬───────────────────┘ │
│ │ │ │
│ ┌────────────▼──────────────────────────────────▼───────────────────┐ │
│ │ core/synth/registry.cpp (Open Source) │ │
│ │ Registers Factory Engines (ACD, FAT, VAP, WTFM, PERC, DIRT) │ │
│ │ + Open-Source Custom Engines (CHIP, your custom engines...) │ │
│ └────────────┬──────────────────────────────────┬───────────────────┘ │
└───────────────┼──────────────────────────────────┼──────────────────────┘
│ │
┌───────────────▼────────────────┐ ┌───────────────▼──────────────────────┐
│ Precompiled Factory Archives │ │ Open-Source Synth Engines │
│ • libPolySynth.a (no registry)│ │ • synth/include/PolySynth/sdk/ │
│ • libPolyDSP.a │ │ • ChiptuneSynthVoice.cpp (CHIP) │
│ (Stripped, ABI-stable) │ │ • YourCustomSynthVoice.cpp │
└────────────────────────────────┘ └──────────────────────────────────────┘
-
Factory Synth Libraries (
libPolySynth.a+libPolyDSP.a): Provide the corePolySynthengine, voice allocator, patch serializer, macro modulation matrix, and the 6 factory synth engines (ACD,FAT,VAP,WTFM,PERC,DIRT). -
Open-Source Registration (
core/synth/registry.cpp):registry.ois extracted fromlibPolySynth.aand compiled directly insideTracker, giving firmware developers full control over which synth engines are registered. -
Zero
PolyDSPDependency: Custom synth engines only includePolySynth/sdk/SynthVoiceSDK.h.
All SDK headers live in synth/include/PolySynth/sdk/:
| Header | Purpose |
|---|---|
InstrumentType.h |
Defines polysynth::InstrumentType (name, createMetadata, createVoice). |
InstrumentVoice.h |
Pure virtual voice interface (onMidiEvent, onParamChange, renderVoiceAudio, renderPostProcessAudio, note query methods). |
InstrumentTypeRegistry.h |
Defines InstrumentTypeRegistry::addInstrumentType(int id, std::unique_ptr<InstrumentType>) and registerInstrumentTypes(). |
SynthVoiceSDK.h |
Header-only C++20 SDK base class CustomSynthVoice, ParamHelper, DSP math utilities, standard MidiCCs, and value string formatters. |
Supporting public headers in synth/include/PolySynth/:
-
Core.h: Fundamental types (sample_t = float,note_t = uint8_t,AudioBufferRef,AudioBufferStereoRef, constantskNoteA4 = 69,kMaxMacroCount = 8,kUserMacroCount = 6). -
Patch.h:PatchandPatch::Metadata(Param,Macro,EditorLayout,OutputChannels). -
MidiEvent.h:MidiEventstructure passed toonMidiEvent. -
config.h:InstrumentTypeIdenum.
The Tracker audio engine supports 3 active synth slots (SYN 1, SYN 2, SYN 3) with up to 8 voices total allocated dynamically across those 3 slots.
-
Engine Selection: When a user selects your synth engine on
SYN 1–SYN 3(or loads a project/patch using it),PolySynth:- Calls your static
createMetadata(const Patch&, Patch::Metadata&)function once to build the parameter table, UI layout, and default macros. - Calls your
createVoice()factory up tometadata.maxVoicestimes (up to 8 voices) to instantiate yourCustomSynthVoicesubclass on the heap. - Sends initial
onParamChange(paramId, defaultValue)calls for every parameter so your voice state is synchronized with the patch.
- Calls your static
-
Parameter & Macro Updates (
onParamChange):- Whenever the user turns a knob, changes a macro, sends MIDI CC, or triggers a step FX locking a macro/param,
PolySynthcomputes the final modulated parameter value (base parameter + all active macro modifiers + per-voice velocity/aftertouch modifiers) and callsonParamChange(paramId, newValue). -
CustomSynthVoiceautomatically dispatchesonParamChangein$O(1)$ time via a static table of 4-byte function pointers (paramHandlers) registered increateMetadata().
- Whenever the user turns a knob, changes a macro, sends MIDI CC, or triggers a step FX locking a macro/param,
-
Note & Transport Events (
onMidiEvent):CustomSynthVoicehandlesMidiEventmessages, maintains a per-voice held-note stack (heldNotes, supporting mono/legato note priority and polyphonic voice stealing), trackspitchBendSemitones($\pm 2$ semitones), and calls your virtual hooks:-
onNoteOn(const InstrumentVoiceContext& ctx, note_t note, float velocity, bool isLegatoStep):-
note: MIDI note number (0–127, where60= C4 and69= A4 =440 Hz). -
velocity: Normalized velocity in[0.0f, 1.0f]. -
isLegatoStep:truewhenplayMode == PlayMode::kLegatoand a previous note was still held on this voice (allowing you to glide pitch without retriggering envelopes).
-
-
onNoteOff(const InstrumentVoiceContext& ctx, note_t note):- Called when the last held note on this voice is released. If an earlier note was still held (in mono/legato overlap),
CustomSynthVoiceautomatically retriggersonNoteOnfor the previously held note instead!
- Called when the last held note on this voice is released. If an earlier note was still held (in mono/legato overlap),
-
onStart()/onClock():- Optional hooks for MIDI Start and 24 PPQN MIDI Clock ticks.
-
-
Per-Voice Audio Rendering (
renderVoiceAudio): Called once per audio block (128samples at44100 Hz, i.e. every2.902 ms) for each allocated voice of the synth slot:void renderVoiceAudio(const InstrumentVoiceContext& ctx, std::optional<AudioBufferStereoRef> inputBuffer, AudioBufferStereoRef outputBuffer, std::span<AudioBufferRef> auxOutBuffer) override;
-
ctx.tempo: Current project tempo in BPM. -
outputBuffer: Pair ofstd::span<float>(outputBuffer[0]= Left / Mono channel,outputBuffer[1]= Right channel), each128samples long.- If
metadata.outputChannels == Patch::Metadata::OutputChannels::kMono, write your voice output tooutputBuffer[0](PolySynthautomatically pans/copies mono voices to stereo at the instrument mixer stage). - If
metadata.outputChannels == Patch::Metadata::OutputChannels::kStereo, write to bothoutputBuffer[0]andoutputBuffer[1]. -
Important: Always overwrite every sample in
outputBuffer[0](andoutputBuffer[1]if stereo). If!isActive(), fill the output buffer with0.0fand return.
- If
-
auxOutBuffer: Optional per-voice send buffers (auxOutBuffer[0]= Delay send,auxOutBuffer[1]= Reverb send), only used whenmetadata.rendersAuxSends = true(e.g.PERCdrum synth where each drum voice has independent send levels). Standard synths leavemetadata.rendersAuxSends = false, and the Tracker mixer handles Delay/Reverb sends automatically.
-
-
Post-Mixdown Audio Rendering (
renderPostProcessAudio):void renderPostProcessAudio(const InstrumentVoiceContext& ctx, AudioBufferStereoRef outputBuffer) override;
- Called once per audio block on the first voice (
voice[0]) only, after all voices of the instrument have been summed together intooutputBuffer. - Useful for shared master saturation, master filtering, or bus effects across all voices of the synth.
- Called once per audio block on the first voice (
Below is the complete end-to-end workflow for adding a new synth engine (e.g. kMySynth / "MY").
Edit synth/include/PolySynth/config.h and add your new enum entry immediately before kLastSynth, then update kLastSynth:
enum class InstrumentTypeId
{
kMidiOut,
kSimpleAnalogSynth,
kFatAnalogSynth,
kWaveTableSynth,
kVASynth,
kFMSynth,
kDrumSynth,
kShapingSynth,
kChiptuneSynth,
kMySynth,
kLastSynth = kMySynth,
// Keep FX IDs at 1000+
kDelay = 1000,
kReverb,
kLastFx = kReverb
};Compatibility Rule: Never reorder existing
InstrumentTypeIdvalues above your entry. Existing project and.pti/.ptspatch files serialize the integerInstrumentTypeId.
Create your header core/synth/engines/MySynthVoice.h and implementation core/synth/engines/MySynthVoice.cpp.
Define a contiguous, zero-based enum class for your parameters:
enum class MySynthParamId : int
{
// Oscillator
kWaveform = 0,
kDetune,
// Filter
kCutoff,
kResonance,
// Amplifier
kAttack,
kRelease,
// Voice
kPolyphony,
kVolume,
};Important (
enumutil::EnumMaxLimit):CustomSynthVoiceusesenumutil::EnumMax<ParamIdEnum>::valueto automatically size the staticparamHandlersarray at compile time. By default,enum_util.hscans enum values in the range[-5, 64]. Keep your enum values contiguous starting at0and<= 64.
Inherit from polysynth::sdk::CustomSynthVoice<MySynthVoice, MySynthParamId>:
// core/synth/engines/MySynthVoice.h
#pragma once
#include "PolySynth/sdk/InstrumentType.h"
#include <memory>
namespace polysynth
{
std::unique_ptr<InstrumentType> createMySynth();
} // namespace polysynth// core/synth/engines/MySynthVoice.cpp
#include "MySynthVoice.h"
#include "config.h"
#ifdef FEATURE_SYNTH
#include "PolySynth/sdk/SynthVoiceSDK.h"
#include <algorithm>
#include <cmath>
namespace polysynth
{
namespace
{
enum class Waveform : int
{
kSaw = 0,
kSquare = 1,
kTriangle = 2,
};
const char* toString(Waveform w)
{
switch (w)
{
case Waveform::kSaw: return "Saw";
case Waveform::kSquare: return "Square";
case Waveform::kTriangle: return "Triangle";
}
return "";
}
enum class PolyphonyMode : int
{
kMono = 0,
kLegato = 1,
kPoly = 2,
};
const char* toString(PolyphonyMode m)
{
switch (m)
{
case PolyphonyMode::kMono: return "Mono";
case PolyphonyMode::kLegato: return "Legato";
case PolyphonyMode::kPoly: return "Poly";
}
return "";
}
} // namespace
enum class MySynthParamId : int
{
kWaveform = 0,
kDetune,
kCutoff,
kResonance,
kAttack,
kRelease,
kPolyphony,
kVolume,
};
class MySynthVoice : public sdk::CustomSynthVoice<MySynthVoice, MySynthParamId>
{
public:
MySynthVoice() = default;
bool isActive() final { return envStage != EnvStage::kIdle; }
void onNoteOn(const InstrumentVoiceContext& /*ctx*/,
note_t note,
float velocity,
bool isLegatoStep) final
{
currentNote = note;
noteVelocity = velocity;
if (!isLegatoStep || envStage == EnvStage::kIdle)
{
envStage = EnvStage::kAttack;
}
}
void onNoteOff(const InstrumentVoiceContext& /*ctx*/, note_t /*note*/) final
{
if (envStage != EnvStage::kIdle)
{
envStage = EnvStage::kRelease;
}
}
void renderVoiceAudio(const InstrumentVoiceContext& /*ctx*/,
std::optional<AudioBufferStereoRef> /*inputBuffer*/,
AudioBufferStereoRef outputBuffer,
std::span<AudioBufferRef> /*auxOutBuffer*/) final
{
auto out = outputBuffer[0];
if (!isActive())
{
std::fill(out.begin(), out.end(), 0.0f);
return;
}
const float freq =
sdk::noteToFreq(currentNote, pitchBendSemitones + detuneCents * 0.01f);
const float dt = std::clamp(freq / sdk::kSampleRate, 0.0001f, 0.49f);
const float atkStep = 1.0f / (std::max(0.001f, attackSec) * sdk::kSampleRate);
const float relStep = 1.0f / (std::max(0.002f, releaseSec) * sdk::kSampleRate);
const float f = std::clamp(
2.0f * std::sin(3.14159265f * std::min(cutoffHz, 18000.0f) / sdk::kSampleRate),
0.005f,
0.95f);
const float q = std::clamp(1.0f - resonance * 0.85f, 0.15f, 1.0f);
for (auto& sample : out)
{
// Envelope
if (envStage == EnvStage::kAttack)
{
envLevel += atkStep;
if (envLevel >= 1.0f)
{
envLevel = 1.0f;
envStage = EnvStage::kSustain;
}
}
else if (envStage == EnvStage::kRelease)
{
envLevel -= relStep;
if (envLevel <= 0.0f)
{
envLevel = 0.0f;
envStage = EnvStage::kIdle;
}
}
// Oscillator
phase += dt;
if (phase >= 1.0f)
{
phase -= 1.0f;
}
float sig = 0.0f;
switch (waveform)
{
case Waveform::kSaw:
sig = 2.0f * phase - 1.0f;
break;
case Waveform::kSquare:
sig = (phase < 0.5f) ? 1.0f : -1.0f;
break;
case Waveform::kTriangle:
sig = 1.0f - 4.0f * std::fabs(phase - 0.5f);
break;
}
// 2-pole Chamberlin SVF Lowpass
svfLow += f * svfBand;
float high = sig - svfLow - q * svfBand;
svfBand += f * high;
sample = std::clamp(svfLow * envLevel * noteVelocity * volumeGain, -1.0f, 1.0f);
}
}
static void createMetadata(Patch& patch, Patch::Metadata& metadata);
private:
enum class EnvStage : uint8_t
{
kIdle,
kAttack,
kSustain,
kRelease,
};
Waveform waveform = Waveform::kSaw;
float detuneCents = 0.0f;
float cutoffHz = 8000.0f;
float resonance = 0.1f;
float attackSec = 0.005f;
float releaseSec = 0.25f;
float volumeGain = sdk::dbToAmplitude(-6.0f);
note_t currentNote = 60;
float noteVelocity = 1.0f;
float phase = 0.0f;
float envLevel = 0.0f;
EnvStage envStage = EnvStage::kIdle;
float svfLow = 0.0f;
float svfBand = 0.0f;
};Still inside MySynthVoice.cpp, implement MySynthVoice::createMetadata and the factory function createMySynth():
void MySynthVoice::createMetadata(Patch& patch, Patch::Metadata& metadata)
{
using ParamId = MySynthParamId;
using Param = Patch::Metadata::Param;
using EditorSection = Patch::Metadata::EditorLayout::EditorSection;
using EditorElement = Patch::Metadata::EditorLayout::EditorElement;
constexpr auto kParamCount = enumutil::EnumMax<ParamId>::value + 1;
metadata.instrumentTypeId = static_cast<int>(InstrumentTypeId::kMySynth);
metadata.outputChannels = Patch::Metadata::OutputChannels::kMono;
metadata.params.resize(kParamCount);
paramHandlers.resize(kParamCount);
sdk::ParamHelper<MySynthVoice> helper(metadata, paramHandlers);
helper.addParam(
static_cast<int>(ParamId::kWaveform),
{
.displayName = "Waveform",
.defaultValue = static_cast<float>(Waveform::kSaw),
.minValue = 0,
.maxValue = enumutil::EnumMax<Waveform>::value,
.isEnum = true,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kOscParam0),
.valueFormatter =
[](const Patch&, Patch::paramvalue_t v)
{ return toString(static_cast<Waveform>(std::lround(v))); },
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val)
{ v.waveform = static_cast<Waveform>(std::lround(*val)); });
helper.addParam(
static_cast<int>(ParamId::kDetune),
{
.displayName = "Fine Tune",
.defaultValue = 0.0f,
.minValue = -100.0f,
.maxValue = 100.0f,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kOscParam0) + 1,
.valueFormatter = sdk::createCentsFormatter(),
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val) { v.detuneCents = *val; });
helper.addParam(
static_cast<int>(ParamId::kCutoff),
{
.displayName = "Cutoff",
.defaultValue = 8000.0f,
.minValue = 40.0f,
.maxValue = 18000.0f,
.editCurve = Param::Curve::kExponential,
.macroOperation = Param::MacroOperation::kMult,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kFilterCutoff),
.valueFormatter = sdk::createFrequencyFormatter(),
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val) { v.cutoffHz = *val; });
helper.addParam(
static_cast<int>(ParamId::kResonance),
{
.displayName = "Resonance",
.defaultValue = 0.1f,
.minValue = 0.0f,
.maxValue = 1.0f,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kFilterResonance),
.valueFormatter = sdk::createPercentFormatter(),
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val) { v.resonance = *val; });
helper.addParam(
static_cast<int>(ParamId::kAttack),
{
.displayName = "Attack",
.defaultValue = 0.005f,
.minValue = 0.001f,
.maxValue = 4.0f,
.editCurve = Param::Curve::kExponential,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kAmpEnvAttack),
.valueFormatter = sdk::createTimeFormatter(),
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val) { v.attackSec = *val; });
helper.addParam(
static_cast<int>(ParamId::kRelease),
{
.displayName = "Release",
.defaultValue = 0.25f,
.minValue = 0.005f,
.maxValue = 8.0f,
.editCurve = Param::Curve::kExponential,
.midiCC = static_cast<uint8_t>(sdk::MidiCCs::kAmpEnvRelease),
.valueFormatter = sdk::createTimeFormatter(),
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val) { v.releaseSec = *val; });
helper.addParam(
static_cast<int>(ParamId::kPolyphony),
{
.displayName = "Play Mode",
.defaultValue = static_cast<float>(PolyphonyMode::kPoly),
.minValue = 0,
.maxValue = enumutil::EnumMax<PolyphonyMode>::value,
.isEnum = true,
.invalidatesOtherMetadata = true, // Re-evaluates maxVoices when changed!
.valueFormatter =
[](const Patch&, Patch::paramvalue_t v)
{ return toString(static_cast<PolyphonyMode>(std::lround(v))); },
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val)
{
auto mode = static_cast<PolyphonyMode>(std::lround(*val));
v.playMode = (mode == PolyphonyMode::kLegato) ? PlayMode::kLegato : PlayMode::kAlways;
});
helper.addParam(
static_cast<int>(ParamId::kVolume),
{
.displayName = "Gain",
.defaultValue = -6.0f,
.minValue = -30.0f,
.maxValue = 6.0f,
.valueFormatter = [](const Patch&, Patch::paramvalue_t v)
{ return fmt::format("{:.1f} dB", v); },
},
[](MySynthVoice& v, Patch::optional_paramvalue_t val)
{ v.volumeGain = sdk::dbToAmplitude(*val); });
// Dynamic maxVoices based on kPolyphony parameter
if (auto poly = patch.getParamValue(static_cast<int>(ParamId::kPolyphony)))
{
auto mode = static_cast<PolyphonyMode>(std::lround(*poly));
metadata.maxVoices = (mode == PolyphonyMode::kPoly) ? 8 : 1;
}
// Patch Editor UI Sections (layoutFlat)
auto paramElem = [](ParamId id)
{
return EditorElement{ .type = EditorElement::Type::kParam, .id = static_cast<int>(id) };
};
metadata.layoutFlat.sections = {
EditorSection{
.name = "Oscillator",
.shortName = "Osc",
.elements = { paramElem(ParamId::kWaveform), paramElem(ParamId::kDetune) },
},
EditorSection{
.name = "Filter",
.shortName = "Filter",
.elements = { paramElem(ParamId::kCutoff), paramElem(ParamId::kResonance) },
},
EditorSection{
.name = "Amplifier",
.shortName = "Amp",
.elements = { paramElem(ParamId::kAttack), paramElem(ParamId::kRelease) },
},
EditorSection{
.name = "Voice",
.shortName = "Voice",
.elements = { paramElem(ParamId::kPolyphony), paramElem(ParamId::kVolume) },
},
};
metadata.layout23 = metadata.layoutFlat;
// Default Macros (6 user macros: indices 0..5, plus system velocity macro)
metadata.initDefaultMacros = [](std::vector<Patch::Metadata::Macro>& macros)
{
macros[0] = {
.displayName = "Cutoff",
.modifiers = { { static_cast<int>(ParamId::kCutoff), 0.5f } },
};
macros[1] = {
.displayName = "Resonance",
.modifiers = { { static_cast<int>(ParamId::kResonance), 0.5f } },
};
macros[2] = {
.displayName = "Envelope",
.modifiers = {
{ static_cast<int>(ParamId::kAttack), 0.3f },
{ static_cast<int>(ParamId::kRelease), 0.5f },
},
};
macros[static_cast<int>(Patch::SystemMacroId::kVelocity)].modifiers = {
{ static_cast<int>(ParamId::kVolume), 1.0f },
};
};
}
std::unique_ptr<InstrumentType> createMySynth()
{
return std::make_unique<InstrumentType>(
InstrumentType{ .name = "MY",
.createMetadata = &MySynthVoice::createMetadata,
.createVoice = [](int /*voiceIdx*/) -> std::unique_ptr<InstrumentVoice>
{ return std::make_unique<MySynthVoice>(); } });
}
} // namespace polysynth
#endifOpen core/synth/registry.cpp, include your header, and add your engine to InstrumentTypeRegistry::registerInstrumentTypes:
#include "engines/MySynthVoice.h"
// ...
void InstrumentTypeRegistry::registerInstrumentTypes(
InstrumentTypeRegistry& registry,
[[maybe_unused]] const Synth::Allocator& largeBuffersAlloc)
{
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kMidiOut), createMidiOut());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kSimpleAnalogSynth),
createSimpleAnalogSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kFatAnalogSynth),
createFatAnalogSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kVASynth), createVASynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kFMSynth), createFMSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kDrumSynth), createDrumSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kShapingSynth),
createShapingSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kChiptuneSynth),
createChiptuneSynth());
registry.addInstrumentType(static_cast<int>(InstrumentTypeId::kMySynth),
createMySynth());
}Note: Any
.cppfile placed incore/synth/engines/is automatically discovered and compiled bycore/makefileviarwildcard.
To expose the new engine in the Tracker's Synth model selector and Patch Editor:
-
Add to
Synth::kSynthTypesincore/synth/Synth.h:static constexpr std::array kSynthTypes = { polysynth::InstrumentTypeId::kSimpleAnalogSynth, polysynth::InstrumentTypeId::kFatAnalogSynth, polysynth::InstrumentTypeId::kVASynth, polysynth::InstrumentTypeId::kFMSynth, polysynth::InstrumentTypeId::kDrumSynth, polysynth::InstrumentTypeId::kShapingSynth, polysynth::InstrumentTypeId::kChiptuneSynth, polysynth::InstrumentTypeId::kMySynth, };
-
Create or Reuse Badge Bitmaps (
L22-bit Grayscale Format): Each synth uses two smallL2(2 bits per pixel, 4-level grayscale, MSB-first packed) bitmaps incore/libs/images/:-
Mini Badge (
80x28pixels,560bytes, stride20): Displayed in the Instrument Parameter screen (SynthEditor). Seechip_mini_nomark_80x28_L2.h. -
Medium Badge (
108x40pixels,1080bytes, stride27): Displayed in the Patch Editor (SynthPatchEditor). Seechip_med_108x40_L2.h. - Register the bitmap enum IDs in
core/libs/NewDisplay/displayStructs.h(enDisplayBitmaps) and add their FT812RAM_Gentries incore/libs/NewDisplay/display.cpp(bitmaps[]).
-
Mini Badge (
-
Add Name & Bitmap Entries to the Synth Editors:
- In
core/interface/instrumentEditor/SynthEditor.cpp: Append your engine's short name tosynthModelNamesand its mini bitmap ID tosynthModelListImages. - In
core/interface/instrumentEditor/SynthPatchEditor.cpp: Append its medium bitmap ID tosynthModelListImages.
- In
From the repository root, build the firmware using the top-level Makefile:
# Build all three devices (Original Tracker, Tracker+, Tracker Mini)
make all
# Or build only Tracker+ or Tracker Mini:
make plus
make miniThe flashable .ptf files are generated in build/:
build/TrackerPlus_CE_1.0.0.ptfbuild/TrackerMini_CE_1.0.0.ptf
Copy the .ptf file to the /Firmware/ directory on your Tracker's SD card and flash via Config → Firmware → Firmware update.
Every parameter in metadata.params is configured via Patch::Metadata::Param:
| Field | Type | Description |
|---|---|---|
displayName |
std::string |
Label shown in the Patch Editor parameter list (keep <= 18 chars for clean UI fitting). |
defaultValue |
optional_paramvalue_t |
Initial value on Init Patch and when resetting via Delete button. |
minValue |
__fp16 / float
|
Minimum parameter value (note: stored as IEEE 754 half-precision __fp16 on ARM, max |
maxValue |
__fp16 / float
|
Maximum parameter value (keep within |
editCurve |
Param::Curve |
Response curve for knob turning and macro modulation (kLinear, kExponential, kExponentialSteep, kPower). |
macroOperation |
Param::MacroOperation |
How macro modifiers combine with the parameter (kAdd = additive in normalized curve space; kMult = multiplicative scaling, ideal for filter cutoff). |
isEnum |
bool |
Set true for discrete integer options (0..N). The UI will step by 1 on knob turns. |
midiCC |
tiny::optional<uint8_t, 0xff> |
Optional MIDI CC number (see sdk::MidiCCs). |
invalidatesOtherMetadata |
bool |
Set true if changing this parameter alters metadata.maxVoices or dynamic parameter names (triggers createMetadata refresh). |
valueFormatter |
std::function<std::string(const Patch&, float)> |
Formats the current parameter value into a human-readable string for the Tracker display. |
The Tracker Patch Editor displays a 2x6 grid of tiles: the top 6 tiles represent your engine's parameter sections (metadata.layoutFlat.sections, up to 6 sections), and the bottom 6 tiles represent the 6 user Macros.
SynthPatchEditor.cpp automatically selects the tile icon and grid label by matching a substring in EditorSection::name:
EditorSection::name Substring |
Tile Short Label | Tile Icon | Typical Use |
|---|---|---|---|
"Oscillator" |
Osc |
kBitmapOsc (Waveform icon) |
Main oscillators, wave models, tuning, sub-osc |
"Modulation" |
Mod |
kBitmapMod (Mod matrix icon) |
LFOs, PWM, vibrato, pitch sweeps, arpeggiator |
"Amplifier" |
Amp |
kBitmapEnv (ADSR envelope icon) |
Amp ADSR envelope, velocity sensitivity |
"Amp Envelope" |
Amp Env |
kBitmapEnv (ADSR envelope icon) |
Amplitude ADSR envelope |
"Envelope" |
Env 1, Env 2... |
kBitmapEnv (Auto-numbered) |
Auxiliary/modulation envelopes |
"LFO" |
LFO 1, LFO 2... |
kBitmapLfo (Auto-numbered) |
Dedicated LFO sections |
"Filter" |
Filter |
kBitmapFilter (Filter curve icon) |
Lowpass/highpass/bandpass filter, bitcrusher |
"Shaper" |
Shp |
kBitmapShaper (Waveshaper icon) |
Distortion, wavefolder, drive (DIRT) |
"Voice" |
Voice |
kBitmapGear (Gear icon) |
Play mode (Mono/Legato/Poly), glide, gain |
"FX" |
FX |
kBitmapFX |
Built-in voice/synth effects |
"Mixer" |
Mixer |
kBitmapMixer |
Oscillator or drum channel mixer |
"Kick", "Tom", "Snare", "Hihat", "Cymbal", "Percussion"
|
Same as name | Drum icons | Drum synth voices (PERC) |
Tip: Always include one of the substrings above in each
EditorSection::name(and keepmetadata.layoutFlat.sections.size() <= 6) so every tile in the top half of the 2x6 Patch Editor grid has a matching hardware icon and label.
synth/include/PolySynth/sdk/SynthVoiceSDK.h provides header-only helpers in namespace polysynth::sdk:
-
sdk::kSampleRate(44100.0f): Audio engine sample rate in Hz. -
sdk::kSilenceGainDb(-90.0f): Silence threshold in dB. -
sdk::dbToAmplitude(float db): Converts dB to linear amplitude (0.0fat or below-90 dB). -
sdk::amplitudeToDb(float amplitude): Converts linear amplitude to dB. -
sdk::noteToFreq(note_t note, float detuneSemitones = 0.0f): Converts MIDI note + fractional semitone offset to frequency in Hz ($A_4 = 440\text{ Hz}$ ). -
sdk::detuneMult(float detuneSemitones): Returns$2^{\Delta / 12}$ frequency multiplier. -
sdk::saturateSoftClip(std::span<sample_t> buf, float gainDb): Applies pre-gain in dB followed by cubic soft-clipping ($y = x - x^3 / 6.72$ , clamped to$[-1, 1]$ ).
-
sdk::createPercentFormatter(bool showPercentSign = true): Formats[0.0, 1.0]as"0 %".."100 %". -
sdk::createTimeFormatter(): Formats seconds as"0.05 s"(< 1 s) or"2.5 s"(>= 1 s). -
sdk::createFrequencyFormatter(float max): Formats Hz/kHz automatically ("8.50 Hz","440 Hz","12.5 kHz"). -
sdk::createSemitoneFormatter(): Formats integer semitones with sign ("-12 st","0 st","+7 st"). -
sdk::createCentsFormatter(): Formats integer cents with sign ("-50 ct","0 ct","+25 ct").
-
Stateless Parameter Handlers (
static_assert(std::is_empty_v<Fn>)):sdk::ParamHelper::addParamstores each parameter handler as a 4-byte C function pointer (void (*)(VoiceType&, Patch::optional_paramvalue_t)).- Handler lambdas must not capture variables (
[]only). - Because of this design, a custom synth engine's static
.bssfootprint inSRAM_DTCis only ~100 bytes (4 bytes * numParams), leaving plenty ofSRAM_DTCheadroom for multiple custom engines.
- Handler lambdas must not capture variables (
-
Zero Heap Allocations on the Audio Thread:
- Never call
new,malloc,std::vector::push_back, orstd::stringinsiderenderVoiceAudio(),renderPostProcessAudio(),onNoteOn(),onNoteOff(), oronParamChange(). - Allocate all per-voice state directly as value members of your
CustomSynthVoicesubclass. Voice objects are only allocated on the heap when your engine is actively selected on one of the 3 synth slots.
- Never call
-
Keep
minValue/maxValueWithin__fp16Range: On ARM targets,Patch::Metadata::Param::minValueandmaxValueare stored as 16-bit half-precision floats (__fp16, max magnitude65504.0f). Never useFLT_MAXor values above65500.0ffor parameter bounds. -
CPU Budget (
600 MHzCortex-M7 with Hardware Single-Precision FPU):- Use
float(0.0f,std::sinf,std::powf,std::fabsf) everywhere, neverdouble. - Hoist per-block coefficient calculations (such as filter coefficients, envelope increments, and
noteToFreqbase pitch when not pitch-modulated at audio rate) outside the128-samplefor (auto& sample : out)loop whenever possible. - Use
PolyBLEPor band-limiting on sharp discontinuities (saw/pulse resets) to keep high notes alias-free at44.1 kHz.
- Use
For a full-featured production example built exclusively with PolySynth/sdk/SynthVoiceSDK.h, study the open-source CHIP synth engine:
-
Header:
core/synth/engines/ChiptuneSynthVoice.h -
Implementation:
core/synth/engines/ChiptuneSynthVoice.cpp -
Memory Footprint:
21.4 KBFlash (QSPI_FLASH),105 Bstatic RAM (SRAM_DTC),224 Bper active voice on the heap.
-
8 Distinct Synthesis Models in One Voice Class:
-
2A03 Pulse(PolyBLEP variable-duty pulse with LFO PWM) -
SID Saw/Tri(Combined saw/triangle with sub-octave ring modulation) -
Noise / LFSR(15-bit NES/GameBoy pitch-tracked LFSR with white & metallic periodic modes) -
FM Bell(2-operator quantized OPL-style FM with 8 carrier:modulator ratios) -
Super Square(3-oscillator detuned pulse unison stack) -
Zap / Laser(Arcade pitch-drop percussion/lead) -
Arp Chord(4-step 50/60 Hz chiptune chord arpeggiator with 7 chord tables) -
Chip Drum(Stepped triangle pitch sweep + LFSR noise burst morphing from Kick to Snare to Hat)
-
-
Dynamic Voice Allocation: Switching
Play Mode(Mono/Legato/Poly) uses.invalidatesOtherMetadata = trueto dynamically switchmetadata.maxVoicesbetween1and8. -
5 Patch Editor Sections (
Oscillator,Modulation,Amplifier,Filter,Voice) and 6 Pre-Mapped Performance Macros (Timbre / PWM,Arp / Chord,Sweep / Vibrato,Env Decay / Rel,Crush / Lo-Fi,Filter Cutoff).