Skip to main content

ControlForge Audio & Synthesis

James M. Belcher Founder, JMB Technical Services LLC August 2026 | ControlForge v1.0.1349


ControlForge treats audio as a first-class I/O domain. The audio HAL device is a pure-Go ALSA output with a polyphonic wavetable synthesizer behind it — per-voice ADSR, an RBJ biquad filter, band-limited wavetables, and sample-accurate event scheduling. From ST you gate notes, tune the envelope and filter live, and load patches; from the runtime side the same device drives the panel annunciator off alarm state. Custom waveforms and filter coefficients are baked artifacts: designed in the compute sidecar, frozen with provenance, and hot-swapped into a running engine without a restart. Raw audio never crosses the scan — the scan writes parameter tags and reads derived tags, and the engine renders on its own goroutine, so a 32-voice patch costs the scan loop essentially nothing.

This guide covers what ships today: the output device, the synth builtins, MIDI, patches, and the annunciator. The visual instrument (designer panel, knob/XY/keyboard widgets) is a later phase and does not exist yet.

1. Architecture

The determinism boundary is the point. Audio samples never enter the scan. ST writes parameters; the engine returns derived tags. Measured on a Dell Precision 7740 (ALC289, 48 kHz): scan impact 53 µs idle vs 55 µs with four voices synthesizing.

2. Capability gate

Audio is gated. Without this in your --config deployment file, every SYNTH_* call faults its task:

capabilities:
audio:
enabled: true

The MIDI codec is not gatedMIDI_NOTE_ON, MIDI_PARSE, MIDI_NAME_TO_NOTE and friends only build or read bytes, so ST that formats a MIDI message to push over serial or MQTT runs on a box with no sound card. Only MIDI_FEED_* needs capabilities.audio, because the feed opens a real rawmidi device.

3. Configuration

The synth is a HAL device. Minimum working manifest:

capabilities:
audio:
enabled: true

hal:
enabled: true
poll_rate_ms: 20
devices:
- name: speaker # the name every SYNTH_* call addresses
type: audio
settings:
card: PCH # substring match against /proc/asound/cards
rate: 48000
period_frames: 256
buffer_periods: 4
channels: 2
voices: 8
master_gain: 0.35
base_address: "%QW100"
SettingMeaning
cardsubstring matched against the ALSA card list (cat /proc/asound/cards)
ratesample rate, Hz
period_framesALSA period size — the latency lever
buffer_periodsring depth in periods (default 4)
channels1 or 2
voicespolyphony ceiling
master_gain0..1 output scale
wavetablebaked artifact name; empty = built-in sine
attack_ms decay_ms sustain release_msinitial ADSR
filterlowpass | highpass | bandpass
filter_cutoff filter_qfilter design
filter_artifactbaked biquad coefficients (overrides the above)
mipsband-limited wavetable levels per octave
base_addressfirst %QW of the per-voice pitch/amp block

Derived tags

The device publishes read-only inputs starting at the %IW block. On the config above:

TagMeaning
%IW100output RMS — non-zero means sound is actually being produced
%IW101underrun count — should stay 0
%IW102active (gated) voice count
%IW103last trigger latency, µs
%IW104buffer depth, µs

These are how you verify the synth without ears. %IW102 > 0 and %IW100 > 0 together mean a voice is gated and producing signal.

4. ST functions

All six synth builtins, exactly as the runtime registers them. Verify any of this against your own target with controlforge capabilities --all or GET /api/docs/functions?search=SYNTH.

SYNTH_NOTE_ON(device: STRING, note: INT, velocity: REAL, [delay_ms: REAL]) : INT
SYNTH_NOTE_OFF(device: STRING, note: INT, [delay_ms: REAL]) : BOOL
SYNTH_PARAM(device: STRING, param: STRING, value: REAL) : BOOL
SYNTH_LOAD_PATCH(device: STRING, patch: STRING) : BOOL
SYNTH_VOICES(device: STRING) : INT
SYNTH_SCHEDULED(device: STRING) : INT
  • SYNTH_NOTE_ON gates a MIDI note. velocity is 0..1; velocity 0 releases. Returns the voice index, -1 when nothing sounded, -2 when the event was queued for a future sample offset.
  • SYNTH_NOTE_OFF releases every voice holding that note. The ADSR release tail keeps sounding — that is correct, not a bug.
  • delay_ms on either call schedules the event at an exact sample offset instead of the next block boundary. This is how you get tight rhythmic timing out of a 10 ms scan: compute the whole bar's events in one scan and let the engine land them precisely.
  • SYNTH_VOICES returns currently-gated voices, -1 if the device name does not exist. Use -1 as your "is the device there" check.
  • SYNTH_SCHEDULED returns pending timestamped events.

Live-tunable parameters

SYNTH_PARAM accepts exactly these keys. Values are clamped to the ranges below — the runtime and the config share one clamp table, so a value out of range is coerced, never silently ignored:

ParamRange
attack_ms0 – 10000
decay_ms0 – 10000
sustain0 – 1
release_ms0 – 30000
filter_cutoff10 – 96000 (re-clamped under Nyquist)
filter_q0.1 – 20
master_gain0 – 1

An unknown key is rejected, not ignored.

5. Recipes

A note on a rising edge

PROGRAM POU_Chime
VAR
trigger_prev : BOOL;
voice : INT;
END_VAR

IF trigger AND NOT trigger_prev THEN
voice := SYNTH_NOTE_ON('speaker', 72, 0.8); (* C5 *)
END_IF;
IF NOT trigger AND trigger_prev THEN
SYNTH_NOTE_OFF('speaker', 72);
END_IF;
trigger_prev := trigger;
END_PROGRAM

Edge-detect. Calling SYNTH_NOTE_ON every scan while a bool is held re-gates the voice continuously and sounds wrong.

Map a tag to pitch (sonification)

A tech can hear a PID loop hunt while their hands are inside the machine:

(* 0-100% -> MIDI 48..84 *)
note := REAL_TO_INT(48.0 + (pv / 100.0) * 36.0);
IF note <> note_prev THEN
SYNTH_NOTE_OFF('speaker', note_prev);
SYNTH_NOTE_ON('speaker', note, 0.5);
note_prev := note;
END_IF;

Sample-accurate rhythm

Scan quantization jitters note spacing by up to a scan period. delay_ms removes it — schedule the whole pattern in one scan:

IF start_bar AND NOT start_bar_prev THEN
FOR i := 0 TO 7 DO
SYNTH_NOTE_ON('speaker', 60, 0.7, INT_TO_REAL(i) * 125.0);
SYNTH_NOTE_OFF('speaker', 60, INT_TO_REAL(i) * 125.0 + 100.0);
END_FOR;
END_IF;
start_bar_prev := start_bar;

Onsets land within ±2 samples. Without delay_ms the same pattern jitters by the scan period.

Check the device exists before using it

IF SYNTH_VOICES('speaker') < 0 THEN
audio_ok := FALSE; (* no such device — do not rely on sound *)
END_IF;

6. Patches

A patch is a named project artifact — JSON holding any SYNTH_PARAM key plus a wavetable artifact, a filter type, or a baked filter_artifact.

SYNTH_LOAD_PATCH('speaker', '@warm_pad'); (* named patch *)
SYNTH_LOAD_PATCH('speaker', '{"attack_ms":200,"filter_q":4}'); (* inline JSON *)

Patch application is all-or-nothing: an unknown key rejects the whole patch rather than applying half of it and leaving the engine in a state nobody designed.

Manage patches over REST:

GET /api/synth/patches
GET /api/synth/patches/{name}
PUT /api/synth/patches/{name}
DELETE /api/synth/patches/{name}

Baked artifacts

Custom waveforms and filter coefficients are designed in the compute sidecar and frozen:

Worker opProduces
wavetable_additivewavetable from harmonic amplitudes
wavetable_from_fftwavetable resynthesized from an analyzed sample
biquad_designfilter coefficients for filter_artifact

POST /api/compute/bake freezes the result with provenance; the loader goroutine hot-swaps it into the running engine. A wavetable can change mid-note without a dropout.

7. MIDI

Input — the feed

protocols.midi_feed opens any rawmidi/char device or PTY and parses the stream continuously (running status, realtime-transparent, sysex-skipping). USB-MIDI keyboards appear as /dev/snd/midiC*D*.

protocols:
midi_feed:
- name: keys
device: /dev/snd/midiC1D0

Then poll it from ST — these are cached values, not a queue, so a slow scan cannot miss a note state:

SYNTH_NOTE_ON('speaker', MIDI_FEED_LAST_NOTE('keys'),
INT_TO_REAL(MIDI_FEED_LAST_VELOCITY('keys')) / 127.0);
FunctionReturns
MIDI_FEED_START(name, device)open a feed from ST instead of config
MIDI_FEED_NOTE(name, channel, note)held velocity (0 = off)
MIDI_FEED_ACTIVE_NOTES(name, channel)count of held notes
MIDI_FEED_LAST_NOTE/LAST_VELOCITY/LAST_CHANNEL(name)most recent note-on (−1 before any)
MIDI_FEED_CC(name, channel, controller)last CC value
MIDI_FEED_PITCH_BEND(name, channel)−8192..8191, 0 centred
MIDI_FEED_PROGRAM(name, channel)last program change
MIDI_FEED_COUNT(name)total messages parsed — use as a liveness check

MIDI_FEED_COUNT not advancing means the feed is open but nothing is arriving: check the cable before the code.

Output — the codec

These build or parse bytes and touch no hardware, so they need no capability. Send the result wherever you like — serial, MQTT, a file:

MIDI_NOTE_ON(channel, note, velocity) : ARRAY MIDI_BUILD_NOTE_ON(...) : INT
MIDI_NOTE_OFF(channel, note, velocity) : ARRAY MIDI_BUILD_NOTE_OFF(...) : INT
MIDI_CC(channel, controller, value) : ARRAY MIDI_BUILD_CC(...) : INT
MIDI_PITCH_BEND(channel, value) : ARRAY MIDI_PROGRAM_CHANGE(channel, program) : ARRAY
MIDI_SYSEX(manufacturer_id, data...) : ARRAY
MIDI_PARSE(byte1, [byte2], [byte3]) : HANDLE
MIDI_GET_STATUS/GET_CHANNEL/GET_DATA1/GET_DATA2(handle) : INT
MIDI_NAME_TO_NOTE(name) : INT MIDI_NOTE_TO_NAME(note) : STRING

8. The panel annunciator

The industrial half of the same device. Alarm priority drives a horn cadence — no ST, no extra hardware:

alarms:
annunciator:
enabled: true
device: speaker
escalate_after_s: 30
volume: 0.7
patterns:
1: { note: 84, on_ms: 200, off_ms: 200 } # critical — fast intermittent
2: { note: 76, on_ms: 300, off_ms: 900 } # warning — slow

ISA-18.2 behaviour:

  • the highest-priority unacknowledged alarm owns the horn; a higher priority preempts mid-cadence
  • unanswered alarms escalate intermittent → continuous after escalate_after_s
  • acknowledging silences the horn while the condition is still active — an operator working the fault can stop the noise without clearing it or disabling the horn
  • a priority with no configured pattern stays silent — silence is a deliberate choice per priority, never a fallback
  • shelved alarms do not sound

9. Latency and dropouts — honest numbers

Measured on a Dell Precision 7740, ALC289, 48 kHz, unprivileged:

ConfigurationKey-to-soundDropouts
period_frames: 256, buffer_periods: 4 (default)≈ 34 ms median0 underruns / 100 s
period_frames: 128, buffer_periods: 2≈ 11 ms19 underruns / 100 s

The aggressive setting is only usable with real-time scheduling. Without it you are trading audible dropouts for latency — for an annunciator or a process chime, take the default.

Scan impact is ~zero either way: 53 µs idle vs 55 µs with four voices.

10. Troubleshooting

SymptomCheck
SYNTH_* faults the taskcapabilities.audio.enabled: true in the --config file — not the project
SYNTH_VOICES returns −1device name mismatch; must equal the HAL name
No sound, %IW102 > 0the engine is gating voices — it is the output path. alsamixer; the HAL writes raw hw and bypasses the desktop mixer
No sound, %IW102 = 0nothing gated: edge-detection bug, or velocity 0
%IW101 climbingunderruns — raise period_frames or buffer_periods
Clicks on patch changeexpected only across a wavetable size change; parameter morphs are interpolated
Note never stopsSYNTH_NOTE_OFF releases; the ADSR release tail still sounds for release_ms
MIDI feed silentMIDI_FEED_COUNT not advancing = nothing arriving; check the device path
  • docs/design/AUDIO_SYNTH.md — the arc, phasing, and the industrial catalog
  • controlforge_hal_guide.md — HAL manifests and device lifecycle
  • controlforge_alarms_guide.md — alarm priorities the annunciator reads
  • controlforge_compute_guide.md — the sidecar that bakes wavetables and filters
  • docs/spec/ST_CAPABILITY_GATE.md — why audio is gated, and how to grant it