Skip to content

Refactor the plugin to use Elgato's node SDK. - #17

Merged
zv1n merged 30 commits into
mainfrom
meach/fix-dial-desync
Aug 13, 2026
Merged

Refactor the plugin to use Elgato's node SDK.#17
zv1n merged 30 commits into
mainfrom
meach/fix-dial-desync

Conversation

@zv1n

@zv1nzv1n commented Aug 13, 2026

Copy link
Copy Markdown
Contributor
  • Complete refactor to new SDK.
  • Migrates any settings changes fro older versions.
  • Fixes Meld Studio reconnect idempotency.
  • Swaps icon state to match the third party icon state mapping.

zv1nand others added 28 commits April 18, 2026 00:13
After a WebSocket reconnect $MS.meld is replaced by a fresh QWebChannel
object, so registerTrackObserver (and any other per-channel wiring) was
lost. Outbound RPCs kept working, but inbound gainUpdated signals
stopped, leaving dials showing stale names/values. The base class also
leaked a $MS.on('ready') listener on every didReceiveSettings delivery,
so over time reconnects replayed an ever-growing stack of stale
callbacks.
- Move the 'ready' reinit listener to a single per-instance handler in
MeldStudioPlugin that iterates the current contexts with current
settings, instead of accumulating one per settings delivery.
- Clean up this.contexts on willDisappear and guard didReceiveSettings
so phantom contexts aren't re-registered on reconnect.
- Default onReady is now a no-op; sessionChanged is already re-emitted
right after ready, so the old session-wide call was redundant.
- VolumeStepper: drop its own didReceiveSettings override (base handles
it), hoist the maybeUnregister willDisappear handler, align onReady
with the base signature, and reset trackInfo to defaults when a track
is missing from the session so stale names can't persist.
connectGain was wiping trackInfo on every reconnect, causing the dial
to flash 0%/-60dB and unmuted before the next gainUpdated arrived. Only
seed defaults when no prior state exists.
This should prevent streamdeck controls from getting out of sync with Meld.
Replace the legacy SDKv2 browser-hosted architecture with Elgato's
official @elgato/streamdeck Node SDK, running the whole plugin as a
single long-lived Node process. This structurally fixes the chronic
state/reconnect/hydration/dial-desync bug class:
- Single QWebChannel connection to Meld shared by all actions and PIs,
with a centralized reconnect + track-observer registry (src/meld/).
- One authoritative session snapshot (MeldSession) as the source of truth.
- Declarative MeldAction.render() lifecycle replacing the race-prone
willAppear/getSettings/hydrateAll chain.
- Optimistic-value-with-TTL reconciliation for dials/toggles; the
authoritative state always wins (the dial-desync fix).
- Property inspectors are thin and hold no Meld connection; they request
session data from the plugin via ui-bridge.ts.
All 14 action UUIDs and visual assets are preserved for seamless upgrade.
Manifest moves to Node 24 runtime, SDKVersion 3, MinimumVersion 7.1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four adversarially-verified review rounds over the Node SDK migration,
fixing every confirmed finding:
State correctness:
- Add DisableAutomaticStates to all two-state actions and invalidate the
state/image dedupe caches on key press, so key icons can never stick
wrong after presses, forced toggles, or disconnected presses.
- Volume dial: echo-safe optimistic reconciliation (epsilon guard so
echoes of older setGain calls can't revert newer values), session-
snapshot gain fallback, no-op instead of slamming volume when gain is
unknown, observer cache cleared on disconnect / track unbind, mute
from the fast gainUpdated path wins over the slower snapshot, and
optimistic expiry repaints authoritative state.
- Transition dial: optimistic staged-scene selection that survives
echoes, id-keyed rotation anchor that survives scene reorders and
transitions, and blip immunity across every render entry point.
- Show Scene: unconfirmed presses self-correct instead of staying lit.
- Record/stream keys hold their last state through connection blips.
- Toggle layer/effect validate the scene->layer->effect parent chain
before rendering state or sending commands.
Process robustness:
- Contain exceptions in QWebChannel message dispatch, response handling,
emitter listeners, per-action renders, and Stream Deck sends; one bad
frame or throwing subscriber can no longer kill the plugin process.
- A handshake failure closes the socket into the backoff reconnect path
instead of leaving a silent zombie connection; backoff only resets
after a fully completed handshake.
- Builds without the track-observer feature (gainUpdated signal or
register/unregisterTrackObserver) degrade to snapshot-only gain.
PI and packaging:
- Parent dropdown changes clear downstream selections atomically; stale
chains render "[No Selection]" with empty child lists.
- Remove Nodejs.Debug from the production manifest.
- install.sh purges stale legacy files (--delete) and restarts the
plugin so deploys take effect.
Tests: new suite covering OptimisticStore expiry semantics, TypedEmitter
listener isolation, and dB/gain math; TypedEmitter decoupled from the
SDK so tests stay pure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tools/fake-meld: a stand-in for Meld Studio's WebChannel server that
the plugin cannot tell apart from the real app, plus preloadable
scenarios. `npm run fake-meld` serves one with Meld closed, and keyboard
controls switch projects, drop the connection, or flip streaming and
recording — states that are slow or awkward to stage by hand.
The server mirrors Meld/Utils/StreamDeckController.qml (API version 2,
all twenty methods, the gainUpdated signal, and the behaviour behind
them) and the snapshot shapes in Meld/StreamDeckMapper.cpp. The test
suite now drives the same server, so the tool and the tests cannot drift.
Reading the Meld source corrected two assumptions and exposed two bugs:
- A track in the session snapshot carries no `gain`; gain arrives only
via gainUpdated, which registerTrack emits immediately on
registration. Replay's volume controls read the (always absent)
snapshot gain and so stepped from an assumed 0, slamming the replay
clip to near-silence instead of nudging it — a bug inherited from the
legacy plugin. Replay now observes the track like the volume dial
does, and refuses to step until a gain is known. The equivalent dead
fallbacks in volume-stepper are gone.
- Scenes carry `vertical` (property inspectors filter on it), layers
carry `isEffectivelyVisible` and `text`, and `setClientName` exists.
The types now match the source of truth.
Connection fixes:
- dispose() (and any teardown that detaches the socket handlers) left
`ready` true and `meld` set, describing a transport that was gone. A
later connect() would then find a stale ready flag, which also
disarmed the handshake watchdog and could wedge the plugin against a
silent peer forever. Teardown now records the disconnect.
- The watchdog's `|| this.#ready` guard could only ever suppress a fire
that was needed, never a spurious one; the generation check already
covers that. Removed.
- The handshake timeout and reconnect delay are injectable, so the
watchdog is exercised in tests rather than waited out.
Tests: the connection suite now covers instance replacement, observer
re-registration, immediate gain on registration, resource-leak-free
churn, and recovery from both a stalled peer and a wrong peer — the last
two asserting a redial actually reaches the bad peer, since asserting
"not ready" alone passed even with the watchdog removed. Verified by
mutation: disabling the watchdog or the teardown fix fails these tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit shipped `meld.sessionChanged.connect(() => {})` — an
empty callback left behind by a review agent's mutation experiment. The
line was correct in de0f0ac and b658958 and silently neutered in f56af3e;
the audit that followed grepped for mutation markers, which an empty
arrow function does not match.
The effect was severe and quiet: the plugin connected, hydrated once, and
then froze. Nothing driven by `sessionChanged` — scene switches, layer
and effect toggles, mute and cue state, project loads — ever repainted
again until a reconnect. Streaming and recording kept working, which is
what made it look healthy.
No test caught it because nothing exercised a live session update: the
connection suite only asserted on the connect-time snapshot, and no test
drove an action's render lifecycle at all.
Adds the coverage that closes that gap:
- test/connection.test.ts asserts a session change made in Meld reaches
listeners and updates the snapshot, not merely that hydration worked.
- test/session-toggle.test.ts drives real ToggleLayer/ToggleEffect/
ToggleMute actions end-to-end against the fake Meld: a key follows the
session after a press, a legacy profile's stale scene/layer chain still
toggles the intended item with the real parent chain derived from the
session, and a press for an item this instance lacks neither sends nor
paints.
- test/volume-stepper.test.ts covers the dial reconciliation rules,
including the desync scenario itself — the fake Meld can now delay its
`gainUpdated` echo (echoDelayMs), so an update for an older write
arrives while a newer one is displayed.
- test/session.test.ts covers the MeldSession selectors.
Both regressions are mutation-verified: reintroducing the empty callback
fails two suites, and removing the gain echo guard fails the desync test.
Also fixes a defect the selector tests found: `has()` used `in` and
`get()` indexed directly, so ids like "constructor" or "toString"
resolved through Object.prototype and conjured items that do not exist.
Lookups are own-property checks now.
Supporting changes: MeldAction takes an optional connection so tests can
point actions at a fake Meld (production still uses the singleton), and
the test loader compiles TypeScript rather than relying on Node's type
stripping, which cannot parse the SDK's decorators.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An expert panel over the action and property-inspector layer independently
confirmed the sessionChanged regression fixed in 05e6284 and found four
more real defects.
Plugin fixes:
- base.ts: setKeyState/setKeyImage read the dedupe cache before the send
and wrote it after, so two updates arriving in one event-loop turn could
drop the newer state — the key latched on the older value with a cache
that agreed, and nothing repainted it. The cache slot is now claimed
before the await and rolled back only if the send fails and no newer
value has claimed it since.
- replay.ts: volume up/down stepped from the gain captured by the last
render. `gainUpdated` refreshes the cache without re-rendering, so a
fader moved in Meld since then was stepped from a stale base. The press
now reads the current value.
- replay.ts: the shared "replay-gain" observer was never released, so it
was re-registered on every reconnect for the lifetime of the process
even with no replay key on screen. It is dropped when the last one goes.
Fake Meld fidelity (a fake that is wrong in the same direction as the
plugin hides the bug):
- setStagedScene now ignores vertical scenes, matching the QML, which
resolves it through Session.scenes only while showScene also searches
verticalScenesModel.
- toggleEffect now honours Meld's `<layerId>-<index>` legacy id fallback,
which the legacy-effect-ids scenario depends on.
Tests (61 total, up from 50):
- test/replay.test.ts: the replay track is observed so its gain is known
before any press, volume steps move by a step rather than collapsing
toward silence, presses read the current gain, and the observer is
released with the last key.
- test/ui-bridge.test.ts: an inspector opening before Meld is up still
gets an answer (connected:false drives its notice), connecting and
session changes push snapshots, and a new Meld instance's items replace
the old ones. registerUiBridge takes an optional connection to allow it.
- test/session-toggle.test.ts and test/volume-stepper.test.ts from the
previous commit round out the action lifecycle coverage the panel
flagged as entirely absent.
Every fix above is mutation-verified: reverting it fails the test that
covers it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ui/lib/meld-pi.js backs the inspectors of 7 of the 14 actions and had no
coverage. Its failures are quiet: a dropdown that lists nothing reads as
"Meld has no scenes" rather than a plugin fault, and a settings chain
left mismatched produces a key that looks configured but does nothing.
test/helpers/fake-dom.ts runs the real file under node:vm against a fake
DOM and property-inspector socket. The <select> semantics are reproduced
faithfully — assigning a value no option carries leaves selectedIndex at
-1, and the first appended option is selected automatically — because
meld-pi.js keys its stale-id handling off exactly that rule; a looser
fake would hide the bugs the tests exist to catch.
18 tests cover every binding pattern the inspectors use: root and chained
selects, the keepFilter show-scene applies to vertical scenes, child
lists staying empty until a parent is chosen, the atomic downstream clear
when a parent changes, legacy profiles' stale ids falling back to the
placeholder without unfiltering the select below, repopulation when a new
Meld instance publishes different ids, bindField defaults and writes,
watchConnection, malformed frames, and binding ids the markup omits.
Mutation-verified: dropping the downstream clear, the stale-id fallback,
or the parent guard each fails the covering tests.
Suite is now 79 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A review over the code, the tests and the harnesses confirmed 19 issues.
Most were tests that did not test what they claimed — the review
mutation-tested each one and reported which broke nothing.
Plugin fixes:
- transition-stepper: the dial offered vertical-canvas scenes. Meld
resolves setStagedScene through its horizontal scene model only (unlike
showScene, which also searches the vertical one), so staging one is a
silent no-op: the dial showed a selection it could not reach and, on
press, transitioned somewhere else. Vertical twins are excluded now.
- media: the key repainted from an empty session on disconnect, flipping
a playing clip to the play icon for the whole reconnect. Every sibling
action already held its visual through a blip; this one did not.
- show-scene: a press lit the key without checking the scene still exists,
faking a confirmation for an id from a previous Meld instance.
- toggle-effect: profiles written before effects had stable ids store them
as <layerId>-<index>. Meld never publishes that form in the snapshot —
it only accepts it as a toggleEffect argument — so the missing-item
guard reported those effects absent and left the keys permanently dead
after an upgrade, where the legacy plugin worked. That id form resolves
now.
Harness fidelity (a fake that errs the same way as the plugin hides the
bug):
- The legacy-effect-id scenario had the contract backwards, publishing
<layerId>-<index> as a snapshot key. Effects are published under real
ids; legacyEffectId() builds the form a profile stores.
- showStagedScene swaps unconditionally, as the QML does. The previous
early return absorbed exactly the state the vertical-scene bug reached.
- Vertical scenes share index numbering with horizontal ones, as Meld's
two separate loops produce.
- The fake can now emit gainUpdated without touching the snapshot, which
is real timing rather than a shortcut: Meld emits it immediately while
the snapshot is republished behind a debounced save.
Tests rewritten to observe the rule they name, and mutation-verified:
- "a matching echo confirms" passed with the confirmation deleted, since
both values render identically; it now asserts the consequence, that a
later external change repaints at once.
- The mute test now makes the fast path and the snapshot disagree.
- Force mode now drives ToggleLayer, which has no direct setter and so
actually reaches the idempotency branch ToggleMute skipped.
- The blank-stepsize test now checks the gain moved, not just that a call
happened; a tautological assertion was removed.
- The wrong-peer test uses a connection whose watchdog cannot rescue it.
- New coverage for the expiry repaint, the transition dial, media and
show-scene.
Also: test files ran in parallel, so an ephemeral port freed by one could
be claimed by another. The suite is serial now and stable across runs.
91 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second review round returned 8 findings, down from 19.
Plugin fixes:
- widget-control: the property inspector offers Wheel Spin -> "Spin it!"
and the plugin ships widget-wheelspin-spin artwork, but the icon map had
no WHEELSPIN_SPIN entry, so the key rendered the stopwatch-reset icon.
The event dispatched correctly; only the label was wrong. Cross-checked
every event the inspector can emit against the map; this was the only gap.
- show-scene: the optimistic light used the default 750ms TTL, but a scene
switch is only confirmed when the session snapshot is republished, and
that rides Meld's 600ms retriggerable autosave. Any concurrent session
edit resets that timer, so the TTL lost the race and the key blinked dark
mid-transition. It now waits 3s, which still self-corrects a press that
never took effect.
Harness fidelity — each of these let a real regression pass:
- toggleLayer/toggleEffect ignored the sceneId/layerId arguments. Meld
validates the whole chain (getSceneById, then scene.layers.findById, then
the effect within that layer), so a caller sending a stale scene was a
no-op there and a success here. Verified: regressing toggle-layer to send
the stored scene now fails the covering test, where before it passed.
The legacy effect-id fallback also matches the QML now — exactly two
dash-separated parts, the index applied to the layer from the arguments,
the embedded prefix ignored.
- The session snapshot can be republished behind a retriggerable debounce
(sessionDebounceMs), modelling the autosave the confirmation actually
rides. Off by default so behavioural tests stay fast.
- FakeDiv starts hidden, as every shipped inspector's connecting notice
does. The previous default made "the notice is showing" true before the
runtime ran, so the assertion could not fail.
Tests:
- watchConnection now asserts the notice is revealed from the hidden state.
- New: a show-scene key holds its light across a retriggered debounce, and
the widget/legacy paths are covered.
- The churn test no longer claims to detect socket leaks it cannot observe;
socket closing is covered by the stalled-peer test's clientCount check.
All fixes mutation-verified. 92 tests, stable across repeated runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 3 returned 2 findings, both harness infidelities and no plugin
defects (19 -> 8 -> 2 across the three rounds).
- FakeSelect started enabled, but every scene/layer/effect dropdown ships
`disabled` in the inspector markup and bindSessionSelects is the only
thing that enables them. A select seeded enabled cannot show that the
binding failed to enable it: deleting `el.disabled = false` left the
whole suite green while, in the real inspector, those dropdowns would be
permanently greyed out and the keys unconfigurable. FakeSelect now
defaults to disabled, matching the markup, and a test asserts binding
flips it.
- callFunction/callFunctionWithArgs were inert in the fake, described as
having no observable session effect. That is true for sendCommand and
friends but wrong for the two Media uses: play/pause move `isPlaying`,
which is published in the layer map and arms the autosave, so a media
press really does come back through the snapshot. The fake echoes it
now, which makes the press path testable at all — a media press had no
coverage, only the initial paint and the reconnect hold. The new test
presses toggle and asserts the key converges on the state Meld reports,
in both directions.
Both mutation-verified: removing the enable line, or inverting the media
icon mapping, fails the covering tests.
94 tests, stable across repeated runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 4 returned 9 findings. They are deeper than round 3's because the
stricter harness can now see further — several were only reachable once
the fake stopped flattering the plugin.
The important one is a regression this branch introduced. Meld serves
session items as a QVariantMap, which Qt serializes sorted by the 32-hex
item id, so snapshot order is unrelated to the order the user sees. The
fake had been serving insertion order, which made the legacy
`<layerId>-<index>` effect resolution added two commits ago look correct:
it indexed into snapshot order, while Meld indexes into its effects
model. On a layer with two or more effects that resolves a *different*
effect, so an upgraded profile would toggle — and display — the wrong
one, where the pre-migration plugin was correct. The id is now forwarded
verbatim for Meld to resolve, as that plugin did, and the fake sorts
items by id and carries the model order separately so the two can
disagree exactly as they do in production.
Because a legacy id names an effect whose state the snapshot cannot
report, SessionToggleAction grew an `actsWithUnknownState` hook: a press
still goes through when the target is identifiable but unreadable,
instead of being suppressed by the missing-item guard.
Other plugin fixes:
- media: `isPlaying` comes from the debounced snapshot, so two toggle
presses inside that window both read the pre-press value and sent the
same command twice. The pending intent now takes precedence.
- media: restart sent seekTo(0), which on a trimmed or looped clip jumps
outside the configured region. It uses skipBack, as Meld's own control
does, which returns to the loop's start point.
Tests, all mutation-verified:
- The legacy-effect test uses three effects so the index actually has to
be resolved correctly; reintroducing snapshot-order resolution fails it.
- Replay's "last key" guard is exercised with two keys mounted — with one
key the branch was unreachable and an unconditional release passed.
- The echo guard is pinned at the regime its docstring names: gain near
the -60 dB floor with a 0.1 dB step, where per-tick deltas are ~1e-5.
Widening GAIN_EPSILON to 1e-2 now fails; before, the whole suite passed.
- New coverage for the media double-press and skip-back.
98 tests, stable across runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The significant one is the dial-desync class returning through a door
this branch never covered.
Meld drops every track-observer registration whenever it tears down its
audio tracks, and restoring or reopening a session does that WITHOUT
closing the socket. Track ids are persisted, so the same ids come back —
and registerTrackObserver deduped on "same context, same track", so the
re-registration the render issued was swallowed and never reached Meld.
No gainUpdated ever arrived again: the dial froze, mute state froze, and
the next tick stepped from the stale gain. It stayed broken until the
socket dropped or the user rebound the track.
Registrations Meld is known to hold are now tracked separately and
forgotten on every session change and disconnect, so the next render
re-registers. The fake already modelled the trigger (loadScenario clears
its observers); nothing had ever driven a dial across it.
Other fixes:
- media: the optimistic playback state had no expiry repaint, so a press
Meld never confirmed left the icon wrong indefinitely.
- transition-stepper: dropped any scene whose name contains "(linked)".
That was a vestige of when Meld suffixed vertical twins that way; Meld
deliberately stopped, so it only discarded scenes a user named that way.
The `vertical` flag is the whole test.
Tests:
- The show-scene "stays lit" test passed with the entire optimistic
mechanism deleted: every session republish in its window already
carried the confirmed state, so render never had to honour a pending
request. It now drives a non-session repaint (a recording change) while
the request is pending, which is the only path that reaches that branch,
and asserts the key is never painted dark.
- The legacy effect-id test used index 1 — the fixed point of the
three-element reversal between model order and id-sorted order, so it
could not tell them apart. Index 0 discriminates.
- New coverage for the session-restore case above.
All mutation-verified. 99 tests, stable across runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 6 returned a single finding, and it masked a real inverted-intent
bug: the fake republished the session instantly, while Meld only rebuilds
the snapshot on `sessionSaved`, behind a ~600ms retriggerable autosave.
SessionToggleAction carried no optimistic state, so a second press inside
that window re-read the pre-press snapshot. Pressing a "show" key twice
sent toggleLayer twice and left the layer hidden — the user asked to show
it and it went dark. Reproduced end-to-end before fixing: two presses
200ms apart, layer ends visible:false.
Toggles now record the value a press is waiting for, reason from it on a
follow-up press, paint it immediately instead of waiting hundreds of
milliseconds for confirmation, and expire back to the session if Meld
never confirms.
The fake's `sessionDebounceMs` now defaults to Meld's real 600ms rather
than publishing instantly, so every test runs at production timing. That
default is what let this bug hide: five existing tests asserted a repaint
within 120ms of a press, which cannot happen against real Meld. They wait
for the confirmation now, or assert the optimistic paint where that is
the point.
New coverage, both mutation-verified: two "show" presses leave the layer
visible, and a toggle press repaints without waiting for the snapshot.
101 tests, stable across runs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 7 findings.
Plugin fix:
- media: #findMediaLayer required the stored scene to still own the layer,
so an upgraded profile holding a scene from a previous selection made
the key silently inert — wrong icon, and every press skipped its
callFunction. Meld resolves callFunction by object id alone and never
consults a scene, so the check added no safety; toggle-layer and
toggle-effect already take the opposite, correct position.
Harness fidelity:
- mount() installed a frozen iterator, so an instance stayed enumerable in
`this.actions` forever. The SDK drops a context from its action store
before the plugin's willDisappear handler runs. The visible set is live
now, with appear()/disappear() helpers that maintain it. This was not
cosmetic: actions stay subscribed to the shared connection for the
process's life, so every dial a suite ever mounted kept re-registering
on later tests' session updates.
Tests that did not test their own subject:
- "set replaces the value and restarts the timer" asserted only the value
and the expiry count, both of which hold if set() keeps the ORIGINAL
timer. That is the invariant a rotation depends on — re-setting on every
tick is what holds the optimistic gain across a long rotation — and it
was unguarded. It now sleeps past the original deadline.
- The session-restore test asserted `registerTrackObserver >= 2` against a
counter never reset between tests; it was already 8 before the restore.
It now resets and asserts exactly one.
- New coverage for the stale-scene media case.
All mutation-verified. 102 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three harness gaps from round 7, each of which let a protocol-level
regression pass unnoticed.
- The fake pushed property updates unconditionally. Qt withholds them
until the client sends `idle`, and un-arms the flag with every batch.
The fake now gates and coalesces the same way, so a client that stopped
acknowledging would visibly stop receiving updates. Verified: removing
the client's idle acknowledgement now fails a test, where before the
whole suite passed.
- `gainUpdated` was broadcast to every socket. Qt only forwards a pure
signal to clients that sent `connectToSignal`, so a client that never
subscribed would receive nothing in production while the fake fed it
everything. Now gated on the subscription — removing the client's
subscribe call fails 10 tests.
- Tests called `onKeyUp` directly, so `onKeyDown` never ran. The app
always delivers both, and `onKeyDown` is what clears the dedupe caches
so a press reasserts the key's visuals. A `press()` helper delivers the
real sequence and every press now goes through it.
102 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last two round-7 findings, both tests that named an invariant without
exercising it.
- The double-press test sampled only the final key state, so a key that
flashed back to "hidden" mid-wait was invisible. TOGGLE_CONFIRM_TTL_MS
was consequently unguarded suite-wide: shortening it 3000 -> 300 left
everything green. The test now asserts the key never goes dark after
lighting, and that mutation fails it with the blink visible in the
painted sequence.
- The replay volume test claimed to protect the unknown-gain guard but
could not reach it: beforeEach waits for the observer's gain to land, so
the gain was always known. A version that pressed with nothing rendered
did not reach it either — it stopped at the trackId check. The track has
to be identified while its gain is still in flight, which needs a
delayed echo; the test now sets one up, and confirms the same press
works once the gain arrives.
103 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erly
Round 8 returned 4 findings, three of them regressions from the previous
two commits.
- The idle gate I added last commit dropped state instead of merging it.
Qt's sendPendingPropertyUpdates folds every pending change for an object
into one frame carrying all changed properties and their notify signals;
mine sent only the newest and discarded the rest. A streaming flag could
be stranded false forever with no further event to correct it, and the
multi-property frame the client has to decode was never produced. It
merges now, and a new test drives three publishes in one turn — under
the old behaviour streamingChanged never fires.
- show-scene's expiry called renderAll, so one key's timeout cleared every
other key's still-pending request and repainted them from the pre-press
snapshot. Only the expired context is repainted now. This was reported
in round 5 and I fixed the sibling cases but missed this one.
- The "never goes dark" assertion added last commit did not pin
TOGGLE_CONFIRM_TTL_MS as its comment claimed: it waited out a single
undisturbed debounce, so anything above ~650ms passed and the whole
650..3000 band was unguarded. It now retriggers the autosave with a burst
of concurrent edits, which is the case the constant exists for; 750 now
fails it with the layer left hidden.
Both new tests needed a second attempt: the first version of each passed
against its own mutation because the scenario could not reach the branch
(one queued frame rather than two; a second key whose scene was already
current).
105 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 9. The notable one is user-facing.
- transition-stepper: the dial press gated only on its own selection, but
setStagedScene silently refuses a scene that is already current, and the
dial's list can be a snapshot behind after a scene switch made elsewhere
in Meld. showStagedScene swaps current and staged unconditionally, so
acting on a refused selection made Meld adopt an unset staged scene and
left the session with no current scene at all. The snapshot now has to
confirm the stage before the press is forwarded.
- media and the shared toggle base kept a pending press keyed by context
only, so retargeting a key within the confirmation window applied the
previous item's pending value to the new one: the key showed the inverse
of reality and the next press moved the property the wrong way.
VolumeStepper already guarded this via onSettingsChanged; both now do.
Harness:
- Staged-scene changes republish immediately. Meld wires
stagedSceneChanged straight to the mapper, so unlike scene/layer/mute
edits it does not ride the autosave. Debouncing it meant no snapshot
ever landed during a rotation, leaving the transition dial's optimistic
reconciliation unreachable from the suite.
- The transition tests waited 40ms for a scenario reload that rides a
600ms debounce, so each test reasoned from the previous test's state.
- "delivers recording updates" asserted `true` when the previous test had
already left recording true, and the fake published a true->true no-op
Qt would never emit. It now drives a real transition in both directions;
hardcoding the emitted boolean fails it.
All mutation-verified. Two of the new tests needed a second attempt: the
staged-guard test first blocked at an earlier guard, and the recording
test needed a genuinely false baseline.
106 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 10.
- media: the key never repainted on a settings change while Meld was
disconnected, so changing the press mode left the previous mode's
artwork until reconnect. For the fixed-icon modes the setting alone
determines the artwork, so that render is now allowed through.
- No test ever delivered didReceiveSettings, leaving the retarget guards
added last round entirely uncovered. A retarget() helper delivers it,
and both media and the toggles now cover a pending press being dropped
when the key is pointed at a different item.
- MEDIA_CONFIRM_TTL_MS was unpinned: 3000 -> 300 left the whole suite
green, while both sibling constants were guarded by a retriggering edit
burst. Media now has the same.
- SessionToggleAction.render's pending-press precedence survived removal,
so a non-session repaint during the wait could light the key back to the
inverse of the press.
- session-toggle's beforeEach waited 40ms for a scenario reload that rides
the 600ms debounce, so every test in the file reasoned from the previous
test's state — the same defect fixed in the transition suite last round.
110 tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Align the plugin manifest, npm package, and lockfile on release version 0.6.4.0.
Document the Node SDK refactor, shared Meld connection, improved reconnect state synchronization, and configurable Record Clip durations from 0 to 90 seconds.
Update the Stream Deck 7.1 requirement and describe the versioned release artifact naming convention.
Cue, Layer Visibility, Effect Visibility, Record, Go Live, Change
Scene, and Mute Track were painting the wrong key state for their
active/inactive condition, which showed custom icon packs backwards.
State 0 is now consistently the active/on icon and state 1 the
inactive/off icon across all toggle actions, with Mute Track's
"active" defined as unmuted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@zv1nzv1n self-assigned this Aug 13, 2026
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@zv1n
zv1n merged commit f8e27ea into mainAug 13, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@zv1n