← all scripts / norns
fm2op new
2-operator FM synth for Norns
install
In maiden's REPL (the >> prompt at the bottom of the editor), run:
;install https://github.com/mrmattallison/fm2opOr add the splines catalog to maiden and install it from the project manager. Restart norns if the script ships a new engine.
readme from mrmattallison/fm2op
fm2op (a humble Norns AI-assisted synth engine) v1.0.0
fm2op is an eight-voice, two-operator FM instrument for Norns, inspired by classic FM hardware. In addition to the basic 2op structure it includes an FX page with a few tasty additions including a drive circuit, low pass filter and redux with an end of chain chorus.
Included: MIDI input, preset browsing and loading, carrier/modulator ADSR pages, FM ratio/depth/feedback, and an FX page (drive, tone filter, redux, chorus).
| Component | Path |
|---|---|
| Script | fm2op.lua |
| Engine class | lib/Engine_FM2op.sc |
| Engine name | FM2op |
Sound + process
This section is for developers and engineers who are rightfully wary about AI-assisted work, not because of the tooling, but because undirected “vibe coding” really can produce shallow code, hidden bugs, and a false sense that craft no longer matters. These worries are legitimate and I empathize.
fm2op was my idea and my product call: it's a Norns script with a two-operator FM engine, a specific screen layout (presets / carrier / modulator / FM / FX), MIDI behavior, and a sound I wanted to hear—not a generic “make me a synth” dump like you might get from a search engine. I wanted to recreate the nostalgia of early-80s keyboards from my youth. I held the requirements, rejected approaches that did not fit Norns or Crone (from my limited knowledge), and kept iterating until the DSP, presets, and UI behaved the way I intended.
How the work was done: I used several large language models over the life of the project, alongside normal reading of Norns and SuperCollider docs and watching YouTube tutorials. Early scaffolding and architectural discussion happened mostly in Claude (long threads, structure, and conventions before the first coherent code landed in version control). Later passes used in-editor assistants (e.g. Cursor) for tight loops: implementing a change, running it on the Norns hardware, spotting regressions, fixing the engine, tuning presets, updating the README (the thing you're reading right now). This is where co-creation works best for me: the models propose and refactor; I steer, verify, and own the outcome.
Nothing here replaces judgment—FM ratios, envelope shapes, filter drive, redux behavior, polyphony rules, and preset character are the kind of decisions you still make by ear and by reading stack traces. The models compress iteration time. I'm not a developer by trade (closer to an average PM); even so, they don't remove the need to understand what you're shipping—and I'm still responsible for that gap when it shows up in the repo.
If you are evaluating this repo: treat it as human-directed synthesis software that happened to be built with LLMs in the loop, not as evidence that “AI wrote a sloppy synth” end to end. Thanks for the care you put into your own craft—it matters.
Quick start
- Copy the project folder to
dust/code/fm2op/on Norns. - Restart, then run
fm2op. - Play from a MIDI controller on port 1; use PARAMS or the on-device pages to edit sound.
After editing Engine_FM2op.sc, always ;restart so SuperCollider recompiles the engine correctly.
Signal flow
Per voice, audio is built as follows:
- Modulator (Op1) —
SinOscFBwith its own ADSR and feedback. - Carrier (Op2) —
SinOscFM’d by Op1, with its own ADSR. - Tone / drive — soft clipping, resonant low-pass with optional cutoff tracking the carrier envelope, and bright-punch on the attack.
- Chorus — stereo modulated delays; can be bypassed with K2.
- Redux — last in the chain. One
Decimatorpath; sample rate and bit depth both follow thereduxcontrol. Output is crossfaded dry→wet soredux = 0is fully clean (insertingDecimatoralone at “full rate” is not always transparent on all SC builds).
Screen UI (E1 = page)
| Page | Role |
|---|---|
| 1 Preset | Browse presets (E2 loads immediately); K3 reapplies current preset |
| 2 Carrier | Carrier ADSR |
| 3 Modulator | Modulator ADSR |
| 4 FM | Ratios, depth, feedback |
| 5 FX | Drive, tone, redux, chorus |
Encoders: E2 selects a parameter on the current page (except Preset, where it changes preset). E3 adjusts the selected value.
Keys: On the FX page, K2 toggles chorus. On other pages (not Preset), K3 sends all notes off.
Parameters (PARAMS menu)
Carrier envelope
car_atk,car_dec,car_sus,car_rel— attack/decay/sustain/release (times in seconds where applicable).
Modulator envelope
mod_atk,mod_dec,mod_sus,mod_rel— same idea for the modulator.
FM core
car_ratio,mod_ratio— indices into the ratio table below.mod_depth— FM index / brightness.feedback— modulator feedback.
FX
drive— saturation.bright_punch— extra FM brightness on the attack.tone_cutoff,tone_res,tone_env_amt— ladder filter; cutoff can follow the carrier envelope viatone_env_amt.redux— lo-fi amount (0= clean,1= heavy; in between blends dry and decimated).chorus_rate,chorus_depth,chorus_mix,chorus_on— chorus.
FM ratio table
Labels available for carrier and modulator:
1:1, 3:2, 2:1, 5:2, 3:1, 7:2, 4:1, 5:1, 5.5, 7:1, 11:1, 14:1
Presets
Built-in presets: Piano, E.Piano, Brass, Flute, Organ, Bell, Chime, Strings, Vibes, Synth Bass.
They are voiced on purpose (envelopes, ratios, FX, and optional MIDI offset)—for example Brass uses a slightly slower modulator attack than “clicky” FM leads so the harmonic build feels more like a brass-style bloom, not an accidental typo. Along the way I hit plenty of wrong voicings too (inverted envelopes, runaway feedback—great for whale-like sound design, less so for melodies).
Loading a preset runs params:set(...) on the matching fields, then push_engine_state() so the engine matches, even if some params were already at the same stored value.
MIDI transpose (midi_transpose)
Optional per-preset field: semitones added to incoming MIDI before noteOn / noteOff, then clamped to 0–127. Presets that use it:
| Preset | Semitones | Effect |
|---|---|---|
| Brass, Bell, Strings | +12 |
One octave above played pitch |
| Synth Bass | -24 |
Two octaves below played pitch |
All other presets omit the field (no transpose).
Conceptually I was imagining a limited 2-octave keyboard and wanted to shift the range of the tones to best fit within that framework.
MIDI
- Input device 1:
midi.connect(1)ininit. - Note on/off use the transposed note:
engine.noteOn(clamp(note + midi_transpose, 0, 127), vel/127)and the same note number fornoteOff. - Velocity scales FM brightness in the engine.
Engine commands and version skew
Lua calls engine.<command>(value) by name (carAtk, modDepth, redux, …). The script uses a guarded send: if the running engine does not expose that command (e.g. old build), the call is skipped so the script should not crash. Use Maiden’s command list after load to confirm the engine matches the script.
Files
fm2op.lua— UI, PARAMS, MIDI, presets, transpose.lib/Engine_FM2op.sc—SynthDef, eight-slot voice allocator, Crone commands.
discussion
Like this script? React with 👍 or ❤️ below (needs a GitHub account). Reactions count as likes (♥) on splines, updated daily.