The long-march roadmap for GLOOPY — the compact JUCE 9 / C++17 linear-arranger
DAW at ~/git/gloopy (plugin hosting, composition-as-code projects, OSC + gRPC
control, automation, embedded sfizz, scriptable/headless workflows). Invoke when
the user says "grind", "keep going on Gloopy", "what's next", "pick the next
feature", or wants to continue the roadmap across sessions. Loads the north star,
the principles that judge each slice, the current shipped state, the working
discipline (build/test/commit/verify-headless), and the full ordered backlog of
features we have actually committed to building. NOTE: this is the *Gloopy* grind
(project-local). A separate global `grind` skill belongs to the Plume Lisp project
— don't confuse them.
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
name
gloop-grind
version
1.0.0
description
The long-march roadmap for GLOOPY — the compact JUCE 9 / C++17 linear-arranger
DAW at ~/git/gloopy (plugin hosting, composition-as-code projects, OSC + gRPC
control, automation, embedded sfizz, scriptable/headless workflows). Invoke when
the user says "grind", "keep going on Gloopy", "what's next", "pick the next
feature", or wants to continue the roadmap across sessions. Loads the north star,
the principles that judge each slice, the current shipped state, the working
discipline (build/test/commit/verify-headless), and the full ordered backlog of
features we have actually committed to building. NOTE: this is the *Gloopy* grind
(project-local). A separate global `grind` skill belongs to the Plume Lisp project
— don't confuse them.
grind — the march to make Gloopy the scriptable, composition-as-code DAW
This skill is the durable map and process for Gloopy (~/git/gloopy): a compact
JUCE 9 / C++17 linear-arranger DAW that hosts VST3/LV2 plugins and an embedded sfizz
SFZ engine, and that is driven end-to-end by a control API (OSC UDP 9000 + gRPC
127.0.0.1:50051) over projects stored as a diff-friendly composition-as-code
text format.
Three living documents, in order of authority:
AGENTS.md (repo root) — the hard-won landmines: proto3-omits-zero, one
instance at a time (pkill -x Gloopy), NewProject resets to the 5-track kit,
manual level staging, the sfizz vendoring, sample-path resolution. Read it every
session; it is the operational ground truth.
docs/ROADMAP.md + ideas.md — the design record of what shipped
(Phases 1–4, composition, recording, presets) and the raw idea pool (14 Feature
Ideas + 16 Ardour borrowings). This skill is the curated subset of that pool that
we will actually build, in order. ideas.md is the superset; this file is the
commitment.
This skill — the shape of the journey and how to walk it. Stable. The
volatile per-feature detail lives in docs/ROADMAP.md and the git log.
The north star (what Gloopy is trying to be)
The DAW you can drive from a script and store in git. Not a full DAW with an API
bolted on — a DAW whose primary surface is the composition-as-code text format and
the OSC/gRPC control API, with the GUI as one client among Python, Common Lisp, CI,
and future AI agents. Every capability is reachable and verifiable headlessly:
grpcurl builds it, --render bounces it, a script asserts the WAV. Gloopy stays
smaller, more scriptable, and more composition-repo friendly than a typical DAW —
that constraint is the product, not a limitation.
The principles (how every slice is judged)
API-first, GUI-second — but GUI is not optional. A feature isn't done until it's
reachable over gRPC (and OSC where it's a live knob) with a stable id. The UI is a
client of that surface, built after — and never the only way to do the thing. If it
can't be scripted, it isn't Gloopy-shaped. AND, per explicit user direction
(2026-07): everything we add must have a desktop UI where appropriate — a control,
menu item, mouse gesture, or shortcut a desktop user can actually reach. A slice is
NOT done at "backend + gRPC + smoke green"; it also needs the desktop affordance,
wiring the same shared transform, screenshot-validated on Xvfb. Do not ship API-only
features (the chord tool was caught being grpcurl-only). "Where appropriate" excludes
pure non-visual plumbing (format internals, scan/validate CLI).
Everything lives in the composition text format. New state serialises to
readable TOML / .notes / .points under the content-addressed dirty-write model
(Source/Composition.cpp), so it diffs and travels. No new hidden project blobs. A
dir → runtime → dir round-trip must stay byte-stable.
Every slice is headless-verifiable. Prove it with grpcurl + --render + a
WAV/text assertion, wired into tests/smoke.sh where it fits. "I built the UI, it
looks right" is not proof. Favour work whose correctness a script can see.
The audio thread is sacred. No allocation, locks, file I/O, or blocking in
renderBlock. New realtime data reaches the audio thread lock-free or under the
engineLock try-lock; structural edits go on the message thread
(callOnMessageThread, pushUndoSnapshot() first). Build the object off-thread,
then swap it in under the lock — the presets/instrument-swap pattern.
Stay curated, not comprehensive. Gloopy hosts VST3/LV2, so built-ins are a
small high-value set, not a catalog. Borrow systems and workflows from bigger
DAWs (Ardour, trackers) — never their code or assets without a license review
against Gloopy's AGPL-3.0 (Ardour is GPL; direct copying needs review). Keep the
JUCE layout simple; don't grow a window-heavy workspace or a per-device C++
subsystem for every controller — keep mappings data-driven. Don't embed a second
scripting VM (no Lua): scripts drive the existing gRPC API. Never name other
named DAWs/trackers as the source in code, docs, or commits — borrow the idea, not
the brand.
Current state (verify against git log + docs/ROADMAP.md each session)
[x] done · [~] partial · [ ] not started
[x]Phases 1–4 (docs/ROADMAP.md): MIDI recording into clips, master limiter +
clip meters, mixer-insert control API, MIDI hot-plug, undo/redo (snapshot-based),
parameter automation (breakpoint lanes, SetAutomation/GetAutomation),
structural change events on Subscribe, region/stem render
(RenderToFile + range/target), Python client + CL client, expanded
dual-osc + resonant filter + LFO synth, groove/swing + piano-roll velocity
editing, unit tests (GloopyTests, ctest) + smoke test + CI under xvfb.
[x]Composition-as-repo format (Source/Composition.cpp,
gloop-compositions.md): TOML manifests + .notes/.points + WAV/plugin
sidecars; content-addressed dirty writes; zip read; .gloopy / folder / .zip
all first-class in the File menu; SaveComposition/LoadComposition RPCs.
[x]Audio recording (Source/Recording.cpp, recording.md): live-input
capture to a WAV take via ThreadedWriter; referencing audio clips
(Clip.audioFile/takeId); monitoring, count-in, punch, take lanes / take
management, loop recording; GLOOPY_REC_TEST_TONE_HZ test seam; SetLoop RPC.
[~]Presets (Source/Presets.cpp): synth, effect-chain, and general
instrument (synth or SFZ, cross-type) presets as composition-friendly TOML under
<userAppData>/Gloopy/presets/ (GLOOPY_PRESET_PATH override); ListPresets +
save/load RPCs + Python client. Remaining: sampler + plugin instrument presets,
plugin effects inside effect-chain presets, and all preset UI (menus, browser,
New-From-Template). These fold into Wave 6 items #16 below.
So the model layers (tracks, mixer inserts, effects, automation, plugins, sfizz,
composition I/O, recording, presets, session-launch grid, group/submix routing, the
OSC/gRPC surface) all exist. The backlog below is about depth, musical model, analysis,
product surface, and the session-view/engine tails — not foundational plumbing.
The working discipline (the meta-process that works — follow it)
Read AGENTS.md and git log --oneline -15 first. The landmines there are
non-negotiable; re-learning them costs real time.
One slice = one feature = one commit. Pick the next unchecked backlog item.
Implement the smallest coherent version that is API-reachable and headless-provable,
not the whole grand vision at once.
The full stack of a slice (most features touch all of these — miss one and it's
half-built):
Model / audio — a new Source/<Feature>.cpp (or extend an existing one);
realtime data lock-free / under engineLock, structural ops via
callOnMessageThread + pushUndoSnapshot().
Serialisation — extend toValueTree/loadFromTreeand the composition
mapping in Source/Composition.cpp (readable TOML / .points), preserving the
dirty-write round-trip. Add to tests/smoke.sh's round-trip check.
proto + gRPC — add messages/RPCs to proto/gloopy.proto (proto3;
--experimental_allow_proto3_optional), implement the override in
Source/GrpcServer.cpp (the main.apiX(...) → set_ok/set_error pattern,
js() for std::string→juce::String). Remember proto3 omits zero/false —
always .get('x', 0), and use OSC for "set to 0 / turn a bool off".
OSC — add a /gloopy/... lane for anything that's a live knob (OscControl.h).
Clients — regenerate stubs (python/gen.sh) and add thin wrappers to
python/gloopy/client.py (and the CL client where it earns it).
Register the source in CMakeLists.txttarget_sources if it's a new .cpp.
Document it — every new feature/capability lands in the user-facing manual
(docs/, Material for MkDocs) as part of the slice, not later: what it does and how to
use it, in the right Diátaxis bucket (User guide for musicians, Control & scripting for
integrators). Document the domain model in
and link to it — don't re-explain nouns per language. Keep it out of implementation-language
framing (describe what it does, not "the C++/JUCE…"). must stay
green. This is not optional: a slice that isn't in the manual is half-built, same as one
missing its gRPC or desktop surface.
The backlog — the real list we're building, ordered
The through-line: make the scriptable composition-as-code surface deep and
complete before making it pretty. Waves are roughly sequential; within a wave, order
by leverage. Each item is a slice: model+serialise+proto+client → headless proof →
commit. ✦ marks a design fork worth a prior-art check first. Effort: S ≈ hours,
M ≈ a day or two, L ≈ a week+.
★ Reprioritized near-term order (updated 2026-07-28) — read this first
Directive (user, 2026-07-28): do Waves 9 → 10 → 11 FIRST, then go back to the beginning.
Those three are the cross-cutting reach & infrastructure epics — version control, platform,
agent control — that widen who and what can drive Gloopy and refresh the foundation. Front-load
them, then return to feature depth (the Phase-B tails below, which pick up the pre-existing
near-term queue).
Already shipped since the 2026-07-26 plan (dropped from the queue; see "Current state"): the
Surge XT featured-synth front (DONE/paused — hosted plugin default + bundled presets; removal
blocked by the .fxp-into-hosted-plugin blocker, docs/surge-embed.md), the Session view epic
(slices 1–4), the grouping / submix-routing epic (complete), the mixer channel model, and
the UI elevation-over-borders pass. Also DONE: undo/redo (already existed — snapshot
stack, Ctrl+Z/Y, commit c3eedf0; the fine-grained Wave 7 #22 rework stays optional/deferred).
Phase A — do these now, in this order:
Wave 9 #30 — Git as project management. IDE-grade source control (status, commit,
commit-graph, branches, tags, checkout any version, diff, stash, remote/push); shell out to
the system git. Central to project management + realizes the "store it in git" half of the
north star. XL, 12 slices — start at slice 1 (availability + status) and walk them in order.
Wave 10 — build & platform.#31 upgrade JUCE off the 8.0.15 FetchContent pin (low-risk;
do it before the port so Windows builds on the latest) → #32 Windows build (CI-native
recommended, else mingw-w64 cross-compile).
Wave 11 #33 — MCP stdio service. AI agents drive Gloopy over stdio; a thin gRPC adapter —
the cheapest of the three (pure adapter code over the existing control API).
Phase B — then go back to the beginning (resume the pre-existing near-term queue, in order):
4. Strong time types (Wave 7 #21) — foundation landed (Source/Time.h); NEXT =
incremental adoption: migrate the beatToSamples/samplesToBeats layer + clip/loop/marker
positions onto the types, add a Sample domain, one subsystem per slice. Before more
timeline/tempo work.
5. AudioBufferPool (Wave 7 #24) — not started; kill audio-thread allocations; cheap, and a
prerequisite for any graph rework.
6. Finish the Session view epic — slices 5 (control API: OSC/gRPC launch clip/scene, stop,
query session state) and 6 (polish: clip colours, follow-actions, capture, quantize menu).
7. Browser sidebar tail (Wave 6 #16: Favorites tab + first-class drag-and-drop onto
tracks/inserts — Templates/Demos/Plugins/Samples/Presets tabs already landed), then the rest of
Waves 2–5.
The heavy engine items (off-thread graph swap #5, multicore rendering #6) stay
deferred and gated on a real profiling trigger — do not start them until Gloopy is
measurably single-core-bound on a heavy multi-track session. See Wave 7 for why.
Wave 1 — Keystone model work (everything downstream rides on these)
Universal parameter model — ParamModel / ParameterRef. ✦ LIdea #3. One parameter abstraction: { stable id, name, min, max, default, scaling (linear|log|dB), unit, current value, thread policy }. Back UI knobs,
automation targets, MIDI/OSC mapping, gRPC state, plugin generic editors, and
save/load with it. Reconcile the existing TargetRef (automation/insert params)
into it so ids are stable across composition files and external clients.
Done when:ListParameters returns stable ids for track/insert/effect/plugin
params; GetParameter/SetParameter work by id; an automation lane and an OSC map
both address the same id; ids survive a composition round-trip.
✅ DONE (2026-07-25, focused session across 3 slices) — all four "done when" clauses
met: ListParameters covers track/insert/effect/synth and plugin (VST3/LV2) params;
Get/Set/SetParameterNormalized work by id; automation, the modulation matrix, and MIDI/
OSC controllers all address the same id via the shared applyParamValue; and the
numeric track id is now serialised so track/<id>/... ids survive a composition round-
trip. See the slice log below.
[x]Automation-by-id + stable track ids landed (commit, focused session): an
automation lane can now address a ParamModel id string (AutoLaneSnap.target),
written each block through the same lock-held applyParamValue that
controllers/modulation use — so an automation lane and a controller/LFO map address
the same id (the keystone reconciliation). apiSetAutomationById(target,points) +
apiAddAutomationPointById (the keyframe primitive) + /
RPCs + Python; serialised on LANE + composition
(). Legacy tuple lanes still work. the numeric
track id is now serialised ( in TRACK + composition track TOML) and
preserved on load ( keeps loaded ids, bumps past
them), so param ids survive a composition round-trip.
the mixer param right-click menu gains "Automate at playhead" / "Clear automation" —
screenshot-validated. smoke proves a cutoff sweep on moves
the render, the lane round-trips through SaveComposition/LoadComposition, and the
RELOADED project still sweeps (stable id keeps the lane live).
Wave 2 — Turn the control surface into the product
Export profiles / named render targets. ✦ MArdour #2 + Idea #9. Named export presets stored in composition text. Targets:
full mix, named range (Wave 1 #2), selected tracks, stems, current clip, loop
region. Delivery presets: WAV master, FLAC archive, streaming WAV, loop pack,
stems. Default outputs to exports/, deterministic filenames.
Done when:DefineExportPreset + RunExport(name) RPCs; a preset renders the
right files to exports/ at correct levels; presets round-trip in the composition.
[x]Landed (Source/Exports.cpp, commit): ExportProfile{name,target, rangeName,format,trackId,tailSeconds} + DefineExportProfile(upsert)/
ListExportProfiles/RemoveExportProfile/RunExport RPCs. Targets mix
(whole song), range (named location → window), track (solo), stems
(one file per instrument track). Reuses apiRenderToFile + apiResolveRange;
output to <project>/exports/ (gitignored) with deterministic
<name>.wav / stems/<id>-<slug>.wav; returns the file list. Serialised in
toValueTree/loadFromTree + composition exports.toml. Python client
define/list/remove/run_export. smoke.sh: master mix renders a file + profile
survives composition round-trip. Verified: 6 stems one-per-track, range export
shorter than mix.
[x]FLAC export landed (commit): apiRenderToFile now picks the encoder from
the output extension (.flac → juce::FlacAudioFormat, else WAV; both 24-bit),
and apiRunExport chooses .flac/.wav from the profile's format field (which
already existed). The forced-.wav line in the render path now only defaults
unknown extensions. smoke.sh proves a FLAC mix is valid (fLaC magic + ffprobe
codec=flac) and losslessly smaller than the WAV. Not yet: streaming/loop-pack/
current-clip targets, per-format bit depth, MP3/OGG (no encoder linked).
Clip / region operations.MArdour #12. Split at playhead / at marker, clip gain, normalize, reverse audio,
per-clip fades (audio), duplicate-to-grid, crop-to-range, consolidate,
bounce-in-place. All through the API and expressible in the composition.
Done when: each op has an RPC; a split/gain/reverse sequence round-trips and
renders correctly.
[x]Split/duplicate/reverse + note read-back landed (Source/ClipOps.cpp,
commit): apiSplitClip (absolute beat → two clips, notes distributed, straddlers
truncated), apiDuplicateClip (at beat, or butt-up-after), apiReverseClip
(mirror notes in the content window; reverse the audio buffer for audio clips),
apiGetClipNotes (read-back — also covers #5's deferred note-export). RPCs
SplitClip/DuplicateClip/ReverseClip/GetClipNotes + Python client. Clips already
serialise, so no new persistence. Verified precisely via GetClipNotes (split
8→4+4 with correct offsets; reverse 60→beat3/63→beat0; duplicate→3 clips) +
smoke.sh.
[x]Repeat clip (tile) landed (commit): apiRepeatClip(trackId,index,copies) appends
N back-to-back duplicates after a clip (each one clip-length further along), turning a
1-bar loop into an N-bar run — the "duplicate-to-grid" variant from #6. Copies share
audio buffers (read-only) and duplicate MIDI notes. RepeatClip RPC (→SliceResult) +
Python. Desktop UI: a "Repeat ▸ x2/x4/x8/x16" submenu on the clip menu (adds N-1
copies for an N× total) — screenshot-validated. smoke proves a 2-beat clip repeated x4
saves as 4 clips at beats 0/2/4/6.
[x]Loop-to-clip landed (commit): apiSetLoopToClip(trackId,index) sets the
transport loop region to a clip's [start,end) and enables looping (audition a clip on
repeat). TransportState/GetTransport gained loop_enabled/loop_start/loop_end
so clients (and the smoke test) can read the loop back. SetLoopToClip RPC (ClipRef) +
Python; transport() now returns the loop fields. Desktop UI: "Loop this clip" on
the arrange-view clip menu — screenshot-validated (Loop button lights + the ruler draws
the region over the clip). smoke proves SetLoopToClip on a clip at [5,8) sets the loop
region there (verified via the new GetTransport loop fields).
[x]Audio-clip split fixed (commit): apiSplitClip on an audio clip previously
copied the full buffer into both halves, so the right half REPLAYED from the
buffer start at the cut instead of continuing. Now each half's buffer is trimmed to its
own span (source-sample offset via the tempo-aware beats→seconds→samples map, same as
crop); peaks rebuilt, inner fades cleared, / cleared so the halves
embed. Fixes the existing "Split at playhead" clip-menu control for audio. smoke proves
the split is audibly TRANSPARENT (whole-render mean-abs diff 0.000000 before vs after a
split of a two-distinct-halves clip — a replay bug would corrupt the second half).
Wave 4 — Musical model & modulation
Modulation matrix — LFO/envelope → any ParamModel. ✦ M (needs Wave 1 #1)
Idea #12. Modulation sources (LFO, envelope) route to any parameter id with
tempo-sync, phase, shape, depth, smoothing, bipolar/unipolar. Persist routes as
readable composition files; expose over gRPC.
Done when: an LFO modulating a filter-cutoff param audibly moves it in a render;
routes round-trip.
[x]LFO landed (Source/Modulation.cpp, commit): Mod{target,rate,depth, center,shape} LFO on any ParamModel id — value = center + depth*osc(rate·t),
shapes sine/tri/saw/square. evaluateModulation(timeSeconds) runs on the audio
thread under engineLock, mirroring evaluateAutomation's shape, driven off
the playhead so renders are deterministic; a new lock-held applyParamValue
writes any ParamModel id directly (audio-thread-safe, no message thread).
apiSetModulation(upsert)/apiRemoveModulation/apiListModulations. Serialised
(MODS/MOD + composition mods.toml). RPCs + Python. Verified: cutoff LFO changes
the render (mean abs diff 0.033), shapes differ, round-trips.
[x]Tempo-sync landed (commit): Mod.syncBeats (>0 = cycle length in beats).
The pure phase/osc math moved to Source/Lfo.h (lfoPhaseCycles/lfoOsc) so it's
unit-testable without the engine — a synced LFO takes its phase from the beat
position (evaluateModulation now gets tc.sampleToBeat(playhead)), so its period
tracks the tempo map; a free LFO still uses rate·seconds. sync_beats on the
SetModulation RPC + ListModulations + Python; serialised in MODS + mods.toml; the
mixer "Add LFO" prompt gains a Sync field. GloopyTests::Lfo proves the sync-vs-free
phase (and caught a wrong triangle expectation); smoke proves a synced LFO modulates
the render and sync_beats round-trips.
[x]Phase offset + unipolar landed (commit): Mod.phase (0..1 cycle offset) and
Mod.unipolar (value ranges center..center+depth instead of center +/- depth). The
folding math is a new lfoUnit(shape, phase, offset, unipolar) in
(unit-tested in ). / on SetModulation + ListModulations
Offline loudness + render reports.S/MArdour #14. After a render, report peak, true-peak, RMS, and LUFS (EBU R128);
feed gloopy validate (Wave 2 #4) and a render report. Transient/onset detection
later (for slicing).
Done when:RenderToFile optionally returns/writes a report; the numbers match a
known test signal within tolerance.
[x]Landed (Source/Loudness.cpp, commit): apiAnalyzeFile computes
peak (dBFS), true-peak (dBTP, 4x windowed-sinc oversampled), RMS (dBFS), and
integrated LUFS (ITU-R BS.1770 — hand-rolled RBJ K-weighting biquads correct at
any rate + gated 400ms/100ms blocks, abs −70 / rel −10). No juce_dsp, no
audio-thread. AnalyzeFile RPC + gloopy analyze <wav> CLI (JSON) + Python
analyze_file. Verified against a −20 dBFS 1 kHz tone (peak −20.0, RMS −23.01,
LUFS −23.26 vs ~−23.6 ref) and the smoke render.
[x]Wired into RenderToFile's return (commit): RenderRequest.report (bool)
makes RenderToFile return a RenderResult { ok, error, LoudnessReport report }
— one call for "render + measure" in CI/scripts. RenderResult is wire-compatible
with Ack on fields 1/2, so existing clients are unaffected; Python render(..., report=True) returns the loudness dict. smoke.sh asserts the inline report equals
a standalone AnalyzeFile and is non-silent.
[x]Momentary + short-term LUFS + LRA landed (commit): extends apiAnalyzeFile
with max momentary (400 ms window — reuses the integrated-LUFS block loop), max
short-term (3 s / 1 s-hop windows), and LRA (EBU Tech 3342: abs gate −70, relative
gate −20 LU, P95−P10 of gated short-term). Added to LoudnessReport + the proto +
the AnalyzeFile/RenderToFile-report paths + gloopy analyze JSON + Python.
smoke proves on a 6 s steady sine that momentary ≥ integrated, short-term ≈
integrated, and LRA is bounded. Not yet:gloopy validate render+analyze wiring
of these. Transient/onset detection landed as Source/Onsets.h (detectOnsets),
consumed by the audio-clip "Slice at transients" op (Wave 3 #6).
Este SKILL.md e muito grande, entao o SkillsMP mostra aqui apenas a primeira secao.Ver no GitHub
[~]Session view (clip-launch grid) (Source/SessionModel.h / SessionLauncher.h
/ SessionView.h, docs/session-view.md): the Ableton-style Session view alongside
the Arrangement view — Tab cycles Arrange → Session → Mixer. Sample-accurate launch
engine (block-split, per-track arrangement override, one clip per track, global scene rows,
launch quant), grid UI with scene/master column pinned left as a frozen pane, record
live MIDI into slots, and session → arrangement capture/copy; a bottom Device View
edits the selected track's chain. Slices 1–4 (model+persistence, launch engine, UI+Tab,
recording) landed. Remaining: control-API (OSC/gRPC launch/stop/query) + polish
(follow-actions, capture, quantize menu). See the Session View epic in the backlog.
[x]Group / submix routing — the grouping epic (complete). A main-output route
(MixerTrack::output, 0 = master) sends a strip's whole signal into a bus — a submix /
Ableton "group" — distinct from a Send (parallel post-FX copy). apiSetInsertOutput /
apiGroupInserts / apiGatherGroup / apiUngroup; foldable session group columns
(nested groups, depth indent, fold/unfold-all), GROUP vs RETURN header tags, interactive
group strip (fader/pan/mute/solo/FX), settable group colour, and transitive solo. Group
gesture from either view: Cmd+G group / Cmd+Shift+G ungroup / Cmd+Shift+F
fold-all. Load-bearing invariant: a bus must be a higher mixerTracks index than everything
routing into it (see the group-bus memory note).
[x]Mixer channel model — dynamic per-track inserts.mixerTracks = [Master] + one insert per track + buses (the fixed 8-strip "Ins N" pool is gone; insertMixerTrackAt /
removeMixerTrackAt reindex the whole routing space). Strips backing a track show the
track's name + colour (rename syncs the track); Delete track on the header menu; and
detachable device windows (a floating DevicePanel per insert, pinned to a stable
MixerTrack*, so several effect chains are visible at once — the Reaper/Ardour model).
[x]UI: elevation-over-borders pass. Surfaces separate by the Palette shade ladder +
spacing, not outlines; borderless button fills; one Palette::radius; thin meters; lineSoft
panel seams; Play is the hero in the transport; watermark empty states. Keep the calm flat
identity — no drop shadows, uppercase section headers. (See the ui-elevation memory note.)
[~]Surge XT front (featured synth) — DONE/paused. The hosted Surge XT LV2 plugin
is the featured "+ Synth" default (full editor via the plugin path) with bundled first-party
factory patches (Presets browser tab, ~639 patches). The embedded Surge core and the
simple SynthGenerator are kept (removal/migration is paused): baking a factory .fxp
into a hosted plugin via the host state API is a proven hard blocker, so the embedded core
stays as the one path where patch-by-path loading works (backs the Presets tab). Only revisit
removal if the LV2 patch-message load gets solved. Plan in docs/surge-embed.md.
[~]Strong time types (Wave 7 #21) — foundation + Sample domain landed.Source/Time.h
(BeatPosition/BeatDuration, TimePosition/TimeDuration, SamplePosition/SampleDuration
integer-backed, compile-checked algebra) + typed constant-tempo/rate conversions
(beats↔seconds↔samples; the empty-tempo-map beatToSamples/samplesToBeats fast path expressed
in the types) + a TimeTypes test (algebra, conversions, compile-rejection static_asserts incl.
samples+beats/samples+double). Remaining: migrate the ENGINE's beatToSamples/samplesToBeats
call sites + clip/loop/marker positions onto the types, one subsystem per slice (tempo-map-aware
conversion stays in TempoConv).
[~]Manual (docs/, mkdocs.yml). Material for MkDocs + Diátaxis, two front doors
(User guide + Control & scripting), the domain model documented once; mkdocs build --strict passes. Keep docs out of implementation-language framing. Deferred: the gRPC/OSC/CL
reference generators (steps commented in docs.yml). See the manual-docs memory note.
For pure-logic slices, add a GloopyTests case (tests/TestMain.cpp) and run
ctest --test-dir build --output-on-failure. For anything that renders, extend
tests/smoke.sh.
Level staging is manual — there's no auto-master-limiter unless a project adds
one. Summing tracks clips at 0 dBFS; after any render, assert max_volume stays
below 0 (≈ −3 dB), lowering per-track volume/synth gain as needed.
Commit cadence. Green build + green tests first. One slice per commit. Source
headers use green@moxielogic.com; commit messages end with
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. Commit and push
frequently to origin (cave@cave.moxielogic.com:atgreen/gloopy.git) — one green,
verified slice = one commit, pushed right away (standing authorization for this repo; see
AGENTS.md). Commit only a coherent, verified slice; branch first only if asked. Update docs/ROADMAP.md (or a new design doc) with the feature's
goal/approach, document the new capability in the user-facing manual (docs/) — step 7 of
the full stack, part of the same commit, and check the item off in this skill's backlog.
A slice may span sessions — that's fine. If you must stop mid-slice: commit the
green sub-step, and write the exact resumption state (done / half-built / next
concrete action / open question) into docs/ROADMAP.md. Don't fake "done" to
satisfy a goal or a hook.
Consult prior art at real design forks (✦), but adapt, don't copy. For DAW
systems (export profiles, location/marker models, bus/send routing, mixer scenes,
tempo maps, loudness analysis) study Ardour's shape and the format conventions,
then implement the small Gloopy version in Gloopy's idioms. Cross-check any
third-party mechanism against the AGPL/GPL boundary before adopting. Record which
prior art a decision followed in the commit or the design doc — by concept, not by
brand name in shipped text.
SetAutomation.param_id
AddAutomationPoint
lanes.toml
target
Also fixed id stability:
tid
.gloopy
refreshTrackIds
nextTrackId
track/<id>/...
Desktop UI:
track/<id>/synth/cutoff
[x]Stepped (hold) automation landed (commit): an automation lane can interpolate
linearly (default) or STEP — hold each point's value until the next (for gated/stepped
moves). A per-lane AutoLaneSnap.step flag read by interpAuto; apiSetAutomationStep/
apiGetAutomationStep + a SetAutomationStep RPC + Python set_automation_step;
serialised on LANE + composition lanes.toml. Desktop: a checkable "Stepped
automation" item on the mixer param right-click menu (reflects + toggles the lane's mode).
smoke proves a step cutoff lane renders darker than the linear ramp (holds 300 Hz until
the last point) and the flag survives a composition round-trip (byte-identical re-render);
screenshot-validated (the checked menu item).
[x]Curved (ease-in/ease-out) automation landed (commit): a per-lane curve float
(-1 ease-out / 0 linear / +1 ease-in) warps t within each segment (interpAuto uses
pow(t, pow(2, 2*curve)), ignored while stepped). apiSetAutomationCurve/
apiGetAutomationCurve + a SetAutomationCurve RPC (AutoCurveRequest) + Python
set_automation_curve; serialised on LANE + composition lanes.toml. Desktop: an
"Automation curve ▸ Linear / Ease in / Ease out" submenu on the mixer param right-click
menu (reflects + sets the lane's curve; disabled while stepped). smoke proves an ease-in
cutoff lane renders darker than the linear ramp (holds low near each segment front),
differs from it, and the curve survives a composition round-trip (byte-identical
re-render); screenshot-validated (the expanded submenu with Linear checked). Not yet:
per-segment (as opposed to per-lane) curve shapes, a visual automation-lane editor.
[x]Plugin-param ids landed (commit): hosted VST3/LV2 params now ride the id
grammar — instrument plugins as track/<id>/plugin/<index> and plugin effects as
effect/<i>/<slot>/plugin/<index>, value normalised 0..1. ListParameters enumerates
them (via AudioProcessor::getParameters()), apiGetParameter/apiSetParameter read/
write by id (setValueNotifyingHost from the message thread), and applyParamValue
(audio thread, setValue) writes them too — so automation/modulation/controllers can
all target plugin params by id. Verified against a real hosted plugin (Surge XT: 2855
params; automation on a plugin param moves the render). smoke is plugin-agnostic +
conditional (first installed instrument plugin; list + set/get monotonic + automatable
by id; skipped if none). Desktop control: the plugin's own generic/native editor
(the "Plugin UI" button) edits these params; a Gloopy-side generic knob rack to
attach LFO/MIDI-learn/automation to plugin params from the desktop is the follow-up
(Wave 6 #19 territory).
Follow-up (not keystone-blocking): the plugin generic-param rack (desktop attach).
[x]Param snapshot + scaling helpers landed (commit, slice 3 — closes the
keystone): SaveComposition now writes a readable params.toml — a non-plugin
id->value/min/max/scaling/unit manifest so external clients can discover the param
model from the repo without instantiating plugins (informational; values load from
each subsystem's own section). Scaling-aware paramNormalize/paramDenormalize
(Source/ParamScale.h, unit-tested: a log cutoff at knob 0.5 = the geometric mean
~632 Hz; linear + dB tapers) back a new apiSetParameterNormalized(id,pos01) +
SetParameterNormalized RPC + Python, so UI knobs / external controllers set a param
from a 0..1 position honouring its log/dB/linear taper. smoke proves the log-scaled
normalized set lands at the geometric mean and that params.toml lists real ids.
Follow-up: re-taper the actual mixer faders to use dB, and a synth-param knob panel
(no dedicated synth-param UI knobs exist yet to re-taper).
[x]Audio-thread-alloc-free id writer landed (commit — the follow-up #24's guard surfaced):
applyParamValue(id,v) (the shared id-addressed writer for automation/modulation/controllers) used
to StringArray-tokenize the id string, and applySynthParam did name.toLowerCase() — both
allocate, and both run per lane/target per audio block from evaluateAutomation/evaluateModulation,
so any project with id-addressed automation/modulation allocated on the audio thread (principle 4).
Fixed by making the parse alloc-free in place (simpler + lower-risk than a cached-handle rework,
and it fully satisfies principle 4 — the concern is allocation, not the O(few-chars) parse):
applyParamValue now copies the id into a stack char buf[192] and splits on / in place, and
applySynthParam takes a const char* and matches names via a new alloc-free gloopy::ieq
(ASCII case-insensitive compare, Source/ParamId.h) instead of toLowerCase(); the effect-param
path compares pr.name.equalsIgnoreCase(seg) (the const char* overload is alloc-free); the two
message-thread applySynthParam callers pass .toRawUTF8(). Case-insensitive behaviour preserved
(so synth/Cutoff still resolves). ieq unit-tested (GloopyTests: case-fold match, prefix
non-match, empty). Verified with #24's guard extended: the alloc-guard smoke now sets an
automation lane on track/<id>/synth/cutoff AND an LFO on track/<id>/synth/reso before playing,
and audioThreadAllocs delta stays 0 (was nonzero before) — while GetParameter confirms the
automation still writes the param (cutoff swept 300→322 at the read). The full existing automation/
modulation/effect-param/controller smoke coverage (which exercises every applyParamValue branch)
guards the rewrite's correctness. Internal engine plumbing, no proto/UI change. A cached-handle
(resolve the target once at set-time) remains a possible micro-opt but isn't needed for alloc-freeness.
Timeline locations — markers / ranges / loop / punch / sections. ✦ MArdour #3. A project-level TimelineLocation { kind: marker|range|loop|punch| section|export|skip, name, startBeat, endBeat }. Fold the existing loop/punch
ranges into it. Store as readable TOML in the composition; surface on the ruler and
in the API. Named ranges (intro, chorus-2, loop-a) become render/export
targets.
Done when:AddLocation/ListLocations/RemoveLocation RPCs; locations
round-trip in the composition; RenderToFile can target a named range.
[x]Landed (Source/Locations.cpp, commit): TimelineLocation{name,kind, startBeat,endBeat} model + AddLocation(upsert)/ListLocations/RemoveLocation
RPCs + apiResolveRange; RenderRequest.range_name resolves a named range → the
render window. Serialised in toValueTree/loadFromTree + composition
locations.toml (survives dir round-trip, dirty-write stable). Python client
add/list/remove_location + render(range_name=…). smoke.sh asserts render-by-range
is shorter than full + location survives the composition round-trip.
[x]Marker ruler UI landed (commit): closed the desktop gap — named markers had NO
desktop control (only AddLocation via the API), despite driving split-at-marker. The
beat-ruler right-click menu gains "Add marker..." (name prompt at the clicked bar →
onAddMarker → apiAddLocation(name,"marker",beat,beat)) and "Remove marker (name)" when
one is near; named markers now DRAW on the ruler as cyan flags + labels (distinct from the
accent-coloured tempo markers), via the existing getMarkers hook. Pure desktop wiring of
the already-headless-proven Locations API (add/list/remove + composition round-trip already
smoke-covered), so no proto/Python change. Screenshot-validated (two markers "Verse"/"Drop"
drawn on the ruler + the "Add marker..." / "Remove marker" menu items).
Not yet: folding the existing SetLoop/punch state into this model (today
they're still separate); export/skip semantics.
CLI composition utilities.MArdour #1.gloopy subcommands reusing the GUI load/render code paths, with
predictable JSON/stdout for scripts and agents: inspect <proj>,
validate <proj>, render <proj> [--preset NAME], export-stems <proj>,
pack <proj> out.zip. validate uses Wave 5 #12 loudness/report data.
Done when: each subcommand runs headless with no GUI, emits stable JSON, and is
exercised in tests/smoke.sh / CI.
[x]Landed (Source/Cli.cpp + Source/Main.cpp dispatch, commit):
gloopy inspect|validate|pack <project>. New headless-CLI MainComponent
mode (MainComponent(bool headless)) skips OSC/gRPC/audio so tools run
alongside a live instance and keep stdout clean (a CoutSilencer mutes load
chatter). inspect → JSON {title,bpm,tracks,inserts,locations,exports};
validate → JSON {ok,errors,warnings} (missing SFZ, export→unknown-range,
zero-length ranges, clip-less tracks) + exit code; pack → normalise to a
composition and zip it (loadable). Works on any input format via
openProjectFile. smoke.sh exercises all three.
[x]validate --loudness landed (commit): with the flag, apiValidateJson
prepares the engine (headless-CLI skips the device, so prepareToPlay preps the
generators), renders the whole song offline, apiAnalyzeFiles it, and adds a
loudness object + level warnings (true-peak > -1 dBTP → clip risk; > -8 LUFS →
very hot; near-silent). The render/analyze chatter is wrapped in CoutSilencer so
stdout stays pure JSON. smoke.sh proves it renders non-silent and reports LUFS.
[x]render + export-stems subcommands landed (commit): gloopy render <project> [out.wav] (headless offline bounce — prepareToPlay preps the generators,
apiRenderToFile writes the mix, prints the path) and gloopy export-stems <project> [outdir] (one WAV per instrument track via a soloed render, emits a JSON stem list).
Both reuse the headless-CLI MainComponent + CoutSilencer for clean stdout. smoke.sh
proves both bounce non-silent audio. The gloopy CLI surface (inspect/validate/pack
/scan/analyze/render/export-stems) is now complete.
MIDI import/export + bulk JSON note I/O.MIdea #9/#14 + Ardour #5. Standard .mid file in/out (map to/from the beat-based
note model), plus ImportNotesJSON / ExportNotesJSON for fast generative
workflows. Define a small importer interface (Wave 6 grows it to Hydrogen, SFZ
folders).
Done when: import a .mid → render; export a clip → re-import → note lists match;
JSON note import builds a clip via the API.
[x]MIDI file I/O landed (Source/Midi.cpp, commit): apiExportMidi writes
a Type-1 SMF (tempo track + one track per instrument track, beats→ticks @960 PPQ);
apiImportMidi reads a SMF (juce::MidiFile, matched note pairs, ticks→beats,
tempo meta → transport bpm) into one synth track + clip per MIDI track, reusing
apiAddSynthTrack/apiAddClip. RPCs ExportMidi/ImportMidi (FilePath) +
Python export_midi/import_midi. Verified export→import→re-export round-trip
(bpm + notes survive, renders); smoke.sh asserts SMF magic + reimport→clips→
non-silent.
[x]MIDI export loop-expansion + desktop control landed (commit): apiExportMidi
now TILES a looped clip's content window across its arrangement length (mirroring
collectNotes/consolidate — notes clamped so they don't ring past the clip end), so the
exported .mid matches playback; a one-shot clip still emits once (unchanged). Also closed a
UI gap: MIDI export was API-only (import had a File-menu item, export didn't) — added a
"Export MIDI File..." item to the File menu (save FileChooser → apiExportMidi, defaults the
.mid extension). smoke proves a 2-beat-content/4-beat looped clip exports its tiled notes
(0/1/2/3, not just 0/1) via export→NewProject→import→GetClipNotes; screenshot-validated
(the File menu item). Not yet: multi-channel/CC import, per-clip (not per-track)
granularity.
[x]Notes JSON + clipboard copy/paste landed (commit): ExportNotesJSON emits a
clip's notes as a compact JSON array [{pitch,start,length,velocity},...];
ImportNotesJSON builds a new clip on a track at a beat from that JSON (clip length =
furthest note end). The parse/format is a pure header (Source/NotesJson.h, tolerant of
missing keys / a {notes:[...]} wrapper / clamps, unit-tested NotesJsonTests) shared by
the RPCs + Python export/import_notes_json. Desktop: clip right-click "Copy notes
(JSON)" -> system clipboard; right-click empty track space -> "Paste notes here" builds a
clip at that beat from the clipboard. smoke proves an export->import round-trip preserves
pitch/start/length; the GUI copy->paste was screenshot- AND functionally-validated (paste
created a clip with the copied notes). Gotcha logged: proto3 omits index 0, so a
successful import (new clip idx 0) reads back as absent, not -1.
shared
audioFile
takeId
[x]Slice at transients landed (commit, needed the audio-split fix): a pure
energy-flux onset detector (Source/Onsets.h, detectOnsets — short-frame log-energy
rise, peak-picked above mean+sensitivity·std with a 50 ms min gap, no FFT, unit-tested)
drives apiSliceClipAtTransients(trackId,index,sensitivity) — it detects onsets in an
audio clip's buffer, maps each onset source-sample → absolute beat (tempo-aware), and
splits the clip left-to-right at each (cutting the fresh right piece each time). RPC
(→SliceResult) + Python. Desktop UI: "Slice at transients" on the audio-clip menu —
screenshot-validated. smoke proves 4 staccato hits slice into ~4 clips; the detector
itself is unit-tested (hits at 0/4000/8000/12000 → 3 interior onsets; silence → none).
[x]Per-clip mute landed (commit): apiSetClipMuted(trackId,index,muted) disables a
clip in the arrangement without deleting it. Fixed a real gap: the MIDI collectClip
ignored clip.muted (only the audio renderer honoured it), so muting a MIDI clip was a
no-op — now both skip muted clips. clip.muted already serialises. SetClipMuted RPC +
Python. Desktop UI: "Mute clip" (checkable) on the arrange-view clip menu, for
non-take clips (takes use Use/Promote) — screenshot-validated. smoke proves a muted MIDI
clip renders silent (-13 -> -144 dBFS) and unmuting restores it.
[x]Clip gain + normalize landed (commit): apiSetClipGain (audio clip gain
in dB) and apiNormalizeClip (scan the clip buffer's peak, set gain so it hits a
target dBFS; returns the applied gain). Audio clips only (MIDI dynamics = velocity);
both under engineLock via the reverse-clip pattern. SetClipGain/NormalizeClip RPCs +
Python. Desktop UI:Normalize + Gain... on the arrange-view clip right-click
menu (audio clips only; Gain prompts for dB) — screenshot-validated on Xvfb.
smoke.sh proves it via deltas (insert loss cancels): normalize -6 vs -18 renders
12 dB apart; SetClipGain -6 dB drops the peak 6 dB.
[x]Per-clip fades landed (commit): Clip.fadeInBeats/fadeOutBeats + linear
fade edges applied in renderAudioClip (tempo-aware lengths, silent at the very
start/end). apiSetClipFades + SetClipFades RPC + Python; serialised in the
ValueTree (fadein/fadeout) and the composition (fade_in/fade_out), so they
round-trip. Desktop UI:Fades... on the audio-clip menu (in/out beats prompt)
— screenshot-validated. smoke.sh proves a 2-beat fade-in drops the first 0.25 s by
~16 dB vs no fade.
[x]Crop-to-range landed (commit): apiCropClip(trackId,index,start,end) trims a
clip to the absolute beat intersection. MIDI: the clip moves/shrinks and notes
overlapping the window are kept (onset/length clamped, re-based). Audio: the sample
buffer is trimmed to the [start,end) window (beat span → wall-clock seconds via the
tempo map → source samples; peaks rebuilt, fades reset, audioFile/takeId cleared so
the cut buffer embeds). CropClip RPC + Python. Desktop UI: "Crop to loop region"
on the arrange-view clip menu (MIDI or audio clips, enabled when a loop is set) →
crops to the transport loop — screenshot-validated on both. smoke proves MIDI
[0,1,2,3]→crop[1,3) keeps 62@0/64@1 + rejects empty ranges, and that an audio clip
cropped [2,4) saves as start=2/len=2 with the buffer cut to exactly the 2-beat window.
[x]Consolidate landed (commit): apiConsolidateClip(trackId,index) flattens a
looped MIDI clip — it writes out every repetition's notes as explicit notes at their
absolute positions (mirroring the render's beat-space tiling in collectClip;
onsets past the clip end dropped, tails truncated to the clip) and un-loops the clip
(contentLen=length, looped=false), so each repetition can then diverge. A
one-shot clip is already flat (no-op success); MIDI only. ConsolidateClip RPC (ClipRef)
Python. Desktop UI: "Consolidate loops" on the arrange-view clip menu, enabled
only for MIDI clips that actually tile (looped + content<length) — screenshot-validated.
smoke proves a 2-beat content tiled over 4 beats → notes at 0/1/2/3 and the clip saves
un-looped (content=len=4).
[x]Bounce-in-place landed (commit): apiBounceClip(trackId,index) freezes a
clip to audio — it renders just that track over the clip's [start,end) region offline
and soloed (reusing apiRenderToFile), then re-imports the WAV as an embedded audio
clip on a fresh " (bounce)" audio track. Non-destructive (source untouched);
works for MIDI (prints the instrument) and audio (prints the insert chain). BounceClip
RPC (ClipRef→TrackId) + Python. Desktop UI: "Bounce to audio" on the clip menu —
screenshot-validated. smoke proves the bounce makes a 1-clip audio track that renders
non-silent while the source MIDI clip stays intact. Known limitation: the bounce
is not level-matched — the soloed render already bakes in the track's insert/pan/
master, and the new audio track re-applies its own insert/pan/master, so the frozen
level differs (verified identical to a manual audio round-trip, so it's a mixer-hop
cost, not a bounce bug). A level-exact freeze needs a pre-master / pre-pan capture
path — follow-up.
[x]Split-at-named-marker landed (commit): apiSplitClipAtMarker(trackId,index, marker) resolves a named timeline location's beat from locations and delegates to
apiSplitClip (no-op if the marker falls outside the clip). SplitClipAtMarker RPC
(→ClipId) + Python. Desktop UI: a "Split at marker ▸ " submenu on the clip
menu, listing only markers that fall strictly inside the clicked clip (via a getMarkers
hook) — screenshot-validated. smoke proves a marker at beat 2 splits a [0,4) clip into
[0,2)+[2,4) with the right clip's notes rebased to 0/1.
Not yet: level-matched freeze (pre-master capture);
audio-clip consolidate (audio clips are one-shot).
[x]Fade curve shapes landed (commit): an audio clip's fade edges gain a fadeShape
(0 linear / 1 equal-power / 2 exponential) — a pure fadeShapeGain(shape, t) helper in
Source/FadeShape.h warps the linear fade position, shared by both edges. Equal-power
(sin·π/2, the constant-power crossfade law from Ardour/Reaper) is louder than linear;
exponential (t²) is a gentle slow start. apiSetClipFadeShape + SetClipFadeShape RPC
(ClipFadeShapeRequest) + Python set_clip_fade_shape; serialised on the CLIP ValueTree
(fadeshape, omitted when 0) + composition fade_shape. Desktop: a "Fade shape ▸
Linear / Equal power / Exponential" submenu on the audio-clip right-click menu. FadeShape
unit test proves the curve ordering/endpoints/clamp; smoke proves fade-region RMS orders
equal-power > linear > exponential and the shape survives a project round-trip (reproducible
re-render); screenshot-validated (the expanded submenu).
Buses & sends. ✦ LArdour #8. Explicit bus tracks; tracks route to a bus before master; per-send
levels; signal flow generator → track inserts → sends → bus inserts → master. Send
presets (vocal reverb, drum parallel comp, delay throw). Keep it minimal — no
general patchbay.
Done when: a reverb bus with two sends renders the expected wet/dry mix; routing
round-trips in the composition.
[x]Landed (Source/Buses.cpp + audio mix loop, commit): apiAddBus
(append a bus mixer track — MixerTrack.isBus, higher index so its buffer
accumulates sends before it's processed) and apiSetSend (upsert an aux send;
MixerTrack.Send{bus,level}, level≤0 removes). The mix loop taps each insert's
post-effects signal into its target buses (additive, independent of
mute/solo); the bus processes its own effects and sums to master like any insert.
Serialised in toValueTree/loadFromTree (MTRACK bus prop + SEND children,
with the FX-load loop now guarding hasType("FX")) + composition inserts.toml
(bus flag + sends = ["busIdx,level", …]). RPCs AddBus/SetSend, Send/is_bus
on MixerInsert; Python add_bus/set_send. Verified: reverb bus lifts tail RMS
0.040→0.189 (4.7×), routing round-trips. smoke.sh asserts the routing.
[x]RemoveBus landed (commit): apiRemoveBus removes a bus mixer track (buses
only — not master/regular inserts) and re-indexes sends across every insert (drops
sends targeting it, decrements sends targeting higher indices, since mixerTracks
indices are the send address space). RemoveBus RPC (reuses TrackId as the index) +
Python remove_bus. smoke.sh proves a send follows its bus down when a lower bus is
removed, and a non-bus (master) is rejected. Sends-in-scenes done last tick.
[x]Bus/send desktop UI landed (commit): the whole bus/send subsystem was API-only;
now the mixer strip's name right-click menu (showGroupMenu) gains a "New bus..." item (name
prompt → apiAddBus) and, per existing bus, a "Send to ▸ Off/25/50/75/100%" submenu that
reflects + sets the send level (apiSetSend), sourced from apiListInserts (isBus + the
insert's sends). New MixerView hooks onListBuses/onInsertSends/onSetSend/onAddBus wired to
the existing Buses.cpp API — no proto/API change (the routing is already headless-proven by
the reverb-bus smoke). Screenshot-validated (two buses created from the desktop, the strip
menu's New bus + "Send to Reverb Bus ▸ ✓Off/25/50/75/100%" submenu).
[x]Pre/post-fader send choice landed (commit): MixerTrack.Send.postFader — a PRE-fader
send taps the post-fx signal at its own level regardless of the fader/mute (a classic aux); a
POST-fader send follows the fader gain (incl. the group VCA) and is silenced when the channel
is muted/soloed out. The mix loop now computes the fader gain + audibility BEFORE the send tap
so post-fader can scale by it. post_fader on the SetSend RPC + Python set_send(post_fader=)
apiSetSend; serialised on the SEND ValueTree (post, omitted when false) + composition
(bus,level,post). Desktop: the "Send to " submenu gains a Pre-fader/Post-fader radio
(enabled once a send exists, checkmarked). Scene recall only updates send levels by bus so it
leaves post untouched (scenes capture level, not the tap point). smoke: a MUTED source's
pre-fader send still routes to the bus (rms 1.42M) while its post-fader send is silenced
(919k), and the post flag survives a project round-trip; screenshot-validated (the ✓Pre-fader /
Post-fader items under the level presets).
Not yet: send presets; a dedicated send-level fader per strip (menu presets for now).
Mixer scenes + control groups (VCA-lite).MArdour #9/#10. Named mixer snapshots (rough mix, vocal up, print mix) capturing
faders/pans/mutes/solos/send levels/bypass, stored as small composition files;
automation stays separate. Control groups = group fader/mute/solo/color as control
scaling, not extra audio routing.
Done when: snapshot → change faders → recall restores them (assert via
GetState); a group fader scales its members; both round-trip.
[x]Mixer scenes landed (Source/MixerScenes.cpp, commit):
DefineMixerScene(snapshot, upsert)/ListMixerScenes/RecallMixerScene/
RemoveMixerScene. Captures per-insert vol/pan/mute/solo + each effect's bypass;
recall tolerates added/removed inserts/effects. Serialised in
toValueTree/loadFromTree (SCENES/SCENE/INSERT) + composition scenes.toml
(inserts as compact "vol,pan,mute,solo,bypassbits" strings via the flat TOML
writer). Python client + smoke.sh (snapshot→mangle→recall restores exactly).
Verified vol/pan/mute + effect-bypass restore, composition round-trip.
[x]Aux-send levels now captured (commit): scenes also snapshot each insert's
sends (bus,level); recall restores the level of every still-existing send (matched
by bus, tolerating added/removed sends like inserts/effects). Encoded as a
"bus:level|..." string in the SCENE/INSERT ValueTree and appended as a 6th field to
the composition's compact insert string (back-compatible: old 5-field rows parse with
no sends). smoke.sh: set a send, snapshot, change it, recall -> level restored.
[x]Control groups (VCA-lite) landed (Source/ControlGroups.cpp, commit): a named
group whose fader SCALES its member inserts' volumes (control scaling, not audio
routing — the mix multiplies each insert's v by its group's gain and silences it if
the group is muted). Membership is a group name carried ON the insert
(MixerTrack.group), so it survives insert re-indexing. apiDefineControlGroup/
SetControlGroupGain/SetControlGroupMute/AssignInsertToGroup(""=clear, defines if
new)/RemoveControlGroup/ListControlGroups + RPCs + Python. Serialised in the
ValueTree (GROUPS/GROUP + a group attr per MTRACK) AND the composition
(groups.toml + a group field per insert in mixer/inserts.toml). Desktop UI: right-click a
mixer strip name -> control-group menu (New group.../Assign to/Group gain 0-100%/Mute/
Delete) — screenshot-validated. smoke proves gain 0.5 drops a member's soloed render
exactly 6 dB, mute silences it, and the group + membership survive both a .gloopy
round-trip and a SaveComposition/LoadComposition round-trip.
[x]Group solo (VCA solo) landed (commit): ControlGroup.solo — soloing a group
makes ONLY its members audible in the full mix (folded into the mix loop's audible
test alongside per-track solo: while anything is soloed, an insert plays iff it is
directly soloed OR belongs to a soloed group). apiSetControlGroupSolo + SetControlGroupSolo
RPC + Python set_control_group_solo; solo added to ListControlGroups. Serialised on
the GROUPS/GROUP ValueTree and groups.toml (omitted when false). Desktop: a "Solo
group" toggle on the mixer strip's control-group menu. smoke proves a soloed group's
full-mix render is byte-identical to a T1-only render (T2 dropped) and that the solo flag
survives a project round-trip; screenshot-validated (the "Solo group" menu item).
Not yet: a dedicated group fader strip in the mixer (menu-driven gain for now).
Source/Lfo.h
GloopyTests::Lfo
phase
unipolar
Python; serialised in MODS + mods.toml + composition. Desktop UI: the mixer "Add
LFO" prompt gains a Phase field and a Bipolar/Unipolar Range selector (screenshot-
validated). smoke proves a half-cycle phase shift changes a synced-saw render and that
phase 0.25 + unipolar round-trip.
[x]Slew / smoothing landed (commit): Mod.slewMs — a one-pole slew (ms time
constant) applied per block to the modulation value, softening abrupt shape edges
(square/saw) and zipper noise. Transient smooth state (smoothState/smoothInit, not
serialised) is seeded on the first block and reset at the start of every offline render
(resetModulationSmoothing) so bounces are deterministic. slew_ms on SetModulation +
ListModulations + Python; serialised in MODS + mods.toml + composition. Desktop UI:
the "Add LFO" prompt gains a "Smooth (ms)" field (screenshot-validated). smoke proves a
40 ms slew softens a fast square LFO (render differs) and slew_ms round-trips.
[x]Random / sample-and-hold LFO shape landed (commit): a 5th LFO shape (shape 4)
that holds a stepped random value for each cycle. The random value is a fixed integer
bit-mix of the cycle index (lfoHash in Source/Lfo.h) — no RNG state, so offline
bounces stay byte-identical (principle 4). Pairs naturally with the existing slew for
"wandering" (smooth-random) modulation. Just widened the shape clamp (0..4) and added
"Random (S&H)" to the Add-LFO shape combo — no proto/serialisation change (shape is
already an int). GloopyTests::Lfo proves S&H is held within a cycle, steps + is
deterministic across cycles, and stays in [-1,1); smoke proves a random-cutoff render
differs from static AND is identical across two renders (reproducible), shape=4 round-
trips; screenshot-validated (the expanded shape combo lists Random (S&H)).
[x]Multiple modulation sources per target (they sum) landed (commit): more than one
LFO can target the SAME ParamModel id and they now SUM instead of the last one winning.
evaluateModulation groups by target (allocation-free O(n^2) scan, no audio-thread heap)
and writes center(first source) + sum(depth_i*osc_i) once per target. apiSetModulation
is now a canonical single-set (clears any sources already on the target); a new
apiAddModulation/AddModulation RPC APPENDS an additional source; RemoveModulation
clears them all. The single-source render is byte-identical (slewing the delta is
equivalent to slewing center+delta since center is constant). Serialisation already looped
the mod vector, so multiples round-trip with no schema change. UI: the mixer "Add LFO..."
now STACKS (each invocation appends). Python add_modulation. smoke proves a 2nd LFO
stacks + changes the render, 2 sources on the target survive a composition round-trip, and
Remove clears all; desktop-validated by driving "Add LFO..." twice via the GUI so
ListModulations reports 2 sources on the param.
Not yet: envelope sources, per-source edit/remove UI (needs a mod-matrix view, #19),
plugin-param targets.
Tempo & time-signature map. ✦ MArdour #13. Tempo / time-signature changes on the timeline, stored as tempo
markers in the composition. Note/clip data stays beat-based, rendering
sample-based; add API helpers for bars/beats ↔ beats/seconds.
Done when: a tempo change mid-song changes the rendered duration as computed;
tempo markers round-trip.
[x]Conversion foundation landed (commit): beatToSamples/samplesToBeats
(tempo-aware, byte-identical to beat*spb / samples/spb when the map is empty;
engineLock is recursive so they're callable from renderBlock).
[x]Scheduler rewrite landed (commit): renderBlock snapshots the markers
once per block into an allocation-free TempoConv (NoteScheduler.h) — the
empty-map case is the constant llround(beat*spb) path (byte-identical), a
non-empty map integrates the tempo piecewise over a fixed-size marker snapshot, so
the audio thread never locks or allocates to convert (principle 4). collectClip
now tiles repetitions in beat space (beatToSample(startBeat + k*repBeats)),
and collectNotes takes an absolute repStartBeat + the TempoConv and places
each note at its absolute song-sample position, filtered to the block window. Also
routed through the map: loopLen, the loop window, the seek target, the automation
beat, audio-clip anchors, apiRenderToFile range endpoints, and MIDI-record beats;
loadFromTree sorts the map (the snapshot needs beat-ascending). Proven: a
GloopyTests case asserting the exact integration of a mid-song 120→240 change
(sample positions 50/100/125/150) + collectNotes honouring it, and a smoke.sh
assertion that a mid-song speed-up shortens a fixed 0..4-beat render while staying
above half (pre-marker beats preserved). Empty-map smoke suite stays green.
[x]Time signature landed (commit): a project time signature on the transport
({num,denom}, default 4/4) with beatsPerBar() in quarter-note beats (4/4→4,
3/4→3, 6/8→3, 7/8→3.5). SetTimeSignature/GetTimeSignature + a bars↔beats conversion
API (BeatsToBarBeat/BarBeatToBeats, 1-based "bar.beat") — the roadmap's "bars/
beats↔beats helpers." Serialised (root tsnum/tsden + composition manifest).
The beatsPerBar constant in ArrangeView/PlaylistView became a member refreshed
from the transport (call sites unchanged), and the "1.1.00" position readout is now
time-sig-aware. Desktop UI: "Time signature..." on the beat-ruler right-click
menu (num/denom prompt) — screenshot-validated (a 12-beat clip spans 4 bars at 3/4,
not 3 at 4/4). smoke proves the conversions (4/4 beat6=bar2.3 vs 3/4 beat6=bar3.1,
inverse round-trips) + save/reload. Not yet: time-signature map (per-bar
changes), and the PianoRoll/StepEditor internal %4 bar markers.
Scales & microtuning.MIdea #11. Project-level scale definitions; per-track tuning mode; piano-roll
scale highlighting + optional snap-to-scale; API helpers for generative clients;
Scala/keymap import later.
Done when: set a project scale via API, snap a note list to it, render; scale
defs round-trip.
[x]Scales + snap landed (Source/Scales.cpp, commit): project scale
{root, name, intervals} set by explicit intervals or a built-in name (major,
minor, modes, pentatonics, blues, whole-tone, chromatic); apiSetScale/
apiGetScale; apiSnapClipToScale snaps each note to the nearest scale degree
(snapPitchToScale, ties round up). Pure metadata + pitch math — no audio path.
Serialised on the root ValueTree + composition manifest (scale_root/
scale_name/scale_intervals). RPCs SetScale/GetScale/SnapClipToScale; Python
set/get_scale + snap_clip_to_scale. Verified chromatic→C-major snap (correct
tie-ups) + round-trip.
[x]Per-track detune (microtuning) landed (commit): a whole-voice detune
(cents, ±2 octaves) on the built-in synth — baseFreq *= 2^(cents/1200) at note-on.
Rides the universal param model as track/<id>/synth/detune (SetParameter/GetParameter
/modulation/automation all free) and serialises with the other synth params.
smoke proves the cents→frequency mapping exactly: +1200 cents doubles a sine's
zero-crossing rate (ratio 1.999) and the value round-trips.
[x]Per-pitch-class microtuning + Scala import landed (commit): a project-level
12-entry cents-offset-from-ET table (projectTuning, all 0 = 12-TET) applied at the
built-in synth's note-on alongside detune (baseFreq *= 2^((detune + tuning[note%12]) /1200)). Stored in SynthParams.tuning (12 atomics), broadcast to every synth by
applyTuningToSynths (new tracks inherit it; load re-applies). apiSetTuning/
apiGetTuning + apiImportScl (a Scala .scl parser in Source/Scales.cpp: skips
description + note-count, reads cents x.y / ratios a/b/n, maps degree i to pitch
class i's offset = cents − i·100). RPCs SetTuning/GetTuning/ImportScl + Python. Serialised
on the root ValueTree + composition manifest (tuning_cents, omitted for 12-TET).
Desktop: File → "Load Tuning (.scl)..." (chooser) + "Reset Tuning (Equal)". smoke:
+1200c on C doubles a sine (ZCR 2.005), survives a composition round-trip, and a test
.scl (degree 1 = 150c) yields class-1 offset +50; screenshot-validated. Gotcha logged:
the .scl state machine needs explicit have-description/have-count flags — a < 0
sentinel for "description seen" swallowed the note-count line. Not yet: SFZ/plugin
microtuning, per-note (not per-class) tables, .kbm keyboard maps, piano-roll UI.
Plugin scan cache + CLI scan.S/MArdour #15. Persist scan results (id, name, format, path, vendor, category,
parameter summary, scan status); record failed scans without blocking startup;
keep plugin identity stable in composition files even if the path moves; add a CLI
gloopy scan.
Done when: a scan writes a cache, a moved-path plugin still resolves on load, and
gloopy scan runs headless.
[x]Landed (commit): enriched PluginInfo/PluginSnap with vendor,
category, version, and input/output channel counts (from the cached
PluginDescription); ListPlugins/ScanPlugins return them; a headless
gloopy scan [--force] CLI prints the cached list as JSON (valid empty array
with zero plugins). The scan already persists to ~/.config/Gloopy/plugins.xml
and load re-resolves by identifier (stable across path moves). Verified: 68
cached plugins with vendor/category/channels; smoke asserts valid JSON.
Not yet: per-plugin parameter summary (needs instantiation), failed-scan
status records, blocklist.
Real-time safety diagnostics.S/MArdour #16. A small diagnostics surface: audio-callback time, DSP load,
xruns/dropouts, plugin latency, render speed, device settings — over the API and in
CI smoke logs. Debug counters/asserts for allocations/locks on the audio path;
mark xrun events on the timeline.
Done when:GetDiagnostics returns live counters; the smoke log includes them.
[x]Landed (Source/Diagnostics.cpp, commit): GetDiagnostics RPC →
device sample-rate/block-size/inputs/outputs, live audio-callback time (last +
max µs) and DSP load, dropped-block count (engine-lock contention in
getNextAudioBlock), and last offline render speed (x realtime). Instrumented with
lock-free relaxed atomics on the audio thread + timing in apiRenderToFile — the
audio path stays lock-free. Python diagnostics(); smoke logs it. Verified:
44100/512/2in/2out, render ~130-180x realtime, DSP load ~0.2%. Not yet:
per-plugin latency, xrun marks on the timeline, allocation/lock debug asserts, a
big transport/record status view.
Selective built-in effects & analyzers.M (curated — see principle 5)
Idea #8/#13. Fill obvious gaps only: parametric EQ, compressor (have limiter),
bitcrusher, chorus/flanger, stereo widener, waveshaper/soft-clipper; plus
oscilloscope / spectrum / vectorscope as non-mutating inserts exposing
API-visible analyzer snapshots. Each registers in Effects.hmakeEffect + the
EffectType proto enum, and works in effect-chain presets.
Done when: each effect renders its expected transform; an analyzer snapshot is
retrievable over the API.
[x]Bitcrusher + Compressor landed (Source/Effects.h, commit): two
Effect subclasses. Bitcrusher = bit-depth reduction + sample-rate decimation +
wet/dry (Bits/Downsample/Mix); Compressor = peak-detected soft-knee with
Thresh/Ratio/Attack/Release/Makeup. Registered in EffectFactory::create +
types(), the proto EffectType enum (BITCRUSHER=5, COMPRESSOR=6), and
apiAddEffect's names[]; params flow through the existing EffectParam
interface so GetEffectParams / effect presets / ParamModel / automation /
modulation all work for free. Verified via render: bitcrusher quantizes to ~5
distinct sample values; compressor is level-dependent (loud note ducked 10 dB vs
quiet 6 dB — dynamic range squeezed). smoke covers both.
[x]Parametric EQ + Waveshaper landed (commit): EqFx (single peaking
band, RBJ biquad, Freq/Gain/Q) and WaveshaperFx (tanh soft-clip Drive + Mix),
as EffectType EQ=7 / WAVESHAPER=8 (all four registries kept in sync). Verified via
render: EQ ±18 dB @ 500 Hz shifts band RMS ~17 dB (boost vs cut); waveshaper drive
25 raises RMS ~17 dB (saturation). smoke covers both.
[x]EQ upgraded to 3 bands (commit): EqFx is now a low shelf + mid peak +
high shelf chained per channel (Low Freq/Low dB, the original Freq/Gain dB/Q mid,
High Freq/High dB — 7 params). Backward-compatible: the mid band keeps the old
param names so projects that stored the single-band EQ still load, with the new
shelves defaulting flat. The RBJ peak/low-shelf/high-shelf coefficient math moved to a
pure header Source/Biquad.h (with a transfer-function magnitude evaluator),
unit-tested (GloopyTests::BiquadEq: each shelf hits its target gain in-band and is
flat out-of-band; 0 dB is unity). smoke proves both shelves shift RMS (added a bright
saw track so the master has HF content for the high shelf); the 7 knobs auto-render in
the generic FX param panel (screenshot-validated). No proto change (effects serialise
generically by param name).
[x]Tempo-synced delay (commit): effects can now be tempo-aware — added a
Effect::setTempo(bpm) hook, called each block for every insert/master effect with
transport.getBpm(). DelayFx gained a "Sync bt" param: 0 = free (Time ms), else the
delay length in beats (0.5 = 1/8, 1 = 1/4, ...), converted to ms via the block tempo.
smoke proves it exactly: a synced 1/4 @120 BPM is a byte-for-byte match of a free 500 ms
delay (diff 0.00000, re-adding a fresh delay per render so feedback tails don't bleed
across renders — a gotcha), and a tempo change to 240 BPM halves it (render differs).
The "Sync bt" knob auto-renders in the FX panel (screenshot-validated). The setTempo hook
generalises to future tempo-synced effects.
[x]Tempo-synced modulation effects (commit): Chorus/Flanger/Phaser gained the same
"Sync bt" param (LFO cycle length in beats; 0 = free Hz) via the setTempo hook. The
sync->rate math is a pure header (Source/EffectSync.h, effectSyncedRate(bpm, syncBeats, freeRate) = bpm/(60*beats) clamped), unit-tested (GloopyTests::EffectSync).
The chorus smoke now proves tempo-sync EXACTLY (Sync 1bt@120 == free 2 Hz, diff 0.00000)
once the reproducible-bounce fix below landed. Not yet: dotted/triplet sync labels.
[x]Reproducible offline bounces (effect reset) landed (commit): apiRenderToFile
now calls fx->reset() on every insert/master effect before rendering (next to the
existing resetModulationSmoothing), so a bounce is bit-identical run-to-run. Fixes a
real pre-existing bug found while testing tempo-synced effects: modulation effects
(delay/chorus/flanger/phaser) carried their delay-line + LFO-phase state across renders,
so two renders of the same project differed (~0.026) — non-deterministic, violating the
composition-as-code "reproducible render" principle. Verified: chorus, and chorus + a
0.7-feedback ping-pong delay (worst case), now render byte-identical twice (diff 0.0);
the tempo-synced-chorus smoke asserts sync==sync2 and the exact free-vs-synced match.
[x]Stereo Widener landed (commit): StereoWidenerFx, mid/side, one Width
param (0 mono / 1 unchanged / 2 double-wide), as EffectType STEREO_WIDENER=9 (all
four registries synced). The pure transform lives in Source/StereoWiden.h
(widenSample) so it's unit-tested without the audio-processor dep — GloopyTests:: StereoWidener proves width 0/1/2 side scaling, mono-sum invariance, and that a
mono signal is untouched; smoke proves the enum→factory→Width-param wiring. The
mixer add-effect menu is data-driven from EffectFactory::types(), so the desktop
affordance is automatic.
[x]Chorus landed (commit): ChorusFx, a short LFO-modulated delay (base
12 ms, sweep depth ms) with no feedback, dry/wet Mix, and a quarter-cycle L/R LFO
offset for width. Rate/Depth/Mix params; EffectType CHORUS=10 (four registries
synced); auto-listed in the data-driven mixer menu. smoke proves the enum→factory
→Mix wiring: Mix=0 is a bit-exact passthrough of the dry render, Mix=0.8 audibly
differs (mean|Δ|=0.08).
[x]Flanger landed (commit): FlangerFx, a very short LFO-swept delay
(0.5 ms centre, ≤5 ms depth) with feedback — a resonant comb "jet" sweep,
unlike the feedback-free chorus. Rate/Depth/Feedbk/Mix params; EffectType
FLANGER=11 (four registries synced); auto-listed in the mixer menu. smoke proves
the enum→factory + Feedbk/Mix params: Mix=0 bit-exact passthrough, Mix=0.8 active
(mean|Δ|=0.10). The chorus/flanger backlog pair is done.
[x]Phaser landed (commit): PhaserFx, a cascade of 6 LFO-swept first-order
allpass stages (log sweep ~200 Hz..up to 5 octaves) with feedback and dry/wet Mix —
sweeping notches, completing the modulation-effects family (chorus/flanger/phaser).
Rate/Depth/Feedbk/Mix params; EffectType PHASER=12 (all four registries synced — proto
enum, types(), create(), the apiAddEffect names[]; also back-filled the stale
Python EFFECTS map which had stopped at WAVESHAPER=8). The pure DSP is
Source/AllpassPhaser.h (allpassStage/phaserCoeff), unit-tested
(GloopyTests::AllpassPhaser: coefficient range/monotonicity + the allpass
magnitude-preservation property across a 6-stage cascade) without the audio-processor
dep. smoke proves the enum→factory→params wiring (waveform diff 0.13 vs dry, stays
level-matched within ~1.4 dB); screenshot-validated (Phaser in the add-effect menu).
[x]Tremolo landed (commit): TremoloFx, periodic amplitude modulation — a sine
LFO scales the gain between 1 and (1 - Depth) at Rate Hz, or tempo-synced when Sync bt > 0
(reusing the shared effectSyncedRate beats→Hz law from the modulation effects + the