diff --git a/CHANGELOG.md b/CHANGELOG.md index 624f0bffe..fcc434815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,16 @@ ## [1.2.x] ### Changed +- Disconnected audio devices are no longer silently replaced with another device: Element closes the device, shows its status in the status bar, and automatically restores it when it reconnects. Double-click the status label to open audio settings. - Note names throughout the UI now use scientific pitch notation (middle C = C4), matching the convention used by most DAWs. ### Fixed +- Freeze on Windows when an ASIO audio interface is disconnected. Reconnection is now driven by system hardware notifications instead of repeatedly probing the driver. - Blocks embedded in the graph return to their embedded state when the plugin window is closed. - MIDI Monitor note names displayed two octaves too high. - MIDI Monitor logged Start/Stop/Continue messages twice. +- CLAP: plugin UIs displaying incorrectly on Linux. +- Windows: the file watcher spun at 100% CPU and could prevent a clean shutdown when unhandled file change notifications were delivered (e.g. by OneDrive). ## [1.2.0] diff --git a/docs/plans/extensions.md b/docs/plans/extensions.md new file mode 100644 index 000000000..c7d0b7476 --- /dev/null +++ b/docs/plans/extensions.md @@ -0,0 +1,143 @@ +# `.element` Extension Format + App-Side Lua Runtime + +> **Phase 0 prerequisite:** [session-scripts.md](session-scripts.md) — session-embedded +> hook scripts. `HookBus`, `el.hooks`, the restricted-environment/capability model, and +> `ScriptingService` are built there first; Phase 3 below then reduces to wiring +> `app.*` events and extension-owned registration. See also [luajit.md](luajit.md) for +> the LuaJIT assessment. + +## Context + +Element has a mature Lua substrate (embedded Lua 5.4, sol2 bindings under `src/el/`, a shared `ScriptingEngine` on `Context`) but the app-side surface is thin: no startup script execution (`ScriptingEngine::execute` is declared but never defined), no Lua graph-mutation API, no lifecycle hooks, and GUI scripting is limited to embedded View scripts. The goal is a new **`*.element` extension format** — a directory (like `.lv2`/`.vst3`) acting as a package/extension that can carry Lua modules, hook scripts, views/panels, DSP scripts, graphs (serialized data **or** Lua builder scripts — manifest decides), presets, resources, and bundled plugins (CLAP first) — plus the app-side Lua runtime it requires: graph building, lifecycle hooks, and GUI extensibility. + +**Decisions made with user:** +- Terminology: **Extension** everywhere (classes, service, Lua modules). Directory suffix stays `.element`. +- Install dir: `~/Music/Element/Extensions` (+ app-support equivalent). **Install-only** model — no open-in-place; app scans at startup + rescan command. +- Manifest: **Lua** (`manifest.lua` returning a table), parsed in a restricted environment. +- Builder-script graphs: **single undo transaction**. +- Headers **internal first** (`src/scripting/`), promote to `include/element/` later. +- Full scope planned: format+loader, graph API, hooks, GUI extensibility (views, panels, panel properties) — phased. + +## Format spec + +``` +MyPack.element/ + manifest.lua -- REQUIRED, returns a table + init.lua -- optional entry script (manifest.entry) + modules/ -- Lua modules exposed to require() + scripts/ -- DSP / DSPUI / View scripts + graphs/ -- *.elg data graphs and/or builder *.lua + presets/ -- *.eln node presets + plugins/ -- CLAP (later VST3/LV2) binaries + resources/ -- assets +``` + +Manifest schema (executed in restricted sol::environment — no `io`/`os`/`require`; side-effect-free at scan time): + +```lua +return { + manifestVersion = 1, + id = "com.example.mypack", -- required, reverse-DNS, stable key + name = "My Pack", -- required + version = "1.2.0", -- required, semver + author = "...", description = "...", + element = { minVersion = "1.0.0" }, + entry = "init.lua", + modules = { ["mypack.util"] = "modules/util.lua" }, + scripts = { "scripts/gain.lua" }, + views = { { slug = "mypack.mixer", title = "Pack Mixer", + script = "scripts/mixerview.lua", placement = "main" } }, -- main|panel + graphs = { { name = "Synth Rig", type = "data", path = "graphs/rig.elg" }, + { name = "Auto Rig", type = "script", path = "graphs/autorig.lua" } }, + presets = "presets", + plugins = { "plugins" }, + resources = "resources", +} +``` + +C++ side: `struct ExtensionManifest` — plain struct parsed once from the sol table, table discarded (no retained Lua objects). All schema knowledge isolated in `ExtensionManifest::parse(const File&, sol::state_view, ExtensionManifest&)` so the format stays swappable. `Extension` = manifest + `File dir` + status (`discovered/loaded/disabled/error`) + `sol::environment` for the entry script + hook-handle/registration lists for teardown. + +## Architecture (new pieces) + +| Piece | Location | +|---|---| +| `ExtensionManifest`, `Extension` | `src/scripting/extension.hpp/.cpp` | +| `ExtensionManager` (scan/load/unload registry) | `src/scripting/extensionmanager.hpp/.cpp` | +| `ScriptingService` (new `Service`) | `src/services/scriptingservice.hpp/.cpp` | +| `HookBus` (C++ event dispatcher) | `src/scripting/hookbus.hpp/.cpp` | +| `el.engine`, `el.hooks`, `el.ui` Lua modules | `src/el/Engine.cpp`, `Hooks.cpp`, `UI.cpp` | +| `ViewFactory` (slug → ContentView registry) | `src/ui/viewfactory.hpp/.cpp`, owned by `GuiService` | +| `ScriptContentView` + `MissingExtensionView` | `src/ui/scriptcontentview.hpp/.cpp` | + +Key existing seams to reuse (verified): +- `ScriptingEngine::addPackage(name, loader)` runtime package registry ([scripting.cpp:118](src/scripting.cpp#L118)) — extension Lua modules register here (namespaced; no global `package.path` pollution). +- `EngineService` mutation vocabulary ([engine.hpp](include/element/engine.hpp)): `addGraph/addNode/addPlugin/addConnection/connectChannels/connect(PortType,...)/removeNode/disconnectNode` — Lua rides the existing message/undo/engine-sync path. +- `StandardContent::createContentView(const String&)` virtual, consulted first by `setMainView`/`setSecondaryView` ([standard.hpp:88](include/element/ui/standard.hpp#L88), [standard.cpp:541,633](src/ui/standard.cpp#L541)). +- `NavigationConcertinaPanel::addPanel(desc, factory, header)` public ([navigation.hpp:73](include/element/ui/navigation.hpp#L73)); panel state keys by name — extension slugs must be stable. +- `ScriptView` per-view `sol::environment` + descriptor pattern ([scriptview.cpp](src/ui/scriptview.cpp)) — the sandbox precedent for all extension script execution. +- `SessionService::sigSessionLoaded/sigWillSave` ([sessionservice.hpp:37-38](src/services/sessionservice.hpp#L37-L38)), `EngineService::sigNodeRemoved`. +- `Node::parse` tolerant multi-format graph reader ([node.cpp](src/node.cpp)) for `type="data"` providers. + +Ownership/lifetime rule: `ExtensionManager` is owned by `ScriptingEngine::Impl` (inside the Lua state's lifetime — sol::environment destruction order). `ScriptingService::deactivate()` unloads extensions **before** state teardown. `ScriptingService` registers in `Services` ctor ([services.cpp](src/services.cpp)) after `EngineService`, before `SessionService`, so extension views/graphs exist before the startup session restores. + +## Phases + +### Phase 1 — Extension core: format, discovery, module/script loading +- New: `extension.hpp/.cpp`, `extensionmanager.hpp/.cpp`, `scriptingservice.hpp/.cpp`, `test/scripting/extensiontests.cpp`, fixture `test/scripting/fixtures/TestPack.element/`. +- Modified: `src/scripting.hpp/.cpp` (implement dead `ScriptingEngine::execute` as protected `lua.script()` in fresh env returning `Result`; expose `extensions()`), `src/datapath.cpp` + `include/element/datapath.hpp` (`defaultExtensionsDir()` = `~/Music/Element/Extensions`, create in `initializeUserLibrary`; also scan `applicationDataDir()/Extensions`; dev env var `ELEMENT_EXTENSIONS_PATH` mirroring `ELEMENT_SCRIPTS_PATH` handling in [bindings.cpp](src/scripting/bindings.cpp)), `src/services.cpp`, `src/scripting/scriptmanager.cpp/.hpp` (**additive** scan — current `scanDirectory` replaces the registry), `include/element/tags.hpp` (`EL_TAG(Extension)`, `tags::extensionId`, `tags::extensionVersion`, `tags::requires`), `src/CMakeLists.txt`, `test/CMakeLists.txt` (+ `add_test`). +- Load sequence: scan (parse manifests only, no code) → for enabled extensions: register modules via `addPackage`, register DSP/View scripts with `ScriptManager`, run entry script in `sol::environment(lua, sol::create, lua.globals())`; every failure → `logError`, status `error`, never throws out. Enable/disable persisted in `Settings` (`extensionsDisabled` list). `Commands::reloadExtensions` for dev iteration. +- Unload = disconnect hooks, drop env, clear `package.loaded` + registered packages, remove ScriptManager entries and view/panel registrations. Full hot-unload of usertypes is explicitly out of scope (documented). + +### Phase 2 — Graph-building Lua API + graph providers +- New: `src/el/Engine.cpp` (`luaopen_el_Engine`), `test/scripting/enginescripttests.cpp`. +- Modified: `src/scripting/bindings.cpp` (register module), `src/el/CMakeLists.txt`, `src/services/scriptingservice.cpp` (provider instantiation), `src/ui/mainmenu.cpp` + commands (File → New Graph From Extension ▸ submenu). +- Facade binds a thin wrapper resolving `EngineService` per call (never bind the service class raw); assert message thread; Lua two-value `nil, "message"` error convention: + ```lua + local engine = require ("el.engine") + local g = engine.addGraph ("My Rig") + local n = engine.addNode (g, "element.volume") + local p = engine.addPlugin (g, { format = "CLAP", id = "org.surge..." }) + engine.connect (g, p, 0, n, 0) -- optional 5th arg "midi" for PortType + engine.remove (g, n); engine.saveGraph (g, path) + ``` + `addPlugin` looks up `context().plugins().getKnownPlugins()` by format + identifier/uid/name. +- Providers: `type="data"` → `Node::parse` → re-UUID (same as `.elg` import) → `EngineService::addGraph(node, true)`. `type="script"` → protected run in fresh env, wrapped in a **single UndoManager transaction** (fallback: per-op undo, documented, if bracketing proves infeasible). +- Tests headless with existing `Context` + engine fixtures: builder script topology assertions, data-provider instantiation, plugin-miss returns nil+msg. + +### Phase 3 — Hook system +- New: `src/scripting/hookbus.hpp/.cpp`, `src/el/Hooks.cpp`, `test/scripting/hooktests.cpp`. +- Modified: `include/element/engine.hpp` + `src/services/engineservice.cpp` — add `sigNodeAdded`, `sigGraphAdded`, `sigGraphRemoved` at the same sites that fire `sigNodeRemoved`; `scriptingservice.cpp` wires all signals → `HookBus`. +- Events: `app.started`, `app.shutdown`, `session.loaded`, `session.saving`, `graph.added`, `graph.removed`, `graph.changed`, `node.added`, `node.removed`; extensions can `hooks.emit` custom events. +- `HookBus`: message-thread only; handlers are `sol::protected_function` tagged with owner extension id (ExtensionManager sets a "current extension" scope during entry scripts; console registrations = `"user"`); dispatch iterates a copy; reentrancy guard (`dispatching` flag + pending queue); auto-disable a handler after 3 consecutive errors; `removeOwner(id)` on unload. +- Lua: `hooks.on(event, fn) → handle`, `hooks.off(handle)`, `hooks.emit(event, ...)`. + +### Phase 4 — GUI extensibility +- New: `viewfactory.hpp/.cpp`, `scriptcontentview.hpp/.cpp`, `src/el/UI.cpp`. +- Modified: `standard.hpp/.cpp` (implement `createContentView` against `ViewFactory`), `src/el/Content.cpp` (finish the `presentView`/`presentViewObject` stubs — string overload resolves real `Content*` via `GuiService`; object overload wraps Lua widget proxies like `ScriptView` does, through the existing `ViewWrapper` in standard.cpp), `guiservice.*` (own ViewFactory, close extension views on `sigExtensionUnloaded`), `bindings.cpp`, `src/el/CMakeLists.txt`. +- `el.ui.registerView { slug, title, script, placement }` → ViewFactory entry producing a `ScriptContentView` (ScriptView machinery fed from an extension file instead of an embedded Script blob) — then `content:presentView("mypack.mixer")` works through the existing name path. `el.ui.registerPanel` → `NavigationConcertinaPanel::addPanel` with a script-backed factory. +- Panel properties (`el.ui.addProperties(panelId, fn)`): `SectionProvider` registries on `GraphSettingsView`/`NodePropertiesView` keyed `"graph.settings"`/`"node.properties"`; first cut limited to text/slider/toggle `PropertyComponent`s backed by Lua get/set callbacks. This is the most invasive piece — do last in the phase or slip to Phase 6. +- Unresolvable view slug → `MissingExtensionView` placeholder, never a crash. + +### Phase 5 — Bundled plugins & presets +- Modified: `include/element/plugins.hpp` + `src/pluginmanager.cpp` — `addExtensionSearchPath(format, dir)` (merged into scan paths, **not persisted** to settings), targeted CLAP scan on extension load reusing the existing verified/out-of-process scan path exactly; never scan at manifest-parse time. `src/datapath.cpp`/PresetService: preset discovery additionally walks loaded extensions' `presets/` dirs. +- Duplicate plugin identifiers across extensions: KnownPluginList dedupes, first wins, log. + +### Phase 6 — Persistence interplay, management UI, polish +- Session stamping: `requires` child tree on the Session ValueTree listing `{extensionId, version}` for extension-provided content in use; written C++-side during the `session.saving` window. Additive child — no `EL_SESSION_VERSION` bump expected; add a `Session::migrate` no-op guard. +- Graphs instantiated from an extension get informational `extensionId`/`extensionVersion` props; graphs stay self-contained after instantiation (plugin state inlined), so no live dependency unless the extension supplies the plugin binary. Extension DSP-script nodes keep embedding code in the session (existing gzip design) so playback survives a missing extension. +- On session load: diff `requires` vs loaded extensions → one consolidated missing/mismatch alert (compare major version). +- Management: Reload Extensions menu item; extensions list view or preferences page (Plugin-Manager-style) — optional `ExtensionsContentView`. +- Docs: format spec page + a shippable example extension. + +## Verification + +- Per-phase Boost.Test suites in `test/scripting/` (each registered in `test/CMakeLists.txt` with `add_test`): manifest parse/reject, discovery, `require` resolution, entry-script error isolation (Phase 1); builder-script graph topology + undo-transaction rollback (Phase 2); hook dispatch/auto-disable/removeOwner/reentrancy (Phase 3); ViewFactory register/lookup/missing-placeholder + headless descriptor instantiation (Phase 4); search-path merge + preset discovery from fixture (Phase 5); requires-tree round-trip + missing-extension session load degradation + load→unload→load cycle without duplicate handlers (Phase 6). +- `cmake --build build && ctest --test-dir build --output-on-failure -R Extension...` per suite. +- Manual end-to-end after Phase 4: drop `TestPack.element` into `~/Music/Element/Extensions`, launch app, confirm entry script runs, `New Graph From Extension` builds a graph, hooks fire in the Lua console, and the registered view opens via `presentView`. + +## Cross-cutting rules + +- All Lua on the message thread only (shared state); engine-side signals already arrive marshalled — assert in HookBus anyway. +- Every extension-code call is protected (`sol::protected_function` / protected script), errors to `ScriptingEngine::logError`, never propagate. +- Extensions are trusted-but-isolated (env per extension inheriting globals, like ScriptView); capability sandboxing out of scope, documented. +- CLAUDE.md compliance: tags via `EL_TAG`, app logic in `ScriptingService`, no `using namespace` in headers, no `ElementApp.h`, Doxygen comments, format with `util/format.py`. diff --git a/docs/plans/header-cleanups.md b/docs/plans/header-cleanups.md new file mode 100644 index 000000000..5d71a2198 --- /dev/null +++ b/docs/plans/header-cleanups.md @@ -0,0 +1,101 @@ +# Public Header Cleanups (`include/element`) + +Remaining work from the July 2026 audit of the public header surface. The +interface is considered experimental, so there is no ABI to preserve — this is +the cheapest time to reshape it. + +Already done (July 2026): fixed the `ChannelConfig` typed-port accessors in +`porttype.hpp`, the `EL_DISABLE_MOVE` const-rvalue bug in `element.hpp`, the +`AtomicLock` dropped-count issue in `atomic.hpp`, missing `#pragma once` in +`ui/grapheditor.hpp`, misplaced `#pragma once` in `parameter.hpp`, missing +`override` in `ui/decibelscale.hpp` / `ui/simplemeter.hpp`; removed the dead +headers `nodeproxy.hpp`, `datapipe.hpp`, `filesystem.hpp`, `linkedlist.hpp` +and the unused `elPortType` enum from `element.h`. + +## Phase 2 — Decide and enforce the public boundary + +The root problem: headers are never installed (no `install()`/export rules +anywhere), and `src/CMakeLists.txt` puts both `include/` **and** `src/` on the +PUBLIC include path, so the public/private split is convention only. This is +why app internals have drifted into `include/` unchecked. + +- [ ] Decide what the SDK surface actually is. Plausible cut: + - Core: `Context`/`Services`, the Model layer (`model`, `node`, `session`, + `graph`, `tags`), extension points (`NodeProvider`/`NodeFactory`, + `Processor`, `Parameter`, `PortType`). + - UI contract: `ui/content.hpp` (`ContentFactory`/`Content`/`ContentView`), + `ui/nodeeditor.hpp`, `ui/updater.hpp`, `ui/about.hpp`, `ui/menumodels.hpp`, + `ui/view.hpp`, and the `Colors`/`Style` portion of `ui/style.hpp`. +- [ ] Move leaked app internals from `include/element/` to `src/`: + `application.hpp`, `ui/standard.hpp`, `ui/simplemeter.hpp`, + `ui/decibelscale.hpp`, `ui/designer.hpp`, `ui/grapheditor.hpp`, + `ui/meterbridge.hpp`, `ui/popups.hpp`. All are concrete components used only + inside `src/` and are not part of any factory contract. +- [ ] Enforce the boundary in CMake: make `src/` a PRIVATE include dir of the + `element` target, declare public headers with `FILE_SET HEADERS`, and add + `install()` + export-set/package-config rules so the SDK is actually + shippable. Enforcement is what stops the drift from recurring. +- [ ] Verify with a clean configure/build that nothing outside the target + reached headers only via the removed PUBLIC `src/` include dir. + +## Phase 3 — Stop third-party types leaking through public signatures + +- [ ] Decide deliberately whether Boost.Signals2 is part of the public + contract. If yes, document it; if no, wrap it — `signals.hpp` is already the + single choke point (`element::Signal` is used in `processor.hpp`, + `engine.hpp`, `audioengine.hpp`, `session.hpp`, `midiiomonitor.hpp`). + `parameter.hpp` uses `boost::signals2::signal` directly and should go + through the alias either way. +- [ ] `version.hpp` includes `` just for + split/trim — replace with a small local helper. +- [ ] Keep the Lua C API out of public signatures: pimpl `LuaMidiPipe` + (`midipipe.hpp` exposes `lua_State*`), and drop `script.hpp`'s include of + `lua.hpp` (which pulls raw `lua.h`/`lauxlib.h`/`lualib.h`). +- [ ] `processor.hpp` includes the whole `` umbrella (with a + self-flagged FIXME) — replace with the specific module headers it needs. +- [ ] `juce/core.hpp` injects `element::` aliases for juce types + (`// FIXME: juce aliases`) — remove or move them out of the umbrella. +- [ ] `shuttle.hpp` uses the deprecated + `juce::AudioPlayHead::CurrentPositionInfo` — migrate to `PositionInfo`. + +## Phase 4 — Longer-term header quality + +- [ ] Pimpl the god headers. `processor.hpp` (~22 KB: full private state, + nested `RMSMeter`/`MidiProgramLoader`/`PortResetter` structs, 6 friends) is + the worst offender; also `node.hpp` (`NodeObjectSync`, fully-inline + `ConnectionBuilder`), `ui.hpp` (`GuiService` has an `Impl` yet still exposes + window/content members and nested structs), `session.hpp` (friends + leaked + ValueTree helpers), `plugins.hpp` (`PluginScanner` private members). The + pattern already exists in-tree (`Context`, `AudioEngine`, `NodeFactory`, + `Services`). +- [ ] Move large inline bodies to `.cpp`: the `Commands` + `toString`/`fromString`/`getAllCommands` tables in `ui/commands.hpp` + (~250 lines), and the `LookAndFeel_E1` override wall in `ui/style.hpp`. + While moving the command tables, fix the `toString`/`fromString` asymmetry — + several command IDs (e.g. session*/transport*) don't round-trip. +- [ ] Pick one export-macro policy and apply it uniformly. `EL_API` currently + decorates about half the public types; `arc.hpp` even mixes `JUCE_API` + (`Arc`) with `EL_API` (`ArcSorter`/`ArcTable`). Consider a generated export + header (CMake `GenerateExportHeader`). +- [ ] `tags.hpp`: `EL_TAG` expands to `static const juce::Identifier` at + namespace scope, so every translation unit gets a private copy of every + Identifier. Switch to `extern` declarations with a single definition TU + (or `inline` variables). +- [ ] `model.hpp`: `EL_MODEL_GETTER`/`EL_MODEL_GETTER_WITH_TYPE`/ + `EL_MODEL_SETTER` are never `#undef`'d and leak into every includer + (contrast `tags.hpp`, which `#undef`s `EL_TAG`). +- [ ] Reframe the C API honestly. `element.h`'s live role is the export-macro + layer (`EL_PLUGIN_EXPORT` on the `luaopen_el_*` entry points) plus the + `EL_MT_*` Lua metatable names — there are no handles, descriptor structs, or + versioning, so it is not a C ABI. Either commit to a real C ABI or document + `element.h` as "export + Lua-module macros". +- [ ] Doc pass over the undocumented keep-public headers: `context.hpp`, + `devices.hpp`, `settings.hpp`, `oversampler.hpp`, `audioengine.hpp`, + `ui/nodeeditor.hpp`, `ui/style.hpp`. (`transport.hpp`, `taptempo.hpp`, + `parameter.hpp`, `plugins.hpp`, `ui/updater.hpp` are the in-tree examples of + the standard to match.) +- [ ] Settle the getter naming convention for new code (`getName()` in + `Node`/`Session` vs `name()` in `Model`/`Script`/`PortType`) and document it + in `docs/cppstyle.md`. +- [ ] Unify the remaining copyright-header variants when files are otherwise + touched (don't rewrite years wholesale). diff --git a/docs/plans/kushview-licensing-authorization.md b/docs/plans/kushview-licensing-authorization.md new file mode 100644 index 000000000..ca145f0ee --- /dev/null +++ b/docs/plans/kushview-licensing-authorization.md @@ -0,0 +1,311 @@ +# Kushview Licensing & Authorization Architecture + +**Status:** Draft / design +**Scope:** Kushview-owned plugins only. Client-side design complete; server-side deferred (stubbed contracts only). +**Goal:** A subscriber (or standalone purchaser) authenticates once, and every Kushview plugin they're entitled to unlocks silently — no per-plugin user/pass prompt. Cancelling a subscription revokes access on the next refresh. + +--- + +## 1. Summary + +Introduce a dedicated **Kushview Account Manager** application as the single place that logs in and manages entitlements. It writes a **shared per-user store** of auth tokens and signed unlock keys. Element and every Kushview plugin link a small **shared read-side SDK** that reads that store and applies keys through a **common JUCE product-unlocking implementation** — never running an interactive login themselves. + +This mirrors the industry-standard pattern (iLok License Manager, NI Native Access, Waves Central, Steinberg Download Assistant): one privileged writer, many trivial readers. + +### Why this shape + +- **Plugins stay trivial.** No OAuth, no browser, no URL-scheme handling shipped in each plugin — just "read store → apply key → else fall back to the existing manual prompt." Less code, less attack surface, replicated across every product. +- **Auth changes ship once**, in the Manager, decoupled from plugin release cycles. +- **Background subscription refresh.** Subscription keys are short-lived by design; a plugin can only refresh while loaded. The Manager runs as a login-item/agent and keeps every owned plugin's key fresh regardless of whether anything is open — the one place this can be solved cleanly. +- **Graceful degradation.** No token / offline / non-subscriber → plugin shows today's manual unlock UI. Nothing breaks. + +--- + +## 2. Background: JUCE unlocking primer (for the target project) + +Kushview plugins are JUCE products. JUCE's licensing primitives: + +- **RSA key pair.** Generated once per product with `juce::RSAKey::createKeyPair()`. + - **Private key** signs unlock "key files". **Lives only on the server.** Never shipped, never in any client. + - **Public key** is compiled into the product and only *verifies* a signed key file. It cannot forge licenses. It is safe to embed — and is already inside every shipped plugin binary. +- **Key file.** A signed blob (produced server-side by `juce::KeyGeneration::generateKeyFile(...)`) containing the user email, product ID, **machine IDs**, and an **expiry**. Applied client-side with `OnlineUnlockStatus::applyKeyFile(...)`. +- **`juce::OnlineUnlockStatus`.** The client-side state object. We use it in "key file" mode (`applyKeyFile` / `isUnlocked` / `getExpiryTime`) and **bypass its built-in web-authentication flow** entirely — keys come from the shared store, not from OnlineUnlockStatus's own HTTP calls. + +Two protections do the real work: **machine-locking** (a key file only unlocks on the machines whose IDs it was signed for) and **expiry** (subscription semantics). OnlineUnlockStatus's own state obfuscation is weak and is *not* relied on for security. + +### Current state in Element (starting point) + +Element already has the auth foundation this builds on: + +- OAuth2 Authorization-Code + PKCE against WordPress `kv-auth/v1` (`src/auth.cpp`, `src/auth.hpp`, namespace `element::auth`). +- Token response already carries an `entitlements` object (today just `preview_updates`) plus user email/display name. +- An established precedent for "authenticated request → short-lived server-signed artifact": `/appcast-url?plat=` returns a signed, expiring Sparkle feed URL that the client caches and refreshes when expired (`GuiService::checkUpdates`, `auth::isAppcastUrlExpired`). +- **No** `OnlineUnlockStatus`/`RSAKey`/`KeyGeneration` anywhere yet — the unlock layer is net-new. +- Plugin scanning/hosting has no entitlement gate — nothing to unwind. + +The signed-key endpoint is the same shape as the existing appcast-url endpoint. The OAuth/PKCE client is the reusable core that gets lifted into the shared SDK. + +--- + +## 3. Component overview + +``` +┌───────────────────────────────────────────────┐ +│ Kushview Account Manager │ the ONLY app that logs in +│ • interactive OAuth2 + PKCE (browser) │ +│ • URL-scheme callback (kushview://) │ +│ • entitlement sync (tier → product list) │ +│ • key fetch + refresh daemon │ +│ • (future) product download / install │ +└───────────────────────┬───────────────────────┘ + │ writes (atomic) + ▼ + ╔═══════════════════════════════════════╗ + ║ Shared per-user Kushview store ║ + ║ • tokens (OS secure storage) ║ + ║ • keys/.key (machine- ║ + ║ locked, expiring signed blobs) ║ + ║ • entitlements.json ║ + ╚═══════════════════════════════════════╝ + ▲ ▲ + read │ │ read + ┌───────────────┴──┐ ┌────┴──────────────────┐ + │ Element │ │ Kushview plugins │ + │ (host + reader) │ │ ToneGenerator, … │ + └──────────────────┘ └───────────────────────┘ + both link the shared READ-SIDE SDK: + KushviewUnlockStatus + StoreReader + fallback +``` + +### 3.1 Kushview Account Manager (new application) + +The single privileged writer. + +Responsibilities: +- Interactive **OAuth2 + PKCE** login (lifted from `element::auth`), including browser launch and `kushview://auth/callback` URL-scheme handling. +- Fetch and persist **tokens** into the shared store (refresh-token rotation, silent refresh). +- Fetch **entitlements** (tier → owned/subscribed product list) and persist to `entitlements.json`. +- For each entitled product, request a **signed, machine-locked, expiring key file** and write it to `keys/.key`. +- **Refresh daemon:** run on login-item/agent schedule; re-request any key nearing expiry so subscription keys never lapse silently. On the server saying "no longer entitled," delete that product's key. +- Account UI: signed-in identity, tier, owned products, per-product authorization status, manual "refresh all." +- (Future) download & install products and updates; can absorb Element's appcast entitlement flow. + +Cross-platform: one codebase (JUCE app or native), signed/notarized, self-updating. + +### 3.2 Shared read-side SDK (linked by Element + every plugin) + +A small static/shared library. Contains **no interactive login** — read + apply + fall back only. + +Public surface: + +```cpp +namespace kv::lic { + +// Identity a product provides about itself. +struct ProductInfo { + juce::String productId; // stable slug, e.g. "kv.tonegenerator" + juce::String publicKey; // this product's embedded RSA public key + juce::String displayName; // for any UI/prompt fallback +}; + +// Locates and reads the shared per-user store. Read-only. +class StoreReader { +public: + static juce::File storeDir(); // platform-specific (see §5) + juce::File keyFileFor (const juce::String& productId) const; + bool hasTokens() const; // is anyone logged in? + // NOTE: reads tokens for refresh-on-read only if we choose to allow it (see §7, open decision). +}; + +// Common JUCE product-unlocking implementation. One per plugin instance. +class KushviewUnlockStatus : public juce::OnlineUnlockStatus { +public: + explicit KushviewUnlockStatus (ProductInfo); + + // The unlock ladder (see §4.2). Cheap; safe to call on load. + bool authorize(); + + juce::String getProductID() override { return info.productId; } + juce::RSAKey getPublicKey() override { return juce::RSAKey (info.publicKey); } + juce::String getState() override; // persisted applied-key state + void saveState (const juce::String&) override; + // Built-in web auth is intentionally unused: + juce::URL getServerAuthenticationURL() override { return {}; } + juce::String readReplyFromWebserver (const juce::String&, const juce::String&) override { return {}; } + +private: + ProductInfo info; + StoreReader store; +}; + +} // namespace kv::lic +``` + +### 3.3 Common key-writing pattern (the shared contract) + +This is the "common pattern for key writing" — one implementation, honored identically by the Manager (writer) and every plugin (reader). + +- **File per product:** `keys/.key`, containing the raw JUCE key-file text produced by `KeyGeneration::generateKeyFile` server-side. +- **Atomic writes** (Manager): write to `keys/.key.tmp`, `fsync`, rename over the target. Readers never observe a half-written file. +- **Applied-state cache** (plugin): after `applyKeyFile` succeeds, the plugin persists the applied state via `saveState()` into its *own* settings. So an already-authorized plugin keeps working even if the store file is later removed — until the embedded **expiry** lapses. +- **Machine-locked:** every key file carries this machine's IDs; copying it to another machine fails `applyKeyFile`. This is what makes the shared store safe to sync/back-up. +- **Expiry-bearing:** subscription keys are short-lived (e.g. 30 days). The Manager refreshes ahead of expiry; a lapsed entitlement simply stops being refreshed and the plugin falls back to prompting after expiry. + +### 3.4 Per-plugin integration (minimal) + +Each plugin adds only: + +```cpp +static const kv::lic::ProductInfo kToneGenProduct { + "kv.tonegenerator", + EMBEDDED_TONEGEN_PUBLIC_KEY, // build-time constant, safe to embed + "Kushview Tone Generator" +}; + +// On construction / first UI show: +kv::lic::KushviewUnlockStatus unlock (kToneGenProduct); +if (! unlock.authorize()) + showManualUnlockPrompt(); // existing per-plugin fallback UI +``` + +Nothing else. No OAuth, no browser, no URL scheme. + +### 3.5 Element's role + +Element is just another reader of the shared store for the *plugin* keys. Its existing OAuth login for **update entitlements** continues to work as-is short-term. + +- **Short-term:** Element keeps its own login (writes the same shared token store the Manager uses) and additionally reads plugin keys via the SDK. Both apps interoperate through one store. +- **Long-term:** the Manager becomes the canonical account/entitlement hub; Element migrates to a pure reader and drops its embedded login UI. *(Decision — see §7.)* + +--- + +## 4. Data flows + +### 4.1 Login (Manager only) + +``` +User clicks Sign In (Manager) + → PKCE verifier/challenge generated, state stored + → browser opens kushview.net/auth/authorize + → user authenticates on the store + → redirect kushview://auth/callback?code=…&state=… + → OS routes to Manager URL-scheme handler + → validate state, exchange code at kv-auth/v1/token + → receive JWT access token + refresh token + entitlements{tier, products[]} + → write tokens (OS secure storage) + entitlements.json + → for each entitled product: fetch signed key → write keys/.key +``` + +### 4.2 Unlock ladder (Element + plugins, on load) + +``` +authorize(): + 1. Local applied state valid AND not expired? → unlocked, ZERO network + 2. keys/.key present? + applyKeyFile(blob) + machine matches AND not expired? → unlocked, cache state + 3. Otherwise → return false + → caller shows existing manual unlock prompt +``` + +Step 1 makes the common case free (no I/O beyond a settings read). Step 3 guarantees graceful degradation. + +### 4.3 Background subscription refresh (Manager daemon) + +``` +On schedule / login-item wake: + ensure access token fresh (silent refresh; rotate refresh token) + GET entitlements + for each product: + if entitled: + if key missing OR expiry within threshold (e.g. < 7 days): + fetch new signed, machine-locked key → atomic write + else: + delete keys/.key # revoked / downgraded +``` + +This is the mechanism that gives real subscription semantics: cancel → next refresh stops re-signing → key expires → plugin falls back to prompting. + +--- + +## 5. Shared store layout & locations + +``` +/ + tokens → refresh/access tokens (prefer OS secure storage, see below) + entitlements.json + keys/ + kv.tonegenerator.key + kv..key +``` + +Platform base directory: +- **macOS:** `~/Library/Application Support/Kushview/Account/` +- **Windows:** `%APPDATA%\Kushview\Account\` +- **Linux:** `$XDG_CONFIG_HOME/kushview/account/` (fallback `~/.config/kushview/account/`) + +**Token at-rest protection:** store the refresh token in OS secure storage where available — macOS **Keychain**, Windows **Credential Manager (DPAPI)**, Linux **libsecret** — falling back to a restricted-permission file (0600) only if unavailable. Key files themselves are machine-locked, so they are low-sensitivity and can live as plain files. + +--- + +## 6. Security model & invariants + +Non-negotiable: + +1. **Private signing key never leaves the server.** All key-file generation is server-side. No client (Manager, Element, plugin) can mint or re-sign keys. +2. **Public keys are embedded and harmless.** They only verify. Already present in shipped binaries. +3. **Entitlement decisions are server-side.** The client asks; the server returns a signed key or a denial. A patched client cannot grant itself products — it can only replay what the server signed, which is machine-locked and time-limited. +4. **Machine-locked keys.** Every key file is bound to `getLocalMachineIDs()`; it cannot be lifted to another machine. +5. **Expiring keys.** Subscriptions rely on short TTL + refresh. No perpetual key for subscription tiers. +6. **TLS everywhere**; tokens in OS secure storage. +7. **Fail closed to the *prompt*, not to unlocked.** Any failure in the ladder ends at the manual unlock UI, never at an unearned unlock. + +Threats explicitly out of scope of "safe embedding": binary patching to skip the `isUnlocked()` check is possible for any offline-verifiable scheme and is not made worse by this design; machine-locking + expiry + server-side entitlement are the mitigations. + +--- + +## 7. Open decisions + +| # | Decision | Options | Lean | +|---|----------|---------|------| +| 1 | Element long-term auth | (a) keep own login + read keys; (b) delegate all account to Manager, Element becomes pure reader | (a) now → (b) later | +| 2 | Can plugins refresh a token themselves? | Read-only (Manager is sole refresher) vs. plugins allowed silent token refresh when store token is stale | Read-only first; simplest, smallest attack surface | +| 3 | Key TTL & refresh threshold | e.g. 30-day TTL, refresh < 7 days | confirm with subscription cadence | +| 4 | Store token format | OS secure storage vs. encrypted file | OS secure storage, file fallback | +| 5 | Standalone-plugin-without-Manager UX | require Manager once vs. per-plugin lightweight login | require Manager (matches Native Access) | + +--- + +## 8. Server-side (DEFERRED — contract stubs only) + +Not designed here; captured so the client contracts are unambiguous. Extends the existing `kv-auth/v1` namespace. + +- `GET /kv-auth/v1/entitlements` (Bearer) → `{ tier, products: [productId…] }` +- `GET /kv-auth/v1/plugin-key?product=&machine=` (Bearer) + → signed JUCE key file (machine-locked, expiring) or `403` if not entitled. + Same shape as the existing `/appcast-url` precedent. +- Reuse existing `/token`, `/token/refresh`, `/token/revoke`. + +Signing service holds the per-product **private** keys and calls `KeyGeneration::generateKeyFile`. + +--- + +## 9. Phased roadmap + +1. **Shared read-side SDK** — `StoreReader`, `KushviewUnlockStatus`, key-file read/apply/cache, store layout & locations. Unit-testable with a fixture store and a locally generated key pair. +2. **Per-plugin integration in one product** (Tone Generator) — embed public key, wire the ladder + fallback prompt. Prove silent unlock from a hand-placed key file. +3. **Account Manager MVP** — lift `element::auth` OAuth/PKCE into it, URL-scheme handling, write tokens + entitlements + keys to the store. +4. **Refresh daemon** — login-item/agent, expiry-driven re-fetch, revocation delete. +5. **Element as reader** — link the SDK, read plugin keys (keep existing update login). +6. **Roll SDK across remaining plugins.** +7. **Server-side** — entitlements + plugin-key signing endpoints (separate plan). +8. *(Future)* download/install + migrate update entitlement into Manager. + +--- + +## 10. Portability notes (for the other project) + +- The SDK and `KushviewUnlockStatus` are pure JUCE + a store path — no Element dependencies. They drop into any Kushview JUCE product. +- The only per-product inputs are the **productId**, the **embedded public key**, and a **display name**. +- The store layout in §5 and the key-writing pattern in §3.3 are the interop contract between the Manager and all readers — keep them identical on both sides. +- Nothing here requires the server work to exist first: with a locally generated test key pair and a hand-written `keys/.key`, the entire read/apply/ladder path is buildable and testable today. diff --git a/docs/plans/luajit.md b/docs/plans/luajit.md new file mode 100644 index 000000000..18bd94daa --- /dev/null +++ b/docs/plans/luajit.md @@ -0,0 +1,108 @@ +# LuaJIT Migration — Assessment + +Sizing the job of moving Element's embedded Lua from vendored **Lua 5.4** +(`src/lua/src`, sol2 bindings) to **LuaJIT**, or supporting both. + +## Why bother + +- **DSP performance.** `DSPScript::process` runs Lua per audio block on the realtime + thread (`src/scripting/dspscript.cpp`). LuaJIT's tracing JIT typically runs numeric + Lua 5–50× faster than the 5.4 interpreter — directly widens what user DSP scripts can + do. +- **FFI.** LuaJIT's `ffi` lets scripts declare C structs/arrays and call C functions with + near-zero overhead and *no binding code*. This aligns exactly with the project + direction of "don't bind every C++ class — thin C core + native Lua wrappers" + (see [session-scripts.md](session-scripts.md)): buffer views, midi packing, and math + helpers could become `ffi` cdata instead of sol2 usertypes / hand-written C modules + (`bytes.c`, `vector.c`, `packed.h`). +- sol2 already supports LuaJIT (`SOL_LUAJIT`), so the binding layer itself is not a + rewrite. + +## The gaps + +### Language: LuaJIT is Lua 5.1 (+ selected 5.2 features) + +No integer subtype (everything is a double; 64-bit ints only via FFI/`ULL` suffixes), no +native bitwise operators (`&`, `|`, `~`, `<<` — use the `bit` library), no `//` floor +division, no `math.type`, no `utf8`, no ``, no seedable per-state RNG semantics of +5.4. `goto` **is** supported (2.0+), and `LUAJIT_ENABLE_LUA52COMPAT` adds `goto`-adjacent +5.2 niceties (`table.pack/unpack` placement, `#` metamethod, etc.). + +Initial audit of shipped scripts (`src/el/*.lua`, `scripts/*.lua`): no `math.type`, +`utf8`, ``, or labels found — the shipped script corpus is close to 5.1-clean +already. Needs a proper pass for `//` and bitwise operators (not reliably greppable) and +for integer-vs-float formatting assumptions (`string.format('%d', ...)` on non-integral +doubles errors on 5.3+ but not 5.1 — and vice-versa gotchas exist). + +**User impact:** any published promise that DSP scripts are "Lua 5.4" changes. If both +engines are supported, scripts must target the intersection (5.1 + bit library), which +should be documented in `docs/luastyle.md`. + +### C API: LuaJIT exposes the 5.1 C API + +Confirmed 5.2+/5.3+ API usage in-tree that would not compile against LuaJIT: +`lua_isinteger`, `lua_seti`/`lua_geti`, `luaL_setfuncs`, `luaL_requiref` (and friends) in +at least: `src/el/bytes.c`, `src/el/vector.c`, `src/el/MidiBuffer.cpp`, +`src/el/MidiMessage.cpp`, `src/el/AudioBufferImpl.ipp`, `src/scripting/dspscript.cpp`. + +Standard fix: vendor the **compat-5.3** shim (lunarmodules/lua-compat-5.3), which +provides these as inline wrappers on 5.1/LuaJIT — the same headers compile unchanged +against 5.4. This is the established path and avoids forking our C modules. +`lua_isinteger` semantics remain approximate on LuaJIT (no true integers) — the modules +using it (`bytes.c` packing, midi byte handling) need case-by-case review, since byte +packing is exactly where double-vs-int64 differences bite. + +### Platform / deployment + +- **macOS arm64:** LuaJIT supports arm64, but JIT-compiled code needs `MAP_JIT` and the + `com.apple.security.cs.allow-jit` entitlement under the hardened runtime. Fine for the + Element app (we control entitlements). +- **Element as a plugin:** the plugin runs inside a *host* process (Live, Logic, ...) + whose entitlements we do not control. A host without `allow-jit` breaks JIT — LuaJIT + must run with the JIT disabled (`jit.off()` interpreter mode, still faster than PUC-Lua + in many cases, but the headline win disappears). This is the single biggest strategic + caveat: **the plugin builds may never reliably get JIT on macOS.** +- Windows/Linux: no equivalent restriction. + +### Maintenance / ecosystem + +LuaJIT is on a rolling v2.1 branch (actively maintained; OpenResty's fork is a fallback). +GC is the 5.1-era incremental collector — no 5.4 generational mode; for realtime use we +already should be (and with LuaJIT, must be) steering allocation-free `process()` paths +and explicit `collectgarbage('step')` scheduling on the message thread. + +## Recommended shape of the work + +Dual-engine behind a CMake option, not a hard cutover: + +1. **`EL_LUA=lua54|luajit`** CMake option; FetchContent or vendor LuaJIT; define + `SOL_LUAJIT` accordingly. +2. Vendor **compat-5.3** and switch the C modules/binding files listed above to include + it (no-op for 5.4 builds). +3. Script corpus audit (`src/el/*.lua`, `scripts/*.lua`, docs examples) down to the + 5.1+bit intersection; update `docs/luastyle.md`. +4. CI test matrix: run the full `test_element` scripting suites under both engines — + the Boost tests in `test/scripting/` become the compatibility gate for free. +5. Benchmark: a DSP-script stress fixture (existing test node pattern) timed under + 5.4 vs LuaJIT-interp vs LuaJIT-JIT, so the decision is data-driven. +6. Only after extensions/hooks land: explore FFI-based buffer/midi views as an + alternative to `bytes.c`/`vector.c` (LuaJIT-only fast path, portable fallback kept). + +## Sizing (rough) + +| Step | Effort | +|---|---| +| CMake option + LuaJIT vendoring + sol2 config | small (days) | +| compat-5.3 adoption across C modules | small-medium; mechanical + `bytes.c` integer review | +| Script corpus 5.1-intersection audit | small | +| Dual-engine CI + benchmarks | medium | +| FFI buffer/midi fast path | larger, separate follow-up | + +## Recommendation + +Worth doing, **after** Phase 0 (session scripts/hooks) and the extension core, and as a +dual-engine option rather than a migration — because (a) the plugin-in-host entitlement +problem means 5.4 must remain a supported fallback anyway, and (b) the test suites those +phases add are exactly the safety net the second engine needs. The strategic win is FFI +plus DSP throughput in the standalone app; the cost is pinning the script dialect to the +5.1 intersection. diff --git a/docs/plans/osc.md b/docs/plans/osc.md new file mode 100644 index 000000000..cee8a9c42 --- /dev/null +++ b/docs/plans/osc.md @@ -0,0 +1,89 @@ +# OSC Control at the Node/Parameter Level + +## Context + +Element's OSC support today ([oscservice.cpp](src/services/oscservice.cpp)) handles only two fixed addresses (`/element/command` — a no-op stub — and `/element/engine` for samplerate). The goal is full bidirectional OSC control of node parameters: **set** values, **query** current values with OSC replies, and push **feedback** to a configured client so bidirectional control surfaces (TouchOSC etc.) stay in sync. + +Decisions made with the user: +- Nodes addressed by **numeric node id or slug** (sanitized name). +- **Full bidirectional** scope (set + query + feedback, with new client settings/UI). +- **No gesture bracketing** for now — plain `setValueNotifyingHost` (defer begin/end gestures). + +Hard constraint discovered: `juce::OSCReceiver` callbacks do not expose the sender's address, so replies/feedback all go to a single **configured OSC client host/port** (new settings), not back to the sender. + +## Address spec + +``` +/element[/graph/]/node//param/ [value] – regular parameter +/element[/graph/]/node//enabled [value] +/element[/graph/]/node//bypass [value] +/element[/graph/]/node//mute [value] +/element[/graph/]/node//inputgain [value] +/element[/graph/]/node//outputgain [value] +/element/command – existing, folded in +/element/engine [args…] – existing, folded in +``` + +- ``: decimal index (`session->getGraph(i)`) or slug; **omitted → active graph** (node ids are only unique per graph). +- ``: all-digits → uint32 engine node id via `Node::getNodeById` (recursive); otherwise slug. +- **Slug rule**: name (fallback `getDisplayName()`), lowercased, runs of non-`[a-z0-9]` → single `-`, trimmed; collisions resolve to **first match in depth-first order** (deterministic; ids are the exact address). +- **Set**: one arg, float32 or int32; regular params normalized 0..1 clamped; specials on ⇔ value ≥ 0.5; gains normalized over [-60, +6] dB (same math as `ParameterTarget::applyGain`). +- **Query**: same address, zero args → reply with the **incoming address echoed verbatim** + one float32 (so one widget address works both ways). +- Wildcard patterns: optional later phase via `juce::OSCAddressPattern::matches` over enumerated candidates. + +## Implementation + +### 1. `ParameterTarget` value API (refactor, DRY) +[mappingtarget.hpp](src/engine/mappingtarget.hpp) / [mappingtarget.cpp](src/engine/mappingtarget.cpp): add public value-based methods and rewire the private MIDI apply paths through them: +- `void setNormalizedValue (float)` — `setValueNotifyingHost`, no gestures +- `void setSpecial (bool on)` — extracted from `applySpecial` (enabled → `object->setEnabled` + `tags::enabled`; bypass → `suspendProcessing` + `tags::bypass`; mute → `model.setMuted`) +- `void setGainNormalized (float)` — extracted from `applyGain` +- `float getNormalizedValue() const` — new, for query replies +- `ParameterPtr getParameter() const` — for feedback observation + +Existing `MappingTargetTests` must stay green. + +### 2. `OSCRouter` — new testable core (no networking) +New files `src/services/oscrouter.hpp/.cpp` (glob picks them up). Message-thread only; constructed with `SessionPtr`. +- `detail::oscSlugify (const juce::String&)` — free function, unit-testable. +- `Result handleMessage (const juce::OSCMessage&)` where `Result { bool handled; std::vector replies; }`. +- `registerHandler (address, std::function)` — exact-address handlers checked first; used to fold in the existing `/element/command` and `/element/engine` listeners (delete `CommandOSCListener`/`EngineOSCListener` structs). +- **Cache**: `std::map` keyed by full address string → `{ Node, std::unique_ptr }`; lazy fill on miss (graph → node by id or slug DFS → `ParameterTarget(node, index)` with special-param enums from [processor.hpp](include/element/processor.hpp) for the named specials); `clearCache()` for wholesale invalidation. +- `Signal sigObserveParameter` — fired on successful set/query of regular params; the service uses it to start feedback observation. + +### 3. Settings + client sender +[settings.hpp](include/element/settings.hpp) / [settings.cpp](src/settings.cpp), following the exact `oscHost*` pattern (settings.cpp:325-354): `isOscClientEnabled/setOscClientEnabled`, `getOscClientHost/setOscClientHost` (default `"127.0.0.1"`), `getOscClientPort/setOscClientPort` (default `9001`). +`OSCService::refreshWithSettings`: also reconnect `impl->sender` to the client host/port when enabled (alert on failure like the host path). + +### 4. Service wiring +[oscservice.cpp](src/services/oscservice.cpp) `Impl`: +- Replace per-address listeners with a catch-all `OSCReceiver::Listener` (message-thread dispatch → safe for models and `setValueNotifyingHost`). Handle bundles by recursing elements. +- `oscMessageReceived`: `router->handleMessage(msg)`; send replies via `sender` only when connected. +- Cache invalidation: in `activate()`, connect `sibling()->sigSessionLoaded` and `context().audio()->sigNodeRemoved` ([engine.hpp:130](include/element/engine.hpp#L130)) → `router->clearCache()` + clear feedback observers; store `SignalConnection`s, disconnect in `deactivate()`. (No node-added signal exists; lazy resolution covers additions.) + +### 5. Feedback +Private `FeedbackController` struct in the Impl: `std::map>` keyed by address, populated from `sigObserveParameter` (only params touched/queried via OSC are observed — bounded cost). Each observer's `sigValueChanged` sends `OSCMessage(address, value)` via `sender`, skipping unchanged values; `ParameterObserver`'s built-in 50 Hz/backoff throttling applies. Cleared on session load / node removal (observers hold `ParameterPtr` refs — must not outlive node removal). +v1 limitation: push feedback for regular parameters only; specials support set + query but no push (they aren't `Parameter`s; a later ValueTree-listener approach must use a persistent member tree, never `addListener` on `model.data()`). + +### 6. Preferences UI +[preferences.cpp](src/ui/preferences.cpp) `OSCSettingsPage` (lines 123-204): add three rows mirroring the host rows — client enabled `SettingButton`, editable client-host `TextEditor` (unlike the read-only host field), IncDec port `Slider`; changes write settings + `triggerAsyncUpdate()` → existing `refreshWithSettings(true)` plumbing. + +## Tests + +New `test/OSCRouterTests.cpp`, suite `OSCRouterTests`; add `add_test(NAME "OSCRouterTests" COMMAND test_element --run_test=OSCRouterTests)` to [test/CMakeLists.txt](test/CMakeLists.txt). juce_osc is linked PUBLIC so `juce::OSCMessage` constructs directly. Fixture per `MidiMappingSessionTests`/`MappingTargetTests`: `Context` + `session()`, `makeNode` helper (extend MappingTargetTests:19-25 pattern to set `tags::id`/`tags::name`) backed by `test/fixture/ParamTestNode.h`. + +Cases: slugify rule; set by id and by slug (float + int arg, clamping); slug collision first-match; specials set object + model properties; gain math; query reply echoes address with correct value; graph prefix and active-graph default; unhandled addresses (foreign namespace, missing node, bad index) → `handled == false`, no crash; registered-handler fold-in short-circuits; cache invalidation re-resolves after `clearCache()`; `sigObserveParameter` fires with exact address + correct `ParameterPtr`. + +## Commit-sized phases + +1. `ParameterTarget` value API refactor (+ keep MappingTargetTests green). +2. `OSCRouter` + full test suite (everything testable before networking). +3. Settings keys + sender connection. +4. Service wiring: catch-all listener, fold-in handlers, replies, invalidation signals. +5. Feedback controller + preferences UI rows. +6. (Optional) wildcard support; docs for the address spec; `util/format.py` pass. + +## Verification + +- `cmake --build build && ctest --test-dir build --output-on-failure -R OSCRouterTests` (plus `-R MappingTargetTests` after phase 1). +- End-to-end: run Element, enable OSC host (port 9000) and client (9001) in Preferences; from a shell use an OSC tool (e.g. `oscsend`/the existing `test/osc/node-client` JS client) to send `/element/node//param/0 0.5` and watch the UI knob move; send the zero-arg form and confirm the reply/feedback arrives on the client port (e.g. `oscdump 9001`). diff --git a/docs/plans/session-scripts.md b/docs/plans/session-scripts.md new file mode 100644 index 000000000..3328f6e70 --- /dev/null +++ b/docs/plans/session-scripts.md @@ -0,0 +1,160 @@ +# Phase 0 — Session Scripts & the Hook System + +Predecessor to [extensions.md](extensions.md). Front-loads the pieces the extension format +needs anyway — the hook bus, the script descriptor conventions, and the restricted +environment / capability model — but delivers them first for scripts **embedded in the +session file**, where DSP/DSPUI scripts already live today. + +## Context + +Nodes already carry scripts: a `scripts` child tree of `Script` models (name / type / +gzip'd code), with `Graph::findViewScript()` resolving the `View` script and `ScriptNode` +embedding DSP source in its state blob. The Session root does **not** have a scripts tree +(`src/session.cpp` — children are `graphs`, `controllers`, `maps`, `midiMappings`, `ui`). + +Phase 0 extends the same pattern to the session: sessions can carry scripts that *run* at +defined lifecycle points. Scripts follow the **same descriptor format planned for +extensions**, so a script is portable between "written into the session" and "shipped in +a `.element` extension" without edits. + +## Design principles + +1. **WordPress-inspired hooks, not a clone.** Two dispatch kinds: + - **actions** — fire-and-forget notifications (`node.added`, `session.loaded`, ...) + - **filters** — each handler receives a value and returns a (possibly modified) value; + the host uses the final result (e.g. filter a display name, a save payload, a menu). + Handlers have an optional integer priority (default 10, lower runs first) and an owner + tag for bulk teardown. No WordPress-style global mutable everything — events are + explicit, dispatch is message-thread only, and the C++ side owns the registry. + +2. **Untrusted by default — capabilities are earned.** A session file is a *document*; + documents that carry executable code must not silently get the keys to the app. Session + scripts run in a restricted `sol::environment`: + - Base env: safe stdlib subset (no `io`, no `os` beyond `time`/`clock`, no `require` + of arbitrary modules), plus pure-data `el.*` modules (bytes, midi, colors, strings). + - Anything powerful — `el.engine`, `el.Context`, `el.ui`, file access — must be + declared in the script descriptor (`requires = { "engine", "ui" }`) and granted by + the host. Grant policy v1: per-session trust prompt on first run ("This session + contains scripts that want: engine access. Run / Run always for this session / + Don't run"), persisted in settings keyed by a session content hash or path. + - Extensions (user-installed) get a more permissive default later; the *mechanism* + (declared requires → injected capabilities) is identical. + +3. **Bind less C++, write more Lua.** The rule going forward: C++ binds a minimal opaque + userdata handle ("impl"), and the ergonomic API is a native Lua module holding that + handle as a private member. Precedent already in-tree: `src/el/object.lua`, + `session.lua`, `command.lua`, `script.lua` wrap C bindings in Lua tables. New surface + (`el.hooks`, later `el.engine` sugar) should be Lua-first with a thin C core — this + avoids sol2 usertype boilerplate for every class and keeps the public script API + decoupled from C++ headers. + +## What gets built + +### 1. Model: session-level scripts + +- `Session` gains a `scripts` child tree, identical shape to `Node::getScriptsValueTree()` + (`include/element/node.hpp:434`). Add `Session::scripts()` / `addScript()` / + `removeScript()` accessors mirroring `Node::addScript` (`src/node.cpp:476`). +- New script type tag: `EL_TAG(Hook)` in `include/element/tags.hpp` (joins `DSP`, `View`, + `GraphView`, `Anonymous`). `Script::make` (`src/script.cpp:183`) accepts `types::Hook` + and seeds a template. +- Additive child tree — no `EL_SESSION_VERSION` bump; add a no-op guard in + `Session::migrate`. +- Persistence is free: `Script` code is already gzip'd into the tree, so `.els` stays a + single file. + +### 2. Hook script descriptor (portable session ↔ extension) + +```lua +--- Session hooks example. +-- @script my-session-hooks +-- @type Hook +return { + type = 'Hook', + requires = { 'engine' }, -- capabilities this script needs + attach = function (hooks, ctx) -- called once when the script is activated + hooks.action ('session.loaded', function() ... end) + hooks.action ('node.added', function (node) ... end, 20) -- priority + hooks.filter ('node.displayName', function (name, node) + return name .. ' *' + end) + end, + detach = function() ... end, -- optional cleanup +} +``` + +`ctx` is the capability table: only granted entries are present (`ctx.engine`, +`ctx.context`, ...). Scripts that got nothing still get `hooks` — pure observers that, +e.g., filter cosmetic values, need no grants at all. + +### 3. `HookBus` (C++, `src/scripting/hookbus.hpp/.cpp`) + +Same class the extensions plan specifies (its Phase 3 shrinks to "wire more signals"): + +- `addAction (event, sol::protected_function, priority, owner)` / `addFilter (...)` → + `Handle`; `remove (Handle)`; `removeOwner (ownerId)`. +- `dispatchAction (event, pusher)` and `applyFilters (event, initialValue, pusher)`. +- Message-thread only (assert); dispatch iterates a copy (handlers may add/remove); + reentrancy guard — nested dispatch queues and drains after; a handler is auto-disabled + after 3 consecutive errors; every call is protected, errors go to + `ScriptingEngine::logError`, never propagate. + +Initial events (actions): `session.loaded`, `session.saving`, `graph.added`, +`graph.removed`, `graph.changed`, `node.added`, `node.removed`. Initial filters: start +with one or two proving the shape (e.g. `node.displayName`) — filters are the part to +grow cautiously. Signal sources: `SessionService::sigSessionLoaded` / `sigWillSave` +(`src/services/sessionservice.hpp:37-38`), `EngineService::sigNodeRemoved`, plus new +`sigNodeAdded` / `sigGraphAdded` / `sigGraphRemoved` emitted at the same sites in +`src/services/engineservice.cpp`. + +### 4. `el.hooks` Lua module — Lua-first + +Per principle 3: a small C binding exposing an opaque bus handle + `register/unregister/ +emit` primitives (`src/el/Hooks.cpp`), wrapped by `src/el/hooks.lua` providing the +friendly `action`/`filter`/`off` API, priority defaults, and owner scoping. The `hooks` +object passed to `attach` is a Lua table closing over the owner id — no usertype needed. + +### 5. Runner & lifecycle (`ScriptingService`, new — shared with extensions plan) + +- On `sigSessionLoaded`: read the session `scripts` tree, for each `Hook` script: + restricted env → run → descriptor table → check `requires` vs grants → prompt if + needed → call `attach (hooks, ctx)`. Owner id = script's tree UUID. +- On session unload/reload/close: call `detach` (protected), `HookBus::removeOwner`, + drop envs. Also handles in-session edits: saving a hook script in the editor + re-attaches it (detach → re-run). +- `app.*` events and extension ownership come later (extensions plan); the bus API is + already owner-based so nothing changes shape. + +### 6. UI (minimal) + +- Session properties / session panel: list session scripts, add/remove, open in the + existing `ScriptEditorView` (`src/ui/scripteditorview.cpp`). +- A "scripts blocked / granted" indicator with a way to re-open the trust prompt. + +## Testing (Boost.Test, `test/scripting/`) + +- Session scripts tree round-trip (add → save XML → load → scripts intact). +- HookBus: priority order, filter value threading, auto-disable after errors, + removeOwner, reentrancy (action handler emitting another action queues, no recursion). +- Runner: descriptor with `requires` not granted → `attach` never called, no error spam; + granted → ctx contains exactly the granted capabilities. +- Restricted env: `io`/`os.execute`/raw `require` unavailable inside a hook script. +- Register suites in `test/CMakeLists.txt` with `add_test`. + +## Relationship to the extensions plan + +- HookBus, `el.hooks`, restricted-env + capability machinery, and `ScriptingService` + move **here** (Phase 0). [extensions.md](extensions.md) Phase 3 reduces to wiring + `app.started`/`app.shutdown` and extension-owned registration; extension entry scripts + reuse the same descriptor/capability conventions with a friendlier default grant. +- The capability model is also the answer to "extensions are trusted-but-isolated" — + one mechanism, two default policies. + +## Open questions + +1. Trust persistence key: content hash (safer, invalidates on edit) vs file path + (friendlier). Suggest hash of the scripts subtree only. +2. Do hook scripts on *nodes/graphs* (not just the session root) run too? Suggest: yes + eventually (graph-scoped hooks fire only while that graph is active), but session-root + only in Phase 0. +3. Filter catalog — which host values are worth filtering first. diff --git a/include/element/audioengine.hpp b/include/element/audioengine.hpp index 35d5f966c..679f5ae6a 100644 --- a/include/element/audioengine.hpp +++ b/include/element/audioengine.hpp @@ -68,6 +68,24 @@ class AudioEngine final : public juce::ReferenceCountedObject { juce::AudioIODeviceCallback& getAudioIODeviceCallback(); juce::MidiInputCallback& getMidiInputCallback(); + /** Returns the total number of audio IO callbacks processed so far. + + Increments once per audio block while the device is running. Poll it + from a timer to detect a device whose callbacks have silently stopped. + + @return the number of audio IO callbacks processed + */ + uint64_t ioCallbackTicks() const; + + /** Returns and clears the last device error reported by the audio device. + + Errors are reported by some backends (e.g. CoreAudio) when a device + fails mid-stream. Safe to call from the message thread. + + @return the last error message, or an empty string if none occurred + */ + juce::String lastDeviceErrorMessage(); + /** For use by external systems only! e.g. the AU/VST version of Element and possibly things like rendering in the future */ diff --git a/include/element/devices.hpp b/include/element/devices.hpp index 54695214e..b9b02e12f 100644 --- a/include/element/devices.hpp +++ b/include/element/devices.hpp @@ -5,6 +5,7 @@ #include #include +#include namespace element { @@ -20,6 +21,25 @@ class DeviceManager : public juce::AudioDeviceManager { void selectAudioDriver (const juce::String& name); void attach (AudioEnginePtr engine); + /** Fires on the message thread whenever an audio device type reports that + its device list changed, e.g. an interface was plugged in or removed — + or, on Windows, when audio-class hardware arrived or was removed + (ASIO cannot report this itself). */ + Signal sigDeviceListChanged; + + /** Checks whether a device is physically present in a device type's list. + + Unlike querying getAvailableDeviceTypes() directly, this always checks + the real hardware list, ignoring the hold that keeps a disconnected + device visible while it is still open (see WatchedDeviceType). + + @param typeName The audio device type name (e.g. "CoreAudio") + @param deviceName The device name to look for + @param rescan If true, rescan the type for devices before checking + @return true if the device is present in the type's hardware list + */ + bool isDevicePresent (const juce::String& typeName, const juce::String& deviceName, bool rescan); + #if KV_JACK_AUDIO kv::JackClient& getJackClient(); #endif diff --git a/src/application.cpp b/src/application.cpp index 2c23e63cd..140c9b77a 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -19,6 +19,7 @@ #include "engine/midiengine.hpp" #include "scripting.hpp" #include +#include "services/deviceservice.hpp" #include "services/sessionservice.hpp" #include "log.hpp" #include "messages.hpp" @@ -88,23 +89,20 @@ class Startup : public ActionBroadcaster auto* props = settings.getUserSettings(); - String error = "No device found at startup"; - if (auto dxml = props->getXmlValue ("devices")) + String error; + if (auto dxml = props->getXmlValue (Settings::devicesKey)) { + // Never substitute another device for the saved one: if it isn't + // connected right now, stay silent and let the device monitor + // restore it when it comes back (see AudioDeviceMonitor). error = devices.initialise (DeviceManager::maxAudioChannels, DeviceManager::maxAudioChannels, dxml.get(), - true, + false, "*", nullptr); - if (error.isNotEmpty()) - { - auto setup = devices.getAudioDeviceSetup(); - error = devices.setAudioDeviceSetup (setup, true); - } } - - if (error.isNotEmpty()) + else { #if JUCE_WINDOWS devices.setCurrentAudioDeviceType ("Windows Audio (Low Latency Mode)", true); @@ -115,7 +113,7 @@ class Startup : public ActionBroadcaster if (error.isNotEmpty()) { - Logger::writeToLog (error); + Logger::writeToLog (String ("[element] audio device not opened at startup: ") + error); } } @@ -404,7 +402,13 @@ void Application::suspended() {} void Application::resumed() { auto& devices (world->devices()); - devices.restartLastAudioDevice(); + // Restarting with an empty setup asserts; if nothing was ever open the + // device monitor owns any restore that might be needed. + const auto setup = devices.getAudioDeviceSetup(); + if (setup.inputDeviceName.isNotEmpty() || setup.outputDeviceName.isNotEmpty()) + devices.restartLastAudioDevice(); + if (auto* const deviceService = world->services().find()) + deviceService->onResume(); } void Application::finishLaunching() diff --git a/src/devicemanager.cpp b/src/devicemanager.cpp index 823aecbc0..87aa9b3c6 100644 --- a/src/devicemanager.cpp +++ b/src/devicemanager.cpp @@ -1,15 +1,215 @@ // Copyright 2014-2023 Kushview, LLC // SPDX-License-Identifier: GPL-3.0-or-later +#include + #include #include "engine/jack.hpp" +#if JUCE_WINDOWS +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#endif + namespace element { using namespace juce; const int DeviceManager::maxAudioChannels = 128; +namespace { + +/** Wraps a native AudioIODeviceType so Element controls disconnect policy. + + While a device of this type is open, its name stays in the reported device + lists even after the hardware disappears. AudioDeviceManager's internal + availability check then still passes, so it never closes the device and + auto-substitutes another one. Element's audio device monitor owns the + close / wait / restore policy instead. +*/ +class WatchedDeviceType : public AudioIODeviceType, + private AudioIODeviceType::Listener +{ +public: + WatchedDeviceType (std::unique_ptr innerType, DeviceManager& dm) + : AudioIODeviceType (innerType->getTypeName()), + owner (dm), + inner (std::move (innerType)) + { + inner->addListener (this); + } + + ~WatchedDeviceType() override { inner->removeListener (this); } + + void scanForDevices() override { inner->scanForDevices(); } + + StringArray getDeviceNames (bool wantInputNames) const override + { + auto names = inner->getDeviceNames (wantInputNames); + + if (auto* const device = owner.getCurrentAudioDevice()) + { + if (device->getTypeName() == getTypeName()) + { + const auto setup = owner.getAudioDeviceSetup(); + const auto& held = wantInputNames ? setup.inputDeviceName + : setup.outputDeviceName; + if (held.isNotEmpty() && ! names.contains (held)) + names.add (held); + } + } + + return names; + } + + int getDefaultDeviceIndex (bool forInput) const override + { + return inner->getDefaultDeviceIndex (forInput); + } + + int getIndexOfDevice (AudioIODevice* device, bool asInput) const override + { + return inner->getIndexOfDevice (device, asInput); + } + + bool hasSeparateInputsAndOutputs() const override + { + return inner->hasSeparateInputsAndOutputs(); + } + + AudioIODevice* createDevice (const String& outputDeviceName, + const String& inputDeviceName) override + { + return inner->createDevice (outputDeviceName, inputDeviceName); + } + + /** Checks the unwrapped hardware list for a device name. */ + bool innerHasDevice (const String& name, bool rescan) + { + if (rescan) + inner->scanForDevices(); + return inner->getDeviceNames (true).contains (name) + || inner->getDeviceNames (false).contains (name); + } + +private: + void audioDeviceListChanged() override + { + callDeviceChangeListeners(); + owner.sigDeviceListChanged(); + } + + DeviceManager& owner; + std::unique_ptr inner; +}; + +#if JUCE_WINDOWS + +/** Watches WM_DEVICECHANGE for audio-class hardware arrival and removal. + + ASIO device types never report list changes on hot plug — their scan only + reads the registry — so this is the sole signal that ASIO hardware + actually appeared or disappeared. Registration is filtered to + KSCATEGORY_AUDIO device interfaces so unrelated hardware (USB sticks, + mice) does not trigger callbacks. + + The window is created on the message thread and JUCE pumps all messages + there, so the WndProc and the debounced callback both run on the message + thread; no cross-thread marshalling is needed. + + A hidden top-level window is required: message-only (HWND_MESSAGE) + windows never receive WM_DEVICECHANGE broadcasts. +*/ +class AudioHardwareWatcher : private Timer +{ +public: + explicit AudioHardwareWatcher (std::function callback) + : onChange (std::move (callback)) + { + JUCE_ASSERT_MESSAGE_THREAD + + const auto className = "ElementHWWatch_" + + String::toHexString (Time::getHighResolutionTicks()); + instance = (HINSTANCE) Process::getCurrentModuleInstanceHandle(); + + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof (wc); + wc.lpfnWndProc = wndProc; + wc.hInstance = instance; + wc.lpszClassName = className.toWideCharPointer(); + atom = RegisterClassExW (&wc); + if (atom == 0) + return; + + hwnd = CreateWindowExW (0, className.toWideCharPointer(), L"", 0, 0, 0, 0, 0, nullptr, nullptr, instance, nullptr); + if (hwnd == nullptr) + return; + + SetWindowLongPtrW (hwnd, GWLP_USERDATA, (LONG_PTR) this); + + // KSCATEGORY_AUDIO: the device interface class registered by audio + // hardware drivers. + static const GUID kscategoryAudio = { + 0x6994AD04, 0x93EF, 0x11D0, { 0xA3, 0xCC, 0x00, 0xA0, 0xC9, 0x22, 0x31, 0x96 } + }; + + DEV_BROADCAST_DEVICEINTERFACE_W filter = {}; + filter.dbcc_size = sizeof (filter); + filter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; + filter.dbcc_classguid = kscategoryAudio; + notify = RegisterDeviceNotificationW (hwnd, &filter, DEVICE_NOTIFY_WINDOW_HANDLE); + } + + ~AudioHardwareWatcher() override + { + stopTimer(); + if (notify != nullptr) + UnregisterDeviceNotification (notify); + if (hwnd != nullptr) + DestroyWindow (hwnd); + if (atom != 0) + UnregisterClassW ((LPCWSTR) MAKEINTATOM (atom), instance); + } + +private: + static LRESULT CALLBACK wndProc (HWND h, UINT message, WPARAM wParam, LPARAM lParam) + { + if (message == WM_DEVICECHANGE + && (wParam == DBT_DEVICEARRIVAL || wParam == DBT_DEVICEREMOVECOMPLETE)) + { + // Restarting the timer coalesces bursts, e.g. composite devices + // exposing several interfaces. + if (auto* const self = reinterpret_cast ( + GetWindowLongPtrW (h, GWLP_USERDATA))) + self->startTimer (500); + } + + return DefWindowProcW (h, message, wParam, lParam); + } + + void timerCallback() override + { + stopTimer(); + onChange(); + } + + std::function onChange; + HINSTANCE instance {}; + ATOM atom {}; + HWND hwnd {}; + HDEVNOTIFY notify {}; +}; + +#endif // JUCE_WINDOWS + +} // namespace + class DeviceManager::Private { public: @@ -23,6 +223,14 @@ class DeviceManager::Private #endif juce::ReferenceCountedArray levelsIn, levelsOut; + + // Owned by the base class' device type list. Kept for direct access to + // the unwrapped hardware lists (see isDevicePresent). + juce::Array watched; + +#if JUCE_WINDOWS + std::unique_ptr hardwareWatcher; +#endif }; DeviceManager::DeviceManager() @@ -60,7 +268,7 @@ void DeviceManager::attach (AudioEnginePtr engine) impl->engine = engine; } -static void addIfNotNull (OwnedArray& list, AudioIODeviceType* const device) +[[maybe_unused]] static void addIfNotNull (OwnedArray& list, AudioIODeviceType* const device) { if (device != nullptr) list.add (device); @@ -68,24 +276,68 @@ static void addIfNotNull (OwnedArray& list, AudioIODeviceType void DeviceManager::createAudioDeviceTypes (OwnedArray& list) { + impl->watched.clearQuick(); + + // Wrap each native type so disconnect policy stays under Element's + // control. JACK is left unwrapped: its lifecycle is server based. + auto addWatched = [&] (AudioIODeviceType* const type) { + if (type == nullptr) + return; + auto watchedType = std::make_unique ( + std::unique_ptr (type), *this); + impl->watched.add (watchedType.get()); + list.add (watchedType.release()); + }; + #if JUCE_ALSA - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_ALSA()); + addWatched (AudioIODeviceType::createAudioIODeviceType_ALSA()); #endif #if ELEMENT_USE_JACK addIfNotNull (list, Jack::createAudioIODeviceType (impl->jack)); #endif - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_ASIO()); - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_WASAPI (WASAPIDeviceMode::exclusive)); - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_WASAPI (WASAPIDeviceMode::sharedLowLatency)); - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_DirectSound()); + addWatched (AudioIODeviceType::createAudioIODeviceType_ASIO()); - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_CoreAudio()); +#if JUCE_WINDOWS + // ASIO cannot report hot plug itself; watch the OS instead. + if (impl->hardwareWatcher == nullptr) + impl->hardwareWatcher = std::make_unique ( + [this] { sigDeviceListChanged(); }); +#endif - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_iOSAudio()); + addWatched (AudioIODeviceType::createAudioIODeviceType_WASAPI (WASAPIDeviceMode::exclusive)); + addWatched (AudioIODeviceType::createAudioIODeviceType_WASAPI (WASAPIDeviceMode::sharedLowLatency)); + addWatched (AudioIODeviceType::createAudioIODeviceType_DirectSound()); + + addWatched (AudioIODeviceType::createAudioIODeviceType_CoreAudio()); + + addWatched (AudioIODeviceType::createAudioIODeviceType_iOSAudio()); + + addWatched (AudioIODeviceType::createAudioIODeviceType_OpenSLES()); + addWatched (AudioIODeviceType::createAudioIODeviceType_Android()); +} + +bool DeviceManager::isDevicePresent (const String& typeName, const String& deviceName, bool rescan) +{ + if (typeName.isEmpty() || deviceName.isEmpty()) + return false; + + for (auto* const type : impl->watched) + if (type->getTypeName() == typeName) + return type->innerHasDevice (deviceName, rescan); + + // Unwrapped types (e.g. JACK). + for (auto* const type : getAvailableDeviceTypes()) + { + if (type->getTypeName() != typeName) + continue; + if (rescan) + type->scanForDevices(); + return type->getDeviceNames (true).contains (deviceName) + || type->getDeviceNames (false).contains (deviceName); + } - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_OpenSLES()); - addIfNotNull (list, AudioIODeviceType::createAudioIODeviceType_Android()); + return false; } void DeviceManager::getAudioDrivers (StringArray& drivers) diff --git a/src/engine/audioengine.cpp b/src/engine/audioengine.cpp index 20bfd40b2..ff4fcdf27 100644 --- a/src/engine/audioengine.cpp +++ b/src/engine/audioengine.cpp @@ -381,6 +381,7 @@ class AudioEngine::Private : public AudioIODeviceCallback, const AudioIODeviceCallbackContext& context) override { jassert (sampleRate > 0 && blockSize > 0); + ioTicks.fetch_add (1, std::memory_order_relaxed); int totalNumChans = 0; ScopedNoDenormals denormals; @@ -584,6 +585,14 @@ class AudioEngine::Private : public AudioIODeviceCallback, audioStopped(); } + void audioDeviceError (const String& errorMessage) override + { + // Can arrive on arbitrary threads (e.g. CoreAudio HAL). Only stash + // the message; the device monitor polls it from the message thread. + const ScopedLock sl (deviceErrorLock); + deviceErrorMessage = errorMessage; + } + void audioStopped() { const ScopedLock sl (lock); @@ -771,6 +780,10 @@ class AudioEngine::Private : public AudioIODeviceCallback, Atomic midiOutLatency { 0.0 }; std::atomic audioStarted { false }; + std::atomic ioTicks { 0 }; + CriticalSection deviceErrorLock; + String deviceErrorMessage; + ReferenceCountedArray inMeters, outMeters; void prepareGraph (RootGraph* graph, double sampleRate, int estimatedBlockSize) @@ -835,6 +848,21 @@ AudioIODeviceCallback& AudioEngine::getAudioIODeviceCallback() jassert (priv != nullptr); return *priv; } + +uint64_t AudioEngine::ioCallbackTicks() const +{ + jassert (priv != nullptr); + return priv->ioTicks.load (std::memory_order_relaxed); +} + +String AudioEngine::lastDeviceErrorMessage() +{ + jassert (priv != nullptr); + const ScopedLock sl (priv->deviceErrorLock); + auto message = priv->deviceErrorMessage; + priv->deviceErrorMessage.clear(); + return message; +} MidiInputCallback& AudioEngine::getMidiInputCallback() { jassert (priv != nullptr); diff --git a/src/services/devicemonitor.cpp b/src/services/devicemonitor.cpp new file mode 100644 index 000000000..19df21ad7 --- /dev/null +++ b/src/services/devicemonitor.cpp @@ -0,0 +1,347 @@ +// Copyright 2026 Kushview, LLC +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "services/devicemonitor.hpp" + +namespace element { + +AudioDeviceMonitor::AudioDeviceMonitor (Backend& backendToUse) + : backend (backendToUse) +{ +} + +bool AudioDeviceMonitor::hasDesired() const noexcept +{ + return desiredInput.isNotEmpty() || desiredOutput.isNotEmpty(); +} + +juce::String AudioDeviceMonitor::desiredDisplayName() const +{ + return desiredOutput.isNotEmpty() ? desiredOutput : desiredInput; +} + +void AudioDeviceMonitor::parseDesired() +{ + desiredType = desiredInput = desiredOutput = juce::String(); + if (desiredXml == nullptr) + return; + + desiredType = desiredXml->getStringAttribute ("deviceType"); + + // Attribute names match AudioDeviceManager's DEVICESETUP format, + // including the legacy single-name variant. + const auto legacyName = desiredXml->getStringAttribute ("audioDeviceName"); + if (legacyName.isNotEmpty()) + { + desiredInput = desiredOutput = legacyName; + } + else + { + desiredInput = desiredXml->getStringAttribute ("audioInputDeviceName"); + desiredOutput = desiredXml->getStringAttribute ("audioOutputDeviceName"); + } +} + +bool AudioDeviceMonitor::desiredPresent (bool rescan) +{ + if (! hasDesired()) + return false; + + if (desiredInput.isNotEmpty()) + { + if (! backend.isDevicePresent (desiredType, desiredInput, rescan)) + return false; + rescan = false; + } + + if (desiredOutput.isNotEmpty() && desiredOutput != desiredInput) + if (! backend.isDevicePresent (desiredType, desiredOutput, rescan)) + return false; + + return true; +} + +bool AudioDeviceMonitor::matchesDesired (const DeviceInfo& device) const +{ + if (desiredType.isNotEmpty() && device.typeName != desiredType) + return false; + return device.inputName == desiredInput && device.outputName == desiredOutput; +} + +void AudioDeviceMonitor::setState (State newState, const juce::String& deviceName) +{ + if (current.state == newState && current.deviceName == deviceName) + return; + current.state = newState; + current.deviceName = deviceName; + sigStatusChanged (current); +} + +void AudioDeviceMonitor::seed (std::unique_ptr savedXml) +{ + desiredXml = std::move (savedXml); + parseDesired(); + + const auto device = backend.currentDevice(); + lastTicks = backend.ioTicks(); + staleCount = pollCount = 0; + + if (device.open) + setState (State::active, device.outputName.isNotEmpty() ? device.outputName : device.inputName); + else if (hasDesired()) + setState (State::waiting, desiredDisplayName()); + else + setState (State::inactive, {}); +} + +void AudioDeviceMonitor::updateDesiredFromBackend() +{ + auto xml = backend.stateXml(); + if (xml == nullptr) + return; + + if (desiredXml == nullptr || ! xml->isEquivalentTo (desiredXml.get(), true)) + { + backend.persist (*xml); + desiredXml = std::move (xml); + parseDesired(); + } +} + +void AudioDeviceMonitor::enterWaiting() +{ + staleCount = 0; + pollCount = 0; + fastRetries = retryCooldown = 0; + if (backend.currentDevice().open) + backend.closeDevice(); + setState (State::waiting, desiredDisplayName()); +} + +void AudioDeviceMonitor::attemptRestore() +{ + if (desiredXml == nullptr || ! hasDesired()) + { + setState (State::inactive, {}); + return; + } + + restoring = true; + const auto error = backend.attemptOpenDesired (*desiredXml); + restoring = false; + + if (error.isEmpty() && backend.currentDevice().open) + { + // The device may have reopened with adjusted parameters (e.g. an + // unsupported sample rate); resync without touching saved settings. + if (auto xml = backend.stateXml()) + { + desiredXml = std::move (xml); + parseDesired(); + } + lastTicks = backend.ioTicks(); + staleCount = 0; + fastRetries = retryCooldown = 0; + setState (State::active, desiredDisplayName()); + } + else + { + setState (State::waiting, desiredDisplayName()); + } +} + +void AudioDeviceMonitor::onChangeEvent() +{ + if (restoring) + return; + + const auto device = backend.currentDevice(); + + switch (current.state) + { + case State::active: { + // Also runs when the device just closed: if the user selected + // "no device" the new state has empty names and we go inactive + // rather than trying to reopen it. + updateDesiredFromBackend(); + + if (! hasDesired()) + { + setState (State::inactive, {}); + break; + } + + if (! desiredPresent (false)) + enterWaiting(); + else if (! device.open) + enterWaiting(); // closed externally; the poll retries reopening + else + setState (State::active, desiredDisplayName()); + break; + } + + case State::waiting: { + if (device.open) + { + // A user-chosen setup updates the manager's explicit + // settings; adopt it. A device opened behind our back does + // not, and gets closed to keep the no-substitution policy. + updateDesiredFromBackend(); + if (matchesDesired (device)) + { + lastTicks = backend.ioTicks(); + staleCount = pollCount = 0; + setState (State::active, desiredDisplayName()); + } + else + { + backend.closeDevice(); + } + break; + } + + // Never blind-restore an event-driven type from a generic change + // broadcast: its cached list always claims the device exists, and + // a failed open stalls the message thread for seconds. + if (backend.reconnectPolicy (desiredType) != ReconnectPolicy::eventDriven + && desiredPresent (false)) + attemptRestore(); + break; + } + + case State::inactive: { + if (device.open) + { + updateDesiredFromBackend(); + if (hasDesired()) + setState (State::active, desiredDisplayName()); + } + break; + } + } +} + +void AudioDeviceMonitor::onHardwareEvent() +{ + if (restoring) + return; + + if (current.state == State::waiting + && ! backend.currentDevice().open + && backend.reconnectPolicy (desiredType) == ReconnectPolicy::eventDriven) + { + if (retryCooldown > 0) + return; // a recent attempt already failed; the fast retries cover it + if (! desiredPresent (false)) + return; + + attemptRestore(); + + if (current.state == State::waiting) + { + // The attempt failed; the driver may just need a moment after + // the OS announced arrival. Retry a few times at the fast + // cadence before falling back to the safety net. + retryCooldown = reconnectPollTicks; + fastRetries = hardwareEventRetries; + pollCount = 0; + } + return; + } + + // All other states and policies: same handling as a manager change. + onChangeEvent(); +} + +void AudioDeviceMonitor::onTimerTick() +{ + switch (current.state) + { + case State::active: { + const bool errored = backend.lastDeviceError().isNotEmpty(); + const auto device = backend.currentDevice(); + const auto ticks = backend.ioTicks(); + const bool frozen = device.open && device.playing && ticks == lastTicks; + lastTicks = ticks; + + if (errored) + staleCount = juce::jmax (staleCount + 1, staleTicksBeforeConfirm); + else if (frozen) + ++staleCount; + else + staleCount = 0; + + if (staleCount >= staleTicksBeforeForce) + { + // The stream is dead even though the device list still + // claims the device exists (stale ALSA cache, dead ASIO + // driver still registered). + enterWaiting(); + } + else if (staleCount >= staleTicksBeforeConfirm) + { + // Only pollPresence lists can actually report absence; + // registry/cached lists always claim the device exists. + if (backend.reconnectPolicy (desiredType) == ReconnectPolicy::pollPresence + && ! desiredPresent (true)) + enterWaiting(); + } + break; + } + + case State::waiting: { + if (backend.currentDevice().open) + break; // change event pending; onChangeEvent decides + + if (retryCooldown > 0) + --retryCooldown; + + const auto policy = backend.reconnectPolicy (desiredType); + const int interval = policy == ReconnectPolicy::eventDriven && fastRetries <= 0 + ? safetyNetPollTicks + : reconnectPollTicks; + if (++pollCount < interval) + break; + pollCount = 0; + + switch (policy) + { + case ReconnectPolicy::pollPresence: + if (desiredPresent (true)) + attemptRestore(); + break; + case ReconnectPolicy::pollBlind: + attemptRestore(); // a failed open of a missing device fails fast + break; + case ReconnectPolicy::eventDriven: + // Fast retry after a hardware event, else the slow + // safety net in case a notification was missed. + if (fastRetries > 0) + --fastRetries; + attemptRestore(); + break; + } + break; + } + + case State::inactive: + break; + } +} + +void AudioDeviceMonitor::onResume() +{ + staleCount = 0; + lastTicks = backend.ioTicks(); + + if (current.state == State::waiting && ! backend.currentDevice().open) + { + pollCount = 0; + // One attempt on wake is acceptable even for event-driven types. + const auto policy = backend.reconnectPolicy (desiredType); + if (policy != ReconnectPolicy::pollPresence || desiredPresent (true)) + attemptRestore(); + } +} + +} // namespace element diff --git a/src/services/devicemonitor.hpp b/src/services/devicemonitor.hpp new file mode 100644 index 000000000..7e17245b8 --- /dev/null +++ b/src/services/devicemonitor.hpp @@ -0,0 +1,166 @@ +// Copyright 2026 Kushview, LLC +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include +#include + +namespace element { + +/** Tracks the audio device's health and owns disconnect/reconnect policy. + + A pure state machine with no direct AudioDeviceManager dependency: all + interaction with the real device stack goes through the Backend interface + so the logic can be unit tested with a fake. + + Policy: when the desired device disconnects, close it and wait — never + auto-select a replacement. When the desired device reappears, restore it + with the last saved setup. User-initiated setup changes are persisted + immediately. How reconnection is attempted depends on the device type's + ReconnectPolicy: by polling presence, by polling opens when presence is + undetectable but cheap to try, or — where a failed open stalls the + message thread (ASIO) — only on platform hardware events plus a slow + safety-net poll. + + All methods must be called from the message thread. +*/ +class AudioDeviceMonitor +{ +public: + enum class State + { + inactive, //!< No device desired (user selected none, or plugin mode) + active, //!< Desired device is open and running + waiting //!< Desired device unavailable; waiting for it to return + }; + + struct Status + { + State state { State::inactive }; + juce::String deviceName; + }; + + /** Snapshot of the currently open audio device, if any. */ + struct DeviceInfo + { + bool open { false }; + bool playing { false }; + juce::String typeName; + juce::String inputName, outputName; + }; + + /** How the monitor tries to reconnect a lost device of a given type. */ + enum class ReconnectPolicy + { + pollPresence, //!< Presence is detectable by rescanning; poll it, open when found. + pollBlind, //!< Presence undetectable but a failed open is cheap (ALSA). + eventDriven //!< Presence undetectable and a failed open blocks the message + //!< thread for seconds (ASIO); open only on hardware events + //!< plus a slow safety-net poll. + }; + + /** Bridge to the real device manager, engine, and settings. */ + struct Backend + { + virtual ~Backend() = default; + + /** Returns info about the currently open device. */ + virtual DeviceInfo currentDevice() = 0; + + /** Returns true if the named device exists in the type's hardware + list. Rescans the type first when rescan is true. */ + virtual bool isDevicePresent (const juce::String& typeName, + const juce::String& deviceName, + bool rescan) = 0; + + /** Returns the reconnect policy for a device type. */ + virtual ReconnectPolicy reconnectPolicy (const juce::String& typeName) = 0; + + /** Tries to open the device described by a DEVICESETUP element. + @return an empty string on success, otherwise the error */ + virtual juce::String attemptOpenDesired (const juce::XmlElement& setupXml) = 0; + + /** Closes the current audio device. */ + virtual void closeDevice() = 0; + + /** Returns the device manager's current explicit settings, or + nullptr if none exist. */ + virtual std::unique_ptr stateXml() = 0; + + /** Persists device settings so they survive an unclean shutdown. */ + virtual void persist (const juce::XmlElement& xml) = 0; + + /** Returns the engine's audio IO callback counter. */ + virtual uint64_t ioTicks() = 0; + + /** Returns and clears the last mid-stream device error, if any. */ + virtual juce::String lastDeviceError() = 0; + }; + + /** Ticks of frozen audio callbacks before checking device presence. */ + static constexpr int staleTicksBeforeConfirm = 4; + /** Ticks of frozen audio callbacks before forcing a disconnect even if + the (possibly stale) device list still claims the device exists. */ + static constexpr int staleTicksBeforeForce = 8; + /** Timer ticks between reconnect attempts while waiting. */ + static constexpr int reconnectPollTicks = 3; + /** Timer ticks between safety-net reconnect attempts for event-driven + types, in case a hardware notification was missed. */ + static constexpr int safetyNetPollTicks = 30; + /** Fast retries (at reconnectPollTicks cadence) after a hardware event + whose restore attempt failed — drivers often need a moment after + the OS announces arrival. */ + static constexpr int hardwareEventRetries = 2; + + explicit AudioDeviceMonitor (Backend& backendToUse); + + /** Adopts the saved device settings and evaluates the initial state. + Never attempts to open a device. */ + void seed (std::unique_ptr savedXml); + + /** Call (deferred) when the device manager broadcasts any change. */ + void onChangeEvent(); + + /** Call (deferred) when the platform reported audio hardware arrival or + removal. Unlike onChangeEvent, this may attempt a restore even when + presence cannot be verified (event-driven policy). */ + void onHardwareEvent(); + + /** Call about once per second from a timer. */ + void onTimerTick(); + + /** Call when the system wakes from sleep. */ + void onResume(); + + Status status() const { return current; } + + /** Fires on the message thread whenever the status changes. */ + Signal sigStatusChanged; + +private: + Backend& backend; + Status current; + std::unique_ptr desiredXml; + juce::String desiredType, desiredInput, desiredOutput; + bool restoring { false }; + uint64_t lastTicks { 0 }; + int staleCount { 0 }; + int pollCount { 0 }; + int fastRetries { 0 }; + int retryCooldown { 0 }; + + bool hasDesired() const noexcept; + juce::String desiredDisplayName() const; + void parseDesired(); + bool desiredPresent (bool rescan); + bool matchesDesired (const DeviceInfo& device) const; + void updateDesiredFromBackend(); + void attemptRestore(); + void enterWaiting(); + void setState (State newState, const juce::String& deviceName); +}; + +} // namespace element diff --git a/src/services/deviceservice.cpp b/src/services/deviceservice.cpp index 935ce4285..7f69cefae 100644 --- a/src/services/deviceservice.cpp +++ b/src/services/deviceservice.cpp @@ -1,18 +1,223 @@ // Copyright 2023 Kushview, LLC // SPDX-License-Identifier: GPL-3.0-or-later +#include + +#include +#include +#include +#include +#include + #include "services/deviceservice.hpp" namespace element { -class DeviceService::Impl +namespace { + +/** Bridges the monitor to the live device manager, engine, and settings. */ +class ContextBackend : public AudioDeviceMonitor::Backend +{ +public: + explicit ContextBackend (Context& ctx) : context (ctx) {} + + AudioDeviceMonitor::DeviceInfo currentDevice() override + { + AudioDeviceMonitor::DeviceInfo info; + auto& devices = context.devices(); + if (auto* const device = devices.getCurrentAudioDevice()) + { + const auto setup = devices.getAudioDeviceSetup(); + info.open = true; + info.playing = device->isPlaying(); + info.typeName = device->getTypeName(); + info.inputName = setup.inputDeviceName; + info.outputName = setup.outputDeviceName; + } + return info; + } + + bool isDevicePresent (const juce::String& typeName, + const juce::String& deviceName, + bool rescan) override + { + return context.devices().isDevicePresent (typeName, deviceName, rescan); + } + + AudioDeviceMonitor::ReconnectPolicy reconnectPolicy (const juce::String& typeName) override + { + // ASIO's scan only reads the registry, so absence is undetectable, + // and a failed open blocks the message thread for seconds. + if (typeName == "ASIO") + return AudioDeviceMonitor::ReconnectPolicy::eventDriven; + // ALSA's device list is scanned once and cached for the lifetime of + // the type object, so absence can never be re-detected there — but a + // failed open of a missing device fails fast. + if (typeName == "ALSA") + return AudioDeviceMonitor::ReconnectPolicy::pollBlind; + return AudioDeviceMonitor::ReconnectPolicy::pollPresence; + } + + juce::String attemptOpenDesired (const juce::XmlElement& xml) override + { + auto& devices = context.devices(); + + juce::AudioDeviceManager::AudioDeviceSetup setup; + const auto legacyName = xml.getStringAttribute ("audioDeviceName"); + if (legacyName.isNotEmpty()) + { + setup.inputDeviceName = setup.outputDeviceName = legacyName; + } + else + { + setup.inputDeviceName = xml.getStringAttribute ("audioInputDeviceName"); + setup.outputDeviceName = xml.getStringAttribute ("audioOutputDeviceName"); + } + + setup.bufferSize = xml.getIntAttribute ("audioDeviceBufferSize", setup.bufferSize); + setup.sampleRate = xml.getDoubleAttribute ("audioDeviceRate", setup.sampleRate); + setup.inputChannels.parseString (xml.getStringAttribute ("audioDeviceInChans", "11"), 2); + setup.outputChannels.parseString (xml.getStringAttribute ("audioDeviceOutChans", "11"), 2); + setup.useDefaultInputChannels = ! xml.hasAttribute ("audioDeviceInChans"); + setup.useDefaultOutputChannels = ! xml.hasAttribute ("audioDeviceOutChans"); + + const auto typeName = xml.getStringAttribute ("deviceType"); + if (typeName.isNotEmpty() && typeName != devices.getCurrentAudioDeviceType()) + devices.setCurrentAudioDeviceType (typeName, true); + + return devices.setAudioDeviceSetup (setup, true); + } + + void closeDevice() override { context.devices().closeAudioDevice(); } + + std::unique_ptr stateXml() override + { + return context.devices().createStateXml(); + } + + void persist (const juce::XmlElement& xml) override + { + auto& settings = context.settings(); + if (auto* const props = settings.getUserSettings()) + props->setValue (Settings::devicesKey, &xml); + settings.saveIfNeeded(); + } + + uint64_t ioTicks() override + { + if (auto engine = context.audio()) + return engine->ioCallbackTicks(); + return 0; + } + + juce::String lastDeviceError() override + { + if (auto engine = context.audio()) + return engine->lastDeviceErrorMessage(); + return {}; + } + +private: + Context& context; +}; + +} // namespace + +class DeviceService::Impl : private juce::Timer, + private juce::AsyncUpdater, + private juce::ChangeListener { public: Impl (DeviceService& o) : owner (o) {} - ~Impl() {} + ~Impl() { detach(); } + + void attach() + { + if (devices != nullptr) + return; + + auto& context = owner.context(); + backend = std::make_unique (context); + monitor = std::make_unique (*backend); + + statusConnection = monitor->sigStatusChanged.connect ( + [this] (const AudioDeviceMonitor::Status& status) { owner.sigAudioDeviceStatus (status); }); + listChangedConnection = context.devices().sigDeviceListChanged.connect ( + [this]() { hardwareEvent = true; triggerAsyncUpdate(); }); + + // Kept for detach: the device manager outlives the services in + // Context teardown, but context() itself is not reachable there. + devices = &context.devices(); + devices->addChangeListener (this); + + std::unique_ptr savedXml; + if (auto* const props = context.settings().getUserSettings()) + savedXml = props->getXmlValue (Settings::devicesKey); + monitor->seed (std::move (savedXml)); + + startTimer (1000); + } + + void detach() + { + stopTimer(); + cancelPendingUpdate(); + hardwareEvent = false; + listChangedConnection.disconnect(); + statusConnection.disconnect(); + + if (devices != nullptr) + { + devices->removeChangeListener (this); + devices = nullptr; + } + + monitor.reset(); + backend.reset(); + } + + AudioDeviceMonitor::Status status() const + { + return monitor != nullptr ? monitor->status() : AudioDeviceMonitor::Status(); + } + + void resumed() + { + if (monitor != nullptr) + monitor->onResume(); + } private: - [[maybe_unused]] DeviceService& owner; + void changeListenerCallback (juce::ChangeBroadcaster*) override + { + // Defer: change messages can arrive while the device manager is + // still inside its own callback stack. + triggerAsyncUpdate(); + } + + void handleAsyncUpdate() override + { + const bool hardware = std::exchange (hardwareEvent, false); + if (monitor == nullptr) + return; + if (hardware) + monitor->onHardwareEvent(); + else + monitor->onChangeEvent(); + } + + void timerCallback() override + { + if (monitor != nullptr) + monitor->onTimerTick(); + } + + DeviceService& owner; + std::unique_ptr backend; + std::unique_ptr monitor; + SignalConnection statusConnection, listChangedConnection; + DeviceManager* devices { nullptr }; + bool hardwareEvent { false }; }; DeviceService::DeviceService() @@ -32,12 +237,27 @@ void DeviceService::activate() // is delivered on the message thread). deviceListConnection = juce::MidiDeviceListConnection::make ( [this] { sigMidiDevicesChanged(); }); + + // Audio device monitoring only applies when Element owns the devices. + if (getRunMode() != RunMode::Plugin) + impl->attach(); } void DeviceService::deactivate() { + impl->detach(); deviceListConnection.reset(); Service::deactivate(); } +AudioDeviceMonitor::Status DeviceService::audioDeviceStatus() const +{ + return impl->status(); +} + +void DeviceService::onResume() +{ + impl->resumed(); +} + } // namespace element diff --git a/src/services/deviceservice.hpp b/src/services/deviceservice.hpp index 9ccae6eb7..2529aa54c 100644 --- a/src/services/deviceservice.hpp +++ b/src/services/deviceservice.hpp @@ -7,6 +7,8 @@ #include #include +#include "services/devicemonitor.hpp" + namespace element { class DeviceService : public Service @@ -22,6 +24,16 @@ class DeviceService : public Service output devices changes, e.g. a controller is plugged in or removed. */ Signal sigMidiDevicesChanged; + /** Fires (on the message thread) when the audio device connection status + changes: opened, disconnected and waiting, or no device selected. */ + Signal sigAudioDeviceStatus; + + /** Returns the current audio device connection status. */ + AudioDeviceMonitor::Status audioDeviceStatus() const; + + /** Call when the system wakes from sleep to re-evaluate the audio device. */ + void onResume(); + private: class Impl; friend class Impl; diff --git a/src/ui/content.cpp b/src/ui/content.cpp index 89b3521a4..dae461ed0 100644 --- a/src/ui/content.cpp +++ b/src/ui/content.cpp @@ -7,10 +7,13 @@ #include #include #include +#include #include #include +#include #include +#include "services/deviceservice.hpp" #include "services/mappingservice.hpp" #include "services/sessionservice.hpp" #include "ui/midiblinker.hpp" @@ -243,6 +246,21 @@ class Content::StatusBar : public Component, private Timer { public: + /** Label that invokes a handler when double-clicked. */ + class ClickableLabel : public Label + { + public: + std::function onDoubleClicked; + + void mouseDoubleClick (const MouseEvent& ev) override + { + if (onDoubleClicked) + onDoubleClicked(); + else + Label::mouseDoubleClick (ev); + } + }; + StatusBar (Context& g) : world (g), devices (world.devices()), @@ -255,6 +273,16 @@ class Content::StatusBar : public Component, addAndMakeVisible (streamingStatusLabel); addAndMakeVisible (statusLabel); + statusLabel.setTooltip ("Double-click to open audio settings"); + statusLabel.onDoubleClicked = [this]() { + if (auto* const ui = world.services().find()) + ui->showPreferencesDialog (ELEMENT_AUDIO_SETTINGS_NAME); + }; + + if (auto* const deviceService = world.services().find()) + deviceStatusConnection = deviceService->sigAudioDeviceStatus.connect ( + [this] (const AudioDeviceMonitor::Status&) { updateLabels(); }); + const Font font (FontOptions (12.0f)); for (int i = 0; i < getNumChildComponents(); ++i) @@ -273,6 +301,7 @@ class Content::StatusBar : public Component, ~StatusBar() { + deviceStatusConnection.disconnect(); latencySamplesChangedConnection.disconnect(); sampleRate.removeListener (this); streamingStatus.removeListener (this); @@ -341,7 +370,22 @@ class Content::StatusBar : public Component, { sampleRateLabel.setText ("N/A", dontSendNotification); streamingStatusLabel.setText ("N/A", dontSendNotification); - statusLabel.setText ("No Device", dontSendNotification); + + AudioDeviceMonitor::Status deviceStatus; + if (auto* const deviceService = world.services().find()) + deviceStatus = deviceService->audioDeviceStatus(); + + if (deviceStatus.state == AudioDeviceMonitor::State::waiting + && deviceStatus.deviceName.isNotEmpty()) + { + statusLabel.setText (String ("Disconnected: ") + deviceStatus.deviceName, + dontSendNotification); + } + else + { + statusLabel.setText ("No Device", dontSendNotification); + } + statusLabel.setColour (Label::textColourId, Colors::toggleRed); } @@ -362,11 +406,13 @@ class Content::StatusBar : public Component, DeviceManager& devices; PluginManager& plugins; - Label sampleRateLabel, streamingStatusLabel, statusLabel; + Label sampleRateLabel, streamingStatusLabel; + ClickableLabel statusLabel; ValueTree node; Value sampleRate, streamingStatus, status; SignalConnection latencySamplesChangedConnection; + SignalConnection deviceStatusConnection; friend class Timer; void timerCallback() override diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 7f2329bb2..221d3692e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -64,6 +64,7 @@ add_test(NAME "AuthTests" COMMAND test_element --run_test=AuthTests) add_test(NAME "BytesTest" COMMAND test_element --run_test=BytesTest) add_test(NAME "JuceIntegrationTests" COMMAND test_element --run_test=JuceIntegrationTests) add_test(NAME "DataPathTests" COMMAND test_element --run_test=DataPathTests) +add_test(NAME "DeviceMonitorTests" COMMAND test_element --run_test=DeviceMonitorTests) add_test(NAME "DSPScriptTest" COMMAND test_element --run_test=DSPScriptTest) add_test(NAME "Element" COMMAND test_element --run_test=Element) add_test(NAME "GraphNodeTests" COMMAND test_element --run_test=GraphNodeTests) diff --git a/test/DeviceMonitorTests.cpp b/test/DeviceMonitorTests.cpp new file mode 100644 index 000000000..3d9a173ce --- /dev/null +++ b/test/DeviceMonitorTests.cpp @@ -0,0 +1,552 @@ +// SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "services/devicemonitor.hpp" + +using element::AudioDeviceMonitor; +using juce::String; +using juce::XmlElement; + +namespace { + +std::unique_ptr makeSetup (const String& type, const String& input, const String& output) +{ + auto xml = std::make_unique ("DEVICESETUP"); + xml->setAttribute ("deviceType", type); + xml->setAttribute ("audioInputDeviceName", input); + xml->setAttribute ("audioOutputDeviceName", output); + return xml; +} + +struct FakeBackend : public AudioDeviceMonitor::Backend { + AudioDeviceMonitor::DeviceInfo device; + bool present { true }; + AudioDeviceMonitor::ReconnectPolicy policy { AudioDeviceMonitor::ReconnectPolicy::pollPresence }; + String openError; + std::unique_ptr state; + std::unique_ptr persisted; + uint64_t ticks { 0 }; + String deviceError; + + int closeCalls { 0 }; + int openCalls { 0 }; + int persistCalls { 0 }; + int rescans { 0 }; + + AudioDeviceMonitor::DeviceInfo currentDevice() override { return device; } + + bool isDevicePresent (const String&, const String&, bool rescan) override + { + if (rescan) + ++rescans; + return present; + } + + AudioDeviceMonitor::ReconnectPolicy reconnectPolicy (const String&) override { return policy; } + + String attemptOpenDesired (const XmlElement& xml) override + { + ++openCalls; + if (openError.isNotEmpty()) + return openError; + + device.open = true; + device.playing = true; + device.typeName = xml.getStringAttribute ("deviceType"); + device.inputName = xml.getStringAttribute ("audioInputDeviceName"); + device.outputName = xml.getStringAttribute ("audioOutputDeviceName"); + state = std::make_unique (xml); + return {}; + } + + void closeDevice() override + { + ++closeCalls; + device = {}; + } + + std::unique_ptr stateXml() override + { + return state != nullptr ? std::make_unique (*state) : nullptr; + } + + void persist (const XmlElement& xml) override + { + ++persistCalls; + persisted = std::make_unique (xml); + } + + uint64_t ioTicks() override { return ticks; } + + String lastDeviceError() override + { + auto message = deviceError; + deviceError.clear(); + return message; + } + + // Puts the fake in "device open and running" condition matching xml. + void openFromSetup (const XmlElement& xml) + { + device.open = true; + device.playing = true; + device.typeName = xml.getStringAttribute ("deviceType"); + device.inputName = xml.getStringAttribute ("audioInputDeviceName"); + device.outputName = xml.getStringAttribute ("audioOutputDeviceName"); + state = std::make_unique (xml); + } +}; + +struct MonitorFixture { + FakeBackend backend; + AudioDeviceMonitor monitor { backend }; + std::vector emitted; + element::SignalConnection connection; + + MonitorFixture() + { + connection = monitor.sigStatusChanged.connect ( + [this] (const AudioDeviceMonitor::Status& status) { emitted.push_back (status); }); + } + + ~MonitorFixture() { connection.disconnect(); } + + void seedActive (const String& type = "TestType", + const String& input = "Test Device", + const String& output = "Test Device") + { + auto xml = makeSetup (type, input, output); + backend.openFromSetup (*xml); + monitor.seed (std::move (xml)); + } + + void makeWaiting() + { + seedActive(); + backend.present = false; + monitor.onChangeEvent(); + BOOST_REQUIRE (monitor.status().state == AudioDeviceMonitor::State::waiting); + } + + // Event-driven types (ASIO) never report absence, so the only way into + // waiting is the frozen-callback watchdog. + void makeWaitingEventDriven() + { + backend.policy = AudioDeviceMonitor::ReconnectPolicy::eventDriven; + seedActive(); + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeForce; ++i) + monitor.onTimerTick(); + BOOST_REQUIRE (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_REQUIRE_EQUAL (backend.openCalls, 0); + } +}; + +} // namespace + +BOOST_AUTO_TEST_SUITE (DeviceMonitorTests) + +BOOST_FIXTURE_TEST_CASE (SeedWithOpenDeviceIsActive, MonitorFixture) +{ + seedActive(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Test Device"); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + BOOST_CHECK_EQUAL (backend.closeCalls, 0); + BOOST_CHECK_EQUAL (backend.persistCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (SeedWithAbsentDeviceWaitsWithoutOpening, MonitorFixture) +{ + backend.present = false; + monitor.seed (makeSetup ("TestType", "Gone Device", "Gone Device")); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Gone Device"); + BOOST_CHECK_EQUAL (backend.openCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (SeedWithoutSettingsIsInactive, MonitorFixture) +{ + monitor.seed (nullptr); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::inactive); +} + +BOOST_FIXTURE_TEST_CASE (DisconnectClosesOnceAndWaits, MonitorFixture) +{ + seedActive(); + backend.present = false; + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); + BOOST_CHECK_EQUAL (backend.persistCalls, 0); + + // Repeated change events while gone don't close or open again. + monitor.onChangeEvent(); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); + BOOST_CHECK_EQUAL (backend.openCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (ReconnectRestoresDesiredDevice, MonitorFixture) +{ + makeWaiting(); + backend.present = true; + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + BOOST_CHECK (backend.device.open); + // Reconnecting must not rewrite saved settings. + BOOST_CHECK_EQUAL (backend.persistCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (UserChangePersistsImmediately, MonitorFixture) +{ + seedActive(); + auto changed = makeSetup ("TestType", "Other Device", "Other Device"); + backend.openFromSetup (*changed); + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.persistCalls, 1); + BOOST_REQUIRE (backend.persisted != nullptr); + BOOST_CHECK_EQUAL (backend.persisted->getStringAttribute ("audioOutputDeviceName").toStdString(), + "Other Device"); + + // The same settings again do not persist twice. + monitor.onChangeEvent(); + BOOST_CHECK_EQUAL (backend.persistCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (ForeignDeviceClosedWhileWaiting, MonitorFixture) +{ + makeWaiting(); + const auto closesBefore = backend.closeCalls; + + backend.device.open = true; + backend.device.playing = true; + backend.device.typeName = "TestType"; + backend.device.inputName = backend.device.outputName = "Wrong Device"; + + monitor.onChangeEvent(); + BOOST_CHECK_EQUAL (backend.closeCalls, closesBefore + 1); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.openCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (WatchdogTripsWhenCallbacksFreeze, MonitorFixture) +{ + seedActive(); + backend.present = false; // hardware gone but no list event (e.g. ASIO) + + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeConfirm; ++i) + monitor.onTimerTick(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); + BOOST_CHECK (backend.rescans > 0); +} + +BOOST_FIXTURE_TEST_CASE (WatchdogResetsWhenCallbacksAdvance, MonitorFixture) +{ + seedActive(); + backend.present = false; + + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeConfirm * 3; ++i) { + backend.ticks += 100; // stream healthy + monitor.onTimerTick(); + } + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.closeCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (WatchdogForcesDisconnectDespiteStaleList, MonitorFixture) +{ + seedActive(); + backend.present = true; // stale list still claims the device exists + + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeForce; ++i) + monitor.onTimerTick(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (DeviceErrorTripsWatchdogImmediately, MonitorFixture) +{ + seedActive(); + backend.present = false; + backend.deviceError = "CoreAudio error"; + monitor.onTimerTick(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (PollRestoresWhenDeviceReturns, MonitorFixture) +{ + makeWaiting(); + + // Still gone: polls check presence but never open. + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks * 2; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + + backend.present = true; + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (UndetectablePresencePollsByOpening, MonitorFixture) +{ + seedActive(); + backend.policy = AudioDeviceMonitor::ReconnectPolicy::pollBlind; // ALSA-style + backend.present = true; + backend.openError = "device busy"; + + // Force-disconnect via frozen callbacks. + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeForce; ++i) + monitor.onTimerTick(); + BOOST_REQUIRE (monitor.status().state == AudioDeviceMonitor::State::waiting); + + // While the open fails, keep waiting and retrying. + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + + backend.openError.clear(); + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 2); +} + +BOOST_FIXTURE_TEST_CASE (SelectingNoDeviceGoesInactive, MonitorFixture) +{ + seedActive(); + backend.device = {}; + backend.state = makeSetup ("TestType", "", ""); + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::inactive); + + // No reconnect attempts while inactive. + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks * 2; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (SelectingDeviceWhileInactiveActivates, MonitorFixture) +{ + monitor.seed (nullptr); + BOOST_REQUIRE (monitor.status().state == AudioDeviceMonitor::State::inactive); + + auto chosen = makeSetup ("TestType", "New Device", "New Device"); + backend.openFromSetup (*chosen); + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.persistCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (ResumeRestoresWhileWaiting, MonitorFixture) +{ + makeWaiting(); + backend.present = true; + monitor.onResume(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (UserPickAdoptedWhileWaiting, MonitorFixture) +{ + makeWaiting(); + const auto closesBefore = backend.closeCalls; + + // A user pick goes through setAudioDeviceSetup (treatAsChosenDevice), + // so the manager's explicit settings reflect the new device. + auto chosen = makeSetup ("TestType", "Other Device", "Other Device"); + backend.openFromSetup (*chosen); + monitor.onChangeEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Other Device"); + BOOST_CHECK_EQUAL (backend.closeCalls, closesBefore); + BOOST_CHECK_EQUAL (backend.persistCalls, 1); + BOOST_REQUIRE (backend.persisted != nullptr); + BOOST_CHECK_EQUAL (backend.persisted->getStringAttribute ("audioOutputDeviceName").toStdString(), + "Other Device"); + + // A later disconnect waits for the adopted device, not the old one. + backend.present = false; + monitor.onChangeEvent(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Other Device"); +} + +BOOST_FIXTURE_TEST_CASE (NoRestoreAttemptWhilePickPending, MonitorFixture) +{ + makeWaiting(); + backend.policy = AudioDeviceMonitor::ReconnectPolicy::pollBlind; + const auto closesBefore = backend.closeCalls; + + // The user picked a device but its change event hasn't run yet. + auto chosen = makeSetup ("TestType", "Other Device", "Other Device"); + backend.openFromSetup (*chosen); + + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks * 2; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + BOOST_CHECK_EQUAL (backend.closeCalls, closesBefore); + + monitor.onResume(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + + // The deferred change event then adopts the pick. + monitor.onChangeEvent(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Other Device"); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenDoesNotBlindPoll, MonitorFixture) +{ + makeWaitingEventDriven(); + + for (int i = 0; i < AudioDeviceMonitor::safetyNetPollTicks - 1; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenSafetyNetEventuallyPolls, MonitorFixture) +{ + makeWaitingEventDriven(); + + for (int i = 0; i < AudioDeviceMonitor::safetyNetPollTicks; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenRestoresOnHardwareEvent, MonitorFixture) +{ + makeWaitingEventDriven(); + monitor.onHardwareEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + BOOST_CHECK_EQUAL (backend.persistCalls, 0); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenFailedEventAttemptCoolsDownThenRetries, MonitorFixture) +{ + makeWaitingEventDriven(); + backend.openError = "driver not ready"; + + monitor.onHardwareEvent(); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + + // A burst of events right after the failure is suppressed. + monitor.onHardwareEvent(); + BOOST_CHECK_EQUAL (backend.openCalls, 1); + + // Fast retries run at the normal poll cadence... + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 2); + + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 3); + + // ...then the cadence falls back to the slow safety net. + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks * 2; ++i) + monitor.onTimerTick(); + BOOST_CHECK_EQUAL (backend.openCalls, 3); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenHardwareEventClearsBurstOnSuccess, MonitorFixture) +{ + makeWaitingEventDriven(); + backend.openError = "driver not ready"; + monitor.onHardwareEvent(); + BOOST_REQUIRE_EQUAL (backend.openCalls, 1); + + backend.openError.clear(); + for (int i = 0; i < AudioDeviceMonitor::reconnectPollTicks; ++i) + monitor.onTimerTick(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 2); + + // Once active, no further reconnect attempts happen. + for (int i = 0; i < AudioDeviceMonitor::safetyNetPollTicks; ++i) { + backend.ticks += 100; + monitor.onTimerTick(); + } + BOOST_CHECK_EQUAL (backend.openCalls, 2); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenChangeEventDoesNotRestore, MonitorFixture) +{ + makeWaitingEventDriven(); + + // Generic manager broadcasts (e.g. unrelated settings churn) must not + // trigger an expensive blind open. + monitor.onChangeEvent(); + BOOST_CHECK_EQUAL (backend.openCalls, 0); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenUserPickWorksDespiteCooldown, MonitorFixture) +{ + makeWaitingEventDriven(); + backend.openError = "driver not ready"; + monitor.onHardwareEvent(); + BOOST_REQUIRE (monitor.status().state == AudioDeviceMonitor::State::waiting); + + // A user pick arriving during the cooldown is still adopted. + auto chosen = makeSetup ("TestType", "Other Device", "Other Device"); + backend.openFromSetup (*chosen); + monitor.onHardwareEvent(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (monitor.status().deviceName.toStdString(), "Other Device"); + BOOST_CHECK_EQUAL (backend.persistCalls, 1); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenActiveWatchdogSkipsRescan, MonitorFixture) +{ + backend.policy = AudioDeviceMonitor::ReconnectPolicy::eventDriven; + seedActive(); + + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeConfirm; ++i) + monitor.onTimerTick(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.rescans, 0); + + for (int i = 0; i < AudioDeviceMonitor::staleTicksBeforeForce - AudioDeviceMonitor::staleTicksBeforeConfirm; ++i) + monitor.onTimerTick(); + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::waiting); + BOOST_CHECK_EQUAL (backend.closeCalls, 1); + BOOST_CHECK_EQUAL (backend.rescans, 0); +} + +BOOST_FIXTURE_TEST_CASE (EventDrivenResumeAttemptsOnce, MonitorFixture) +{ + makeWaitingEventDriven(); + monitor.onResume(); + + BOOST_CHECK (monitor.status().state == AudioDeviceMonitor::State::active); + BOOST_CHECK_EQUAL (backend.openCalls, 1); +} + +BOOST_AUTO_TEST_SUITE_END()