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 gated — MIDI_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"
| Setting | Meaning |
|---|---|
card | substring matched against the ALSA card list (cat /proc/asound/cards) |
rate | sample rate, Hz |
period_frames | ALSA period size — the latency lever |
buffer_periods | ring depth in periods (default 4) |
channels | 1 or 2 |
voices | polyphony ceiling |
master_gain | 0..1 output scale |
wavetable | baked artifact name; empty = built-in sine |
attack_ms decay_ms sustain release_ms | initial ADSR |
filter | lowpass | highpass | bandpass |
filter_cutoff filter_q | filter design |
filter_artifact | baked biquad coefficients (overrides the above) |
mips | band-limited wavetable levels per octave |
base_address | first %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:
| Tag | Meaning |
|---|---|
%IW100 | output RMS — non-zero means sound is actually being produced |
%IW101 | underrun count — should stay 0 |
%IW102 | active (gated) voice count |
%IW103 | last trigger latency, µs |
%IW104 | buffer 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_ONgates a MIDI note.velocityis 0..1; velocity 0 releases. Returns the voice index,-1when nothing sounded,-2when the event was queued for a future sample offset.SYNTH_NOTE_OFFreleases every voice holding that note. The ADSR release tail keeps sounding — that is correct, not a bug.delay_mson 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_VOICESreturns currently-gated voices,-1if the device name does not exist. Use-1as your "is the device there" check.SYNTH_SCHEDULEDreturns 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:
| Param | Range |
|---|---|
attack_ms | 0 – 10000 |
decay_ms | 0 – 10000 |
sustain | 0 – 1 |
release_ms | 0 – 30000 |
filter_cutoff | 10 – 96000 (re-clamped under Nyquist) |
filter_q | 0.1 – 20 |
master_gain | 0 – 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 op | Produces |
|---|---|
wavetable_additive | wavetable from harmonic amplitudes |
wavetable_from_fft | wavetable resynthesized from an analyzed sample |
biquad_design | filter 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);
| Function | Returns |
|---|---|
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:
| Configuration | Key-to-sound | Dropouts |
|---|---|---|
period_frames: 256, buffer_periods: 4 (default) | ≈ 34 ms median | 0 underruns / 100 s |
period_frames: 128, buffer_periods: 2 | ≈ 11 ms | 19 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
| Symptom | Check |
|---|---|
SYNTH_* faults the task | capabilities.audio.enabled: true in the --config file — not the project |
SYNTH_VOICES returns −1 | device name mismatch; must equal the HAL name |
No sound, %IW102 > 0 | the engine is gating voices — it is the output path. alsamixer; the HAL writes raw hw and bypasses the desktop mixer |
No sound, %IW102 = 0 | nothing gated: edge-detection bug, or velocity 0 |
%IW101 climbing | underruns — raise period_frames or buffer_periods |
| Clicks on patch change | expected only across a wavetable size change; parameter morphs are interpolated |
| Note never stops | SYNTH_NOTE_OFF releases; the ADSR release tail still sounds for release_ms |
| MIDI feed silent | MIDI_FEED_COUNT not advancing = nothing arriving; check the device path |
11. Related
docs/design/AUDIO_SYNTH.md— the arc, phasing, and the industrial catalogcontrolforge_hal_guide.md— HAL manifests and device lifecyclecontrolforge_alarms_guide.md— alarm priorities the annunciator readscontrolforge_compute_guide.md— the sidecar that bakes wavetables and filtersdocs/spec/ST_CAPABILITY_GATE.md— why audio is gated, and how to grant it