Desktop App TODO¶
Tracks work for the minihost-desktop binary described in desktop_app.md. Library-level work (CLI, Python bindings, C ABI improvements not specifically driven by the desktop app) lives in the root TODO.md.
Ordered roughly by dependency: items lower in a tier generally depend on items above them.
Phase 0 - Prove the stack¶
Throwaway prototype work. If any item here can't be done in ~a day, the stack choice or the scope is wrong and the design doc needs revisiting before further investment.
-
[x] Build a GUI-mode
libminihost. Siblingminihost_guistatic library defined inprojects/libminihost/CMakeLists.txtalongside the headlessminihost. Built whenMINIHOST_BUILD_DESKTOP=ONorMINIHOST_BUILD_GUI_LIB=ON. Both archives coexist in a single build tree. -
[x]
mh_get_juce_processorC ABI shim.void*accessor inminihost.{h,cpp}returning the underlyingjuce::AudioProcessor*. Header stays valid C; only consumers that link JUCE cast. -
[x] Editor-window smoke prototype.
projects/minihost_desktop/ src/main.cpp: loads a plugin viamh_open, opens its editor in aDocumentWindow, renders 5 s to a WAV viamh_process_midi.--auto-renderflag runs the render-and-quit path non-interactively. Validated with Dexed (5.0 s of 48 kHz / 2 ch / 24-bit PCM, clean exit, editor visible throughout the render). -
[x] Editor lifetime / locking probe.
--probe [--iterations=N]mode inmain.cpp. Opens the editor, runs N consecutive 5 s renders on a worker, simultaneously drives parameter writes from (a) the message thread via ajuce::Timerand (b) a dedicated worker thread at high rate. Watchdog-bounded. Validated: -
Dexed (VST3, synth, 2238 params): 10 iters / 50 s of render, 1715 worker-thread param writes, exit 0.
-
gigaverb (VST3, effect, 8 params): 5 iters, 701 worker writes, exit 0.
No deadlock, no crash, clean mh_close on shutdown with both writers active until just before the close. Editor-thread Timer writes are starved (~0.5 Hz effective) under render load -- not a correctness problem but documents that high-rate UI feedback during heavy DSP may lag.
Follow-ups (not blocking v1, captured for honesty): - [ ] Re-run the probe under ThreadSanitizer. Explicitly deferred: needs a TSan build configuration + CI plumbing that's out of scope for in-session work. Schedule when CI machinery exists.
-
[x] Editor open/close mid-render stress.
--probenow toggles theAudioProcessorEditor(full ctor/dtor cycle) every 250 ms across the render iterations. Validated on Dexed: 3 iters x ~5 s + ~60 editor cycles, 338 worker-thread param writes, exit 0. No crash, no deadlock against the render thread. -
[ ] Broaden plugin coverage (AU, LV2; commercial plugins). Deferred: test-fixture procurement; the v1 probe runs cleanly on every VST3 we've tried (Dexed, gigaverb).
Phase 1 - Offline graph renderer with editor windows¶
Graph executor in C¶
-
[x]
mh_graph_v2_*C API inprojects/libminihost/(minihost_graph_v2.{h,cpp}). Kahn topological sort, channel-count validation, per-node output buffer pool. Built-in node kinds:input,output,mix(gain skipped -- redundant with mix weights). C++ wrapperminihost::GraphV2inminihost_graph_v2.hpp(header-only, RAII). -
[x] Render-block entry point
mh_graph_v2_render_blockdrivingmh_processper plugin node. v1 uses block-level scheduling (sample-accurate automation handled inside each node bymh_process_autoif/when the graph plumbs automation through; topology and buffer pool prove out without it). -
[x] Python binding
minihost.GraphV2insrc/minihost/_core.cpp. ExistingPluginGraphunchanged. Plugin refs kept alive vianb::keep_aliveonadd_plugin. -
[x] Parity tests in
tests/test_graph_v2.py(15 tests): topology / validation (9), non-plugin numerical parity (4), plugin parity againstPlugin.process_audio(2). Full suite 576 -> 591 passed, 71 skipped, no regressions. -
[x] Automation passthrough. New C ABI
mh_graph_v2_set_node_automationstagesMH_ParamChangelists for a plugin node on the next render_block. Plugin nodes with automation set dispatch viamh_process_auto(combined with MIDI if both are set; cleared after each render_block). Python bindingGraphV2.set_node_automation(node_id, [(sample_offset, param_index, value), ...])with per-node scratch storage that outlives Python call boundaries. Parity testtest_graph_automation_matches_plugin_process_autoasserts the graph path matchesPlugin.process_autoto within 1e-5. -
[ ] MIDI routing -- second scheduling lane for MIDI events between nodes. Defer until a concrete use case appears.
Application shell¶
-
[x]
projects/minihost_desktop/sub-project. Scaffolded with stubmain.cpp(exits immediately) and CMakeLists linkingminihost_gui. Top-levelCMakeLists.txtadds it viaadd_subdirectoryguarded byMINIHOST_BUILD_DESKTOP(default OFF). -
[x] Split
libminihost_audiointo siblingminihost_audio(PUBLIC-links headlessminihost) andminihost_audio_gui(PUBLIC- linksminihost_gui). Desktop linksminihost_audio_gui. Python wheel and CLI tools continue to linkminihost_audiounchanged (576 tests pass). -
[x] MainWindow + menu bar.
MainWindowis a JUCEDocumentWindow+MenuBarModelwith a system menu bar (macOS main menu on Mac, native menu bar elsewhere). File menu: Open Plugin... (asyncjuce::FileChooser-> loads the plugin and opens anEditorWindow), Quit. Help menu: About. The shell launches with no command-line args and waits for the user. Edit / View / Render menus deferred until ProjectModel / GraphCanvas / RenderDialog exist (no useful commands without them). -
[x] Multi-window plugin lifetime.
DesktopApplicationowns anOwnedArray<EditorWindow>; eachEditorWindowowns itsMH_Plugin*andmh_closes in its destructor.closeButtonPressedinvokes a callback that removes the window from the array. Single-plugin shortcut mode (positional plugin arg, no MainWindow) quits when the last EditorWindow closes. Phase 0 validation modes (--auto-render,--probe) still reachable behind explicit flags. -
[ ]
ProjectModelobservable refactor. Explicitly deferred: the currentProjectDocument+ canvas direct-mutation pattern works. An observable refactor only buys value once undo/redo is on the roadmap; revisit then. -
[x] Plugin browser dialog.
Plugins > Plugin Browser / Scan...opens ajuce::PluginListComponentover an app-ownedAudioPluginFormatManager(registered viajuce::addDefaultFormatsToManager-- JUCE 8.0.11 movedAudioPluginFormatManagerto the headless module and deleted itsaddDefaultFormats()) +KnownPluginList. The scanned list persists toknown_plugins.xmlnext to the device settings (saved on shutdown, restored at launch).Plugins > Add Plugin from Library...builds a menu offKnownPluginList(staticaddToMenu/getIndexChosenByMenu) and adds the chosen plugin to the canvas via the sharedCanvasComponent::addPluginFromFilepath (same probe asAdd Plugin...). Two known limitations: scanning is in-process (a plugin that crashes on instantiation-during-scan takes down the app -- the out-of-process scan mitigation is tracked under Crash resilience); and plugins identified by an AU id rather than a file path add an unprobed node (mh_open takes a path), which defers channel validation to load time. Verified by compile only -- the scan/list UI needs a display and real plugins, neither available in the headless test environment. -
[x] Scanner proven headless + testable. Added a
minihost_desktop --scan-plugins[=<dir1;dir2>] [--scan-out=<file.xml>] [--scan-format=<VST3|AudioUnit|LV2>]mode (main.cpprunPluginScan) that registers the host formats and drives ajuce::PluginDirectoryScannerper format to completion, writing theKnownPluginListto XML. This is the headless seam the compile-only note above was missing:tests/test_desktop_pluginscan.pyscans an empty dir (always-on; proves the VST3 format registers, the scan terminates, and valid XML is written) and, whenMINIHOST_TEST_PLUGINpoints at a.vst3, scans a copy of that one plugin and asserts the library records it. Confirmed live: a hermetic single-VST3 scan finds the plugin; a full default scan on a real machine exercises (and demonstrates) the in-process fragility -- a commercial AU callingexit()during instantiation-during-scan ends the process, so--scan-format=VST3is required for a hermetic single-dir scan (AudioUnit scanning ignores the directory list and enumerates every system component). The CLI also seedsknown_plugins.xmlfor the GUI. -
[x] AudioUnit support (open-by-descriptor). minihost was a path-only host:
mh_openrequires a file thatexists(), so AudioUnits -- identified by an AU id, not a path -- could not load at all (the picker surfaced ~391 AUs that all failed "Plugin file does not exist"). Added aPluginDescription-based load path across the whole stack. C ABI:mh_open_desc(pd_xml, ...)+mh_session_open_desc(minihost.{h,cpp}) deserialize ajuce::PluginDescription(itscreateXml()form) and reuse the sharedfinishPluginFromDescinstantiation tail; AU was already registered ininitFormatManager, and the descriptor wrapper always brings up the message thread (AU needs it). Desktop: plugin nodes gained an optional base64descriptorfield (+display_name); the picker now carries the fullPluginDescription(not a collapsedFile);CanvasComponent::addPluginFromDescriptionand a sharedprobeAndAddPluginroute AU picks throughmh_open_descwhile VST3/LV2/browsed files keep the path route; the project loader, standalone editor (loadPlugin/EditorOptions.descriptor_xml), and canvas double-click editor all branch on the descriptor. Python/CLI parity:Plugin.from_descriptor(pd_xml, ...)binding +.pyi, andproject.pypersists/readsdescriptorand opens AU nodes via it -- so a project made in the desktop app also renders from the CLI. Verified:tests/test_au_descriptor.py(4 tests, stock Apple AU) --from_descriptoropens + processes a block, bad XML/nonexistent AU raise,render_projectwith an AU node produces audio, and thedescriptorfield round-trips through project JSON. Also confirmed live end-to-end: AUBandpass loads via descriptor and renders through the desktop--render-projectgraph. Out of scope: Python-side authoring of AU projects (scanning to descriptors in Python) -- projects are authored in the desktop app for now. Two pre-existing bugs surfaced (not AU-specific). Fixed: the legacyreceives_mididefault-true migration crashed when wiring MIDI to a plugin that doesn't accept it --loadProject(project.cpp) now checks each opened instance'smh_get_info().accepts_midiand only wires MIDI-accepting plugins, so effect-only graphs load instead of aborting (tests/test_desktop_render_parity.py::test_desktop_effect_only_no_midi_migration_crash, using a stock Apple AU effect). Fixed: the desktop--render-projectaborted on process exit after a plugin load (VST3 too; output was written correctly first). Cause: the desktop app is a JUCE app that already owns the process MessageManager, but libminihost spins up its own message thread on the firstmh_open(for headless Python/CLI callers). In this process that second thread never becomes the real message thread and is left joinable at exit ->std::terminate. Fix:DesktopApplication::initialise()setsMINIHOST_MESSAGE_THREAD=0(respecting an explicit override) before anymh_open, so construction runs inline on the app's own threads and teardown is clean. Plugin renders now exit 0 (asserted intest_desktop_effect_only_no_midi_migration_crash). -
[x] Plugin list picker everywhere.
File > Open Plugin...and the canvas right-clickAdd Plugin...no longer open a raw filesystem chooser -- both route through one sharedDesktopApplication::showPluginPickerpopup that lists the scanned library first (KnownPluginList::addToMenu), then aBrowse to file...fallback and aScan for plugins...shortcut. Open Plugin hosts the pick in a standalone editor window; Add Plugin adds a graph node viaCanvasComponent::addPluginFromFile. The canvas gainedsetOnAddPluginRequested(falls back to its own file chooser when used standalone, so the component stays self-contained);MainWindowgainedsetOnRequestOpenPlugin+setOnAddPluginRequested. Empty library shows a disabled(no plugins scanned)header plus the Scan shortcut, so the next step is self-evident. Fixes the discoverability trap where the list flow was buried under a separatePluginsmenu and gated on a scan the user never found. Menu/popup UI verified by compile + the headless scan test; the popup itself needs a display to click.
Node-graph canvas¶
-
[x] Canvas widget (read-only view + drag). Lives in
projects/minihost_desktop/src/canvas.{h,cpp}. Renders aproject::ProjectDocumentas rounded-rectangle nodes with port circles and bezier-curve edges. Auto-layout by topological column (column = max predecessor column + 1) and row (sequence within column). Per-kind colour coding (input = blue, output = rust, mix = olive, plugin = slate). Click to select, drag to reposition. Wired intoMainWindowviaFile > Open Project.... -
[x] Connect / disconnect / delete edit ops. Drag from a node's output port to an input port creates an edge; click an edge near its midpoint to select it; Delete / Backspace removes the selected node (cascading any edges that reference it) or the selected edge. In-progress connect drag draws a dashed bezier preview.
-
[x] Add nodes (Mix presets, Plugin file chooser, Input via audio-file chooser, Output via save-file chooser). Right-click context menu wires all four kinds. Input takes channel count from the chosen file's actual channel count (via
mh_audio_get_file_info); sample-rate mismatches surface as an alert. Output defaults to 2-channel / 24-bit; further property editing of existing nodes is deferred to a property-panel slice. -
[x] Edit existing node properties. Right-click a node -> Properties... dialog tailored to the kind: input (id, channels, source path), output (id, channels, sink path, bit depth), mix (id, num_inputs, channels, gains), plugin (id; path/state_b64 read-only). For mix nodes, shrinking
num_inputscascades: edges withdst_port >= new_num_inputsare dropped automatically. ID renames rewrite all referencing edges and the layout map. -
[x] Channel-count validation at connect time. Adding a plugin via the canvas now probes it eagerly (
mh_open+mh_get_info+mh_close) and cachesprobed_in_channels/probed_out_channelson thePluginNodeSpec.addEdgeToDocrejects mismatched edges with an alert before they hit the doc. Plugins loaded from disk (no fresh probe) skip canvas-side validation; render-time still catches mismatches viamh_graph_v2_compile. -
[x] Undo / redo. Snapshot-based: each canvas edit records the whole
ProjectDocument(which round-trips losslessly, so a copy is a correct restore point) into anUndoHistory(src/undo_history.h, GUI-free, depth-capped) before mutating; undo/redo swap the document in place under the stabledoc_pointer. Wired at every mutation site (add/delete/connect/move/properties); plain node clicks and rejected edges do not record no-op steps. Exposed viaEdit > Undo / Redo(enabled state refreshed on each edit) and Cmd/Ctrl+Z / Cmd/Ctrl+Shift+Z. The core is exercised end-to-end through the binary's--undo-selftestmode (tests/test_desktop_undo.py): a mutation changes the serialization, undo restores the pre-edit bytes exactly, redo re-applies, and a fresh edit clears the redo stack. Editor-window edits are not yet coalesced into a single step (see below). -
[x] Save canvas positions back to disk. Schema extended with optional
layout: {node_id: {x, y}}. Loader populatesProjectDocument.layout; canvas applies saved positions for known nodes (falls back to auto-layout for any node missing from the map). Drag end (mouseUp) writes the new position back to the document;File > Save Projectserializes it viaproject::saveProjectFile(atomic tmp + rename). Verified: Python load->save round-trip preserves layout; C++ save -> Python load preserves layout byte-for-byte. Three new Python tests pass (test_load_project_without_layout,test_layout_round_trip,test_layout_drops_unknown_ids); full suite 604 -> 607 passed.minihost_desktop --save-roundtrip=<path>is the headless parity-test entry point.
Editor windows¶
-
[x]
EditorWindowDocumentWindowsubclass owning a transientMH_Plugin*+ JUCEAudioProcessorEditor*. Configurable toolbar viaEditorOptions::toolbar_label/toolbar_action. Used in three call sites: Phase 0 single-plugin shortcut ("Render 5s" button), MainWindowFile > Open Plugin..., and canvas double-click (new this slice: "Capture State" button). -
[x] Canvas double-click -> editor. Double-clicking a plugin node opens a transient editor against a freshly-loaded plugin instance with state restored from
state_b64if present. The "Capture State" button readsmh_get_state, base64-encodes it, and writes back todoc.plugins[i].state_b64.File > Save Projectthen persists the new state. Multiple editors can be open simultaneously (each owns its ownMH_Plugin*). -
[ ] Parameter mirror. Explicitly deferred: JUCE's editor already shows live values; a host-side mirror only matters for automation-overlay UIs and MIDI-learn workflows, neither of which exist yet.
-
[ ] Editor session as a single undo entry. Explicitly deferred together with the undo/redo subsystem above.
Project file I/O¶
-
[x] JSON schema v1 (
minihost_project_version: 1). Defined once insrc/minihost/project.py(Python loader, canonical reference) and re-implemented in C++ atprojects/minihost_desktop/src/project.{h,cpp}for the desktop binary. Same schema, same semantics. -
[x] Load / save (Python):
minihost.load_project,minihost.save_project(atomic via tmp + rename). Load (C++):minihost_desktop::project::loadProjectandparseProjectFile. Save in C++ deferred until the desktop UI needs to write back (no save-from-canvas yet). -
[x] Plugin state blob -- persisted via
mh_get_state/mh_set_state, base64-encoded. Decode supported in both loaders. -
[x] Round-trip + render parity tests. Python:
tests/test_project.py(13 tests). C++ vs Python parity: end-to-end demo renders the same project through both pipelines and observes 0.000e+00 max sample diff -- bit-identical output. -
[x]
minihost render <project.json>CLI (Python entry point).minihost_desktop --render-project=<project.json>(C++ headless mode; same code path as the GUI menu). -
[x] Offline MIDI-file rendering (desktop/Python parity). A
midi_inputnode gained an optionalsource(.mid) field. Previously the desktopmidi_inputwas live-only (port_name), so an offline render of a MIDI-driven instrument produced silence with no error, while the Python renderer (whose_MidiInputNodehas asource) rendered it correctly. Fixed:MidiInputNodeSpec.source;readMidiFileEvents(project.cpp, via the vendoredsmf::MidiFile) flattens a.midto sorted absolute sample-offset events (channel-voice only, truncatingint(seconds*sr)to match Python'smidi_file_to_events);renderProjectstreams them into the graph per block with forward cursors (mh_graph_set_midi_input_events), mirroring the Python_render_loadedloop. Verified bit-identical:tests/test_desktop_render_parity.py::test_desktop_midi_chain_matches_pythonrenders a MIDI chord -> instrument through both the desktop binary and Python and asserts non-silence + max sample diff == 0. The desktop links themidifilestatic lib. -
[x] File > Render Project... menu in the desktop shell. Async via
juce::ThreadWithProgressWindow(modal progress bar with Cancel). Result alert on completion. -
[x] File > New Project -- creates an empty
ProjectDocument(sample_rate=48000,block_size=512, no nodes/edges) and shows it on the canvas with the title bar reading "(untitled)". Drives the same edit surface (right-click to add nodes, drag ports to connect) as projects opened from disk. File > Save Project As... prompts for a path; Save Project on an untitled document falls through to Save As automatically and retitles the window on success.
Render¶
-
[x] Render dialog. Async
juce::AlertWindowshown beforeProjectRenderJob::launch: bit-depth selector (16/24/32) and normalize-to-dBFS field (0 = off, e.g. -1.0). Picks are threaded throughproject::RenderOptionstorenderProject. -
[x] Render thread.
ProjectRenderJobis ajuce::ThreadWithProgressWindow.run()callsloadProject+renderProjectblock-by-block. -
[x] Progress + cancel. Progress wired via
setProgress(done/total)per block; the progress window's Cancel button flipsthreadShouldExit()which the render loop's progress callback forwards to thecancel_flagatomic. -
[x] Render parity test.
tests/test_desktop_render_parity.py(2 tests): C++ desktop--render-project=...vs Pythonrender_projectproduces bit-identical WAVs (max sample diff == 0.0). C++--save-roundtrip=...vs Python load preserves layout / edges / channels.
Packaging¶
Explicitly deferred -- all three need CI machinery + dev certificates that aren't available in-session. The build already produces a runnable .app bundle locally (via the CMake APPLE block in projects/minihost_desktop/CMakeLists.txt); signing / notarization / installers are CI-time concerns.
-
[ ] macOS
.appbundle with code signing + notarization. -
[ ] Windows portable
.exe. -
[ ] Linux AppImage.
Phase 2 - Realtime mode¶
Adds the audio device callback driver and live MIDI input. Same graph, stricter realtime constraints.
-
[x] Audio device picker.
LiveEngineowns ajuce::AudioDeviceManager.Audio > Audio Device Settings...opens ajuce::AudioDeviceSelectorComponentin a dialog. Persisted across launches: settings file at~/Library/Application Support/minihost/desktop_settings.xml(or the platform equivalent). Saved onshutdown()and applied oninitialise(). Audio device + MIDI input identifier both round-trip. -
[x]
AudioIODeviceCallbackdriving the graph.LiveEngine::audioDeviceIOCallbackWithContextdrains pending GUI commands + transport + MIDI, then callscompiled_->graph->renderBlockand copies output node 0 to the device output channels.Audio > Start Live/Stop Live/Restart Livemenu items. -
[x] Allocation-free
mh_graph_v2_render_block. The render path uses pre-allocated pool storage fromcompile(); allocation- free for the steady-state caller pattern (planar buffer pointers passed each call don't grow the pool).tests/test_graph_v2_rt.py(5 tests): repeated-calls stability, varying nframes, input immutability, output-reuse safety, gain-change-without-recompile. -
[x] SPSC command queue.
RtParamQueue<1024>inprojects/minihost_desktop/src/rt_param_queue.h(header-only, power-of-two capacity, drop-newest-on-overflow). Producer = GUI thread, consumer = audio callback. Audio thread drains up to 64 commands per block and applies them viajuce::AudioProcessorParameter::setValue(RT-safe; does not acquiremh_set_param's mutex). -
[x] Topology swap under mute.
LiveEngine::startalways callsstop()first, which detaches the audio callback before mutatingcompiled_. Switching project files in the canvas (File > Open Project) also callsstop()on the engine before parsing. Audible click is acceptable per the design. -
[x] Transport bar (BPM + loop region).
LiveEngine::setTransportPlaying,setBpm,setLoop(start, end, is_looping). Audio thread builds anMH_TransportInfofrom atomic bpm/playing/loop state + audio-thread-owned sample/beat counters and pushes it to every plugin viamh_set_transportbefore each block. Position wraps inside[loop_start, loop_end)when looping is enabled. Menu items:Audio > Transport: Play,Stop,Set BPM...,Set Loop Region.... -
[x] Live MIDI input.
juce::MidiInputCallbackonLiveEngine; lock-free ring buffer (midi_ring_, capacity 1024) bridges OS MIDI thread -> audio thread. Audio callback drains up to 256 events per block and fans them out to plugin nodes viamh_graph_v2_set_node_midi.Audio > MIDI Input...shows a PopupMenu listingjuce::MidiInput::getAvailableDevices(). Per-plugin destination routing: newreceives_midifield onPluginNodeSpec(default true). LiveEngine skips plugins withreceives_midi = falseduring fan-out. Toggle exposed in the per-node Properties... dialog. -
[x]
mh_graph_v2_set_node_midiC ABI extension. Stages MIDI events to deliver to a plugin node on the next render_block. Plugin nodes with pending MIDI dispatch viamh_process_midi; nodes without dispatch viamh_processas before. Pending events are cleared after every render_block (caller must re-stage every block). Caller-owned pointer; serializes with render_block via the audio thread's single-callback contract.
Crash resilience¶
Decision recorded in desktop_app.md: ship in-process, document the limitation, add cheap recovery, and gate out-of-process hosting on evidence. The two mitigations below are the pre-release work that decision implies; out-of-process hosting itself stays in Phase 2.5+ below.
-
[x] Autosave / crash recovery. The desktop app (
main.cpp, GUI shell only) snapshots the workingProjectDocumentto a sidecar next to the desktop settings (autosave.json+autosave.meta, the latter recording the origin path) on akAutosaveIntervalMs(5 s) heartbeat timer whenever the document is dirty. Canvas edits/undo/redo raise the dirty flag via a newMainWindow::setOnDocumentEditedExternalcallback; state capture raises it directly; a successful autosave, an explicit Save, and a cleanshutdown()lower it and delete the sidecar. A sidecar surviving to the next launch means the previous session did not reachshutdown()(plugin crash / kill / power loss), somaybeOfferCrashRecovery()prompts Recover / Discard; Recover loads the snapshot and re-associates the origin path so Save writes back correctly, then marks it dirty (newer than disk). The lifecycle is guarded to the GUI shell (autosave_enabled_) so headless modes sharing the settings dir never clear a crashed session's sidecar. All schema paths are absolute, so the sidecar's location is irrelevant to reload. Sidecar write/parse/clear mechanics verified headlessly viaminihost_desktop --autosave-selftest=<project.json>(tests/test_desktop_autosave.py, 2 tests); the timer + recovery dialog are GUI-thread orchestration and stay manual (same limitation noted for undo/redo). -
[x] Save-before-quit prompt.
DesktopApplication::systemRequestedQuit()(GUI shell only) intercepts every quit path (window close, File > Quit, Cmd+Q, OS logout) and, when there are unsaved changes, shows Save / Don't Save / Cancel instead of discarding silently. Save persists first and quits only on success (deferringquit()into the async save callback, which for an untitled project routes through the Save As chooser); Don't Save quits (cleanshutdown()drops the sidecar); Cancel aborts. Tracks a dedicatedunsaved_changes_flag (change since last explicit Save) distinct from the autosavedoc_dirty_flag (change since last heartbeat), reset by Save / Save As / New / Open. The two save methods gained an optional success continuation. Single-plugin and headless modes never arm it (autosave_enabled_), so their directquit()paths are unchanged (verified by the desktop render/undo/scan tests). The dialog itself needs a display and stays manual. -
[x] Document the in-process limitation. README's Desktop section and the in-app
Help > Aboutdialog now state that plugins run in-process (a misbehaving plugin can crash the app and lose unsaved edits), and point at the two mitigations shipped for it: out-of-process scanning and autosave/crash recovery. CHANGELOG records the autosave feature under[Unreleased]. -
[x] Out-of-process plugin scanning.
projects/minihost_desktop/src/plugin_scanner.{h,cpp}implements JUCE's child-process scan pattern (KnownPluginList::CustomScanner+ChildProcessCoordinator/ChildProcessWorker, adapted fromextras/AudioPluginHost): each plugin is instantiated in a disposable child process (a relaunch of this binary carryingkScannerProcessUID), so a plugin that crashes or callsexit()during instantiation-during-scan takes down only the child. The parent detects the dead/hung child (bounded ~40 s per-plugin timeout), blacklists that plugin, respawns, and continues.known_plugins_.setCustomScanner(...)routes both the interactivePluginListComponentscan and the headless--scan-plugins --scan-ooppath (single integration point:KnownPluginList::scanAndAddFileinvokes the custom scanner and blacklists on failure). Worker detection is the first thing ininitialise(). Evidence that justified it (the "gate on evidence" trigger): a full in-process scan on a real machine died when the commercial AU u-he Bazille calledexit()mid-instantiation. Verified live: the same full AudioUnit scan run--scan-oopcompletes cleanly (exit 0, 391 AUs recorded) instead of dying; a hermetic single-VST3 OOP scan proves the handshake intests/test_desktop_pluginscan.py(test_scan_oop_finds_real_plugin). One gap: no deliberately-crashing plugin fixture exists for a hermetic containment assertion, so the crash-survival check is a documented manual run, not CI.
Phase 2.5+ - Deferred¶
Items the design doc lists as deferred. Surfaced here so they're not forgotten, but not scheduled.
-
[ ] Latency compensation across fan-in paths. Insert per-path delay at fan-in points using
mh_get_latency. -
[ ] Feedback loops with a one-block delay node on the back-edge.
-
[ ] Sidechain input buses. Blocked on a C ABI gap (see
docs/dev/graph.mdopen questions). -
[ ] Out-of-process plugin hosting for crash containment. Decide only if v1/v2 testing shows in-process crashes are routine; see the crash-resilience decision.
-
[ ] Opt-in crash reporting. No usage telemetry either way. This is the evidence that would justify (or not) out-of-process hosting.
Non-goals¶
Mirrors the design doc; restated here so PRs that drift toward these get a one-link rejection:
-
DAW features (timeline, clip editing, mixer automation curves drawn in-app, time-stretching, take comping).
-
Preset browser UI beyond what
read_vstpreset/set_stategive us. -
MIDI learn / parameter mapping UI.
-
Plugin shell disambiguation.
-
Cross-graph routing (every project is exactly one graph).
-
App Store distribution (incompatible with hosting arbitrary plugin code).