Skip to content

Layout hygiene + Piano mapping mode#2

Open
myfunc wants to merge 2 commits into
feat/freeform-panels-audio-enginefrom
fix/layout-hygiene-piano-mapping
Open

Layout hygiene + Piano mapping mode#2
myfunc wants to merge 2 commits into
feat/freeform-panels-audio-enginefrom
fix/layout-hygiene-piano-mapping

Conversation

@myfunc

@myfunc myfunc commented Apr 14, 2026

Copy link
Copy Markdown
Owner

Stacked on top of #1 (feat/freeform-panels-audio-engine).

Two halves

1. Layout hygiene (user report)

Лейаут плохо себя чувствует. Вообще то много Knob Panel A, Knob Panel B.
При перезагрузке появляются старые окна.

Root causes: store subscription opened a floating panel for every entry on handshake → stacked on top of the loaded layout; update_panel cleared old slot without repopulating the new one on bank change; no cleanup for "ghost" panels left by menu clicks that were then closed in dockview. Layout schema bump was dead code.

Fixes: idempotent reconcile_panels() + POST /api/panels/reconcile; deterministic 2x2 grid (no floating); knownPanelIdsRef + layoutReadyRef + one-shot reconcile; explicit Add Pad/Knob Panel A/B menu; PanelHeader auto-syncs template titles; atomic save_config; LAYOUT_SCHEMA_VERSION 2 -> 3 stamped in saveLayout.

2. Piano as freeform panel with play/map banks

Same UX as pad/knob panels — PanelHeader with [Play] [Map] bank selector, preset dropdown, engineering LED activate button.

Play mode — unchanged sample playback. Map mode — keys 36..72 bind to actions (keystroke, plugin action, script, app-open, text-type), dispatched through the same executor pad actions use. Map wins over play; out-of-range falls through to pad dispatch; in-range unmapped on map is swallowed. activate_panel on a piano panel calls Piano.stop_all() so held notes don't get stuck on bank flip.

Config: [[piano_presets]] in config.toml, shape mirrors [[pad_presets]].

PR #1 review feedback

Review on PR #1 flagged 4 must-close items and several easy wins. Addressing them in this PR rather than opening a third, per user request — GitHub shows the cumulative diff.

Must-close

  • _apply_reconfigure preserves FX params via new FXChain.get_state() / apply_state() (snapshot on success + rollback).
  • _CmdShutdown drains the queue and ACKs pending _CmdReconfigure with {ok:False, error:"engine shutting down"} — no more 5 s ghost timeouts.
  • _pitch_cache_lock guards every access (MIDI / HTTP / reconfigure threads).
  • update_panel runs the whole critical section under self._lock; on_mode_changed / notify_preset_changed / event_bus.publish remain outside.

Easy wins

  • Case-insensitive preset comparison in delete_preset / rename_preset is now strict (matches the rest of the codebase).
  • _migrate_panel_presets uses uuid.uuid4().hex[:8] suffixes (consistent with create_panel).
  • Added Mapper.iter_active_panels() public accessor; core.py no longer touches _active_panels / _panel_presets.

Deferred to follow-ups (bigger refactors)

  • Package-relative imports in plugins/piano/*.
  • Pre-allocated scratch buffers in Pitch / Reverb allpass.
  • threading.Event-signaled producer loop instead of 1 ms busy-sleep.

Decisions baked in

Question Decision
Dockview x = delete backend panel? No. Delete is a dedicated button in PanelHeader.
Piano map vs play both active Map wins.
Map range 36..72 only. Pad zone (16..31) unaffected.
Piano presets storage config.toml [[piano_presets]].
Title auto-sync on bank change Yes, only for template titles.
LAYOUT_SCHEMA_VERSION bump Yes, acceptable reset.

Test plan

  • pytest — 124/124 passing (103 -> 124). Suites: test_panel_reconcile.py (6), test_piano_presets.py (3), test_piano_mapping.py (6), test_audio_engine.py (+3 new), test_core_panels.py (+1 new), test_mapper.py (+1 new).
  • npm run build — tsc clean, vite OK.
  • from backend.app import create_app — ok (mandatory after the IndentationError we shipped last round).
  • Review round 1 (score 61 FAIL): Rules-of-Hooks, atomic save_config, full action editor for piano keys, handshake race, WS piano_presets.changed handler, schema stamp, stuck notes on bank switch, createPanelRequest default, no-op PATCH waste, strict label type.
  • PR Freeform pad/knob panels + Piano audio engine (Wave C/D) #1 review (4 must-close + 3 easy wins).

Stacked PR notes

Targets feat/freeform-panels-audio-engine (PR #1). When #1 merges, GitHub retargets this to feature/web-ui automatically.

🤖 Generated with Claude Code

myfunc and others added 2 commits April 14, 2026 01:43
…g ghosts

Addresses user report: "too many Knob Panel A/B; old windows appear on reload."

Root causes:
- The App.tsx store subscription opened a floating panel for every entry
  in panels store on first handshake, double-stacking them over any
  panels already in the loaded layout and over any panels closed by user
  in a previous session.
- update_panel on an active panel with bank-change cleared the old slot
  but did not reactivate on the new slot, so active_panels[pad:A] ended
  up null while pad-A panels existed.
- No cleanup for "ghost" panels created through menu clicks and later
  closed in dockview (dockview close != backend DELETE /api/panels).
- Template titles drifted from bank on A/B toggle (title "Knob Panel B"
  but bank "A").

Backend (core.py, app.py, mapper.py):
- VALID_BANKS dict gates create_panel/update_panel: {'pad':('A','B'),
  'knob':('A','B'),'piano':('play','map')}.
- New reconcile_panels(fix_titles: bool) method: clears stale active-
  slot refs, assigns unique live panels to empty slots, optionally
  rewrites titles that match the template regex while preserving
  custom titles. Called automatically after _migrate_panel_presets in
  bootstrap(); exposed as POST /api/panels/reconcile so users can
  heal existing broken state without restarting.
- update_panel: when the panel being edited was active on the old
  bank, the new bank slot is populated (auto-activate on empty; leave
  alone if occupied) so no panel becomes a silent orphan.
- save_config (mapper.py) is now atomic: write tempfile -> fsync ->
  os.replace(), preventing TOML corruption on concurrent writers.

Frontend (App.tsx, PanelHeader.tsx, useLayoutPersistence.ts):
- LAYOUT_SCHEMA_VERSION 2 -> 3; saveLayout stamps schemaVersion on
  writes and loadLayout discards saved layouts without a matching
  version. filterOrphanPanelRefs strips panel ids that aren't in the
  current store (keeping utility panels properties/log/settings).
- onReady builds a deterministic 2x2 grid (pad A/B top, knob A/B
  below, properties+log right). No floating ever.
- Store subscription no longer open-by-default: uses knownPanelIdsRef
  + layoutReadyRef so first handshake doesn't re-spawn panels that
  are already part of the layout (or that the user intentionally
  closed). A one-shot reconcile pass right after layoutReady adds any
  truly-new panels that arrived during the async gap.
- Menu now exposes explicit A/B choices (Add Pad Panel A/B, Add Knob
  Panel A/B) instead of a single "Add Pad Panel" that always created
  bank A.
- PanelHeader bank switch rewrites title iff the current title matches
  the template regex; custom user titles are preserved.

feat(piano): freeform panel with play/map banks + per-preset mapping

Piano is now a first-class freeform panel with two banks:
- play: original sample playback behavior unchanged.
- map: each key in the MPK keyboard range (36..72) can be bound to
  an action (keystroke, plugin action, script, app-open, text-type),
  dispatched through the same executor as pad actions. Out-of-range
  notes fall through to pad dispatch; in-range notes on the active
  map panel are swallowed (no audio fallthrough).

Backend (core.py, mapper.py, config.toml):
- VALID_BANKS['piano'] = ('play', 'map'); create_panel/update_panel
  accept type='piano'.
- PIANO_MAP_NOTE_MIN=36 / MAX=72, PianoKeyMapping + PianoPreset
  dataclasses, AppConfig.piano_presets round-tripped by load_config /
  save_config.
- [[piano_presets]] section in config.toml (Default preset shipped
  empty; commented example shows the shape).
- Mapper.lookup_piano_key(preset_name, note) -> mapping | None.
- core.handle_piano_note(note, velocity, on): map wins over play, no-op
  if neither is active. Called from _handle_event_locked before the
  legacy plugin_manager.on_pad_press fallback.
- activate_panel on a piano panel triggers Piano.stop_all() under
  core._lock so notes held on play don't get stuck when the user flips
  to map mid-press (and vice versa).

API (app.py):
- GET/POST /api/piano/presets, GET/DELETE /api/piano/presets/{name},
  PATCH /api/piano/presets/{name}/keys/{note} (validates [36..72],
  strict label type check, early-return on no-op patches, GCs empty
  entries). All writes hold core._lock across save_config to prevent
  interleaved truncate+rewrite.
- /api/piano/note + /off accept bank in body; bank='map' routes
  through handle_piano_note, otherwise the existing play flow.
- piano_presets.changed event published on every mutation.

Frontend (types.ts, stores, PanelHeader.tsx, PianoPanel.tsx,
PropertiesPanel.tsx, WebSocketProvider.tsx):
- PanelType gains 'piano'; PianoBank='play'|'map'; ActivePanelKey
  union includes 'piano:play'/'piano:map'; PianoKeyMapping +
  PianoPreset types.
- useAppStore: pianoPresets + fetchPianoPresets + updatePianoKey
  (optimistic PATCH). createPanelRequest default bank derives from
  type (piano -> 'play', else 'A'). selectedPianoNote integrated
  with Properties selection.
- PanelHeader: bank options are type-driven ([Play][Map] for piano),
  preset dropdown pulls from pianoPresets for piano panels.
- PianoPanel split into guard-wrapper + PianoPanelInner (Rules-of-
  Hooks: early-return now precedes no hook calls). Play mode
  unchanged. Map mode renders the 36..72 range with label overlays,
  click-to-select, and dispatches through /api/piano/note bank='map'
  (no audio engine path).
- PropertiesPanel: PianoKeyPropertiesView with the same action
  picker the pad editor uses — keystroke keys, plugin_action target,
  script process, open-app, type-text — persisted via updatePianoKey.
- WebSocketProvider: handles piano_presets.changed + pulls
  piano_presets out of the handshake payload so multi-tab stays in
  sync.
- Menu: Add Piano Panel (Play) / (Map) entries alongside Pad/Knob
  A/B entries.

Tests: 103 -> 119. New suites:
- tests/test_panel_reconcile.py (6): stale slot, empty-slot
  assignment, template-only title rewrite, idempotence, healthy
  no-op, ambiguous warning.
- tests/test_piano_presets.py (3): TOML load, lookup, missing.
- tests/test_piano_mapping.py (6+): map -> executor, play -> plugin,
  map wins when both active, no-active-no-op, out-of-range
  fallthrough, map swallows unmapped in-range.

Review round 1 findings addressed before commit:
- Rules-of-Hooks crash in PianoPanel (split into wrapper + inner).
- Non-atomic save_config (tempfile+fsync+os.replace; piano
  endpoints hold core._lock across save).
- Piano action picker could only set action type (now full
  per-type editor matching pads).
- Handshake race where panels arriving before layoutReady were
  never opened (one-shot reconcile after layoutReady).
- Missing piano_presets.changed WS handler.
- LAYOUT_SCHEMA_VERSION bump was dead code (now stamped in saveLayout).
- Stuck notes on bank switch (Piano.stop_all() on activate).
- createPanelRequest default bank='A' invalid for piano.
- PATCH piano key created-then-GC'd empty entries on no-op patches.
- PATCH accepted non-string label silently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Applying the 4 "must close before merge" findings and 3 easy wins from
the PR #1 review. Added in this branch (PR #2) per user request since
PR #1 is the parent — GitHub shows cumulative diff.

Must-close
- `audio_engine._apply_reconfigure` preserved FX params: `FXChain.apply_state(state)` added; the engine snapshots via `get_state()` before rebuilding the chain and restores both on the success path and the rollback path. `_stream_error` is now also cleared on successful rollback so stale errors don't bleed through.
- Shutdown deadlock on pending reconfigure: `_handle_command(_CmdShutdown)` now drains the remaining queue, and any `_CmdReconfigure` gets `{ok: False, error: "engine shutting down"}` + `done.set()` before the producer exits. No more 5 s ghost timeouts.
- `_pitch_cache` thread safety: introduced `self._pitch_cache_lock` in `PianoPlugin.__init__`, guards every `.get` / `[]=` / `.clear()` access (MIDI thread, HTTP thread, reconfigure thread).
- `update_panel` lock asymmetry: full critical section (settings read/write, mapper routing, legacy mirror, snapshot) now runs under `self._lock`. `plugin_manager.on_mode_changed`, `notify_preset_changed`, and `event_bus.publish` still fire outside the lock to avoid reentrancy into plugin code.

Easy wins
- Removed `.lower()` from preset-name comparisons in `delete_preset` / `rename_preset` — matches the case-sensitive semantics used everywhere else.
- `_migrate_panel_presets` now generates panel IDs with `uuid.uuid4().hex[:8]` suffix (consistent with `create_panel`), eliminating the collision risk from `int(time()*1000)+index`.
- Added public `Mapper.iter_active_panels()` returning a defensive copy; `core.py` no longer reaches into `self.mapper._active_panels` / `_panel_presets` directly.

Not in this commit (intentional)
- Package-relative imports in `plugins/piano/*`, pre-allocated scratch in `Pitch`/`Reverb`, `threading.Event`-signaled producer loop — bigger refactors, tracked as follow-ups in PR #1.
- Frontend `.replace('#', '#')` concern — current code already renders bare `{key.name}`; no change needed.

Tests (+5, 119 -> 124, all green)
- `test_reconfigure_preserves_fx_state`: sets non-default reverb/volume params, reconfigures to 48 kHz, asserts params preserved.
- `test_shutdown_acks_pending_reconfigure`: two queued reconfigures during shutdown both get `ok=False, error='engine shutting down'`, no 5 s hang.
- `test_pitch_cache_thread_safe`: 4 threads interleave put/get/clear, no `RuntimeError`.
- `test_update_panel_holds_lock_for_full_critical_section`: lock-acquire counter around `settings.put` asserts zero out-of-lock writes during `update_panel(bank=, preset=)`.
- `test_mapper_iter_active_panels_is_copy`: mutating the returned dict does not leak into live state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to 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