The Song Editor plugin for feedBack
(plugin id editor) — a DAW-style timeline editor for building and editing
feedpak arrangements: import Guitar Pro tabs, MIDI, or community XML; sync a
chart to a recording; author and tune every note; and build the result back
into a .feedpak package the rest of the ecosystem plays.
It is the largest plugin in the ecosystem. If you are here to work on it, read this top-to-bottom once — the architecture section saves real time.
- Import — Guitar Pro (GP3–GP8), MIDI (with tempo-map import), community
arrangement XML, MusicXML-adjacent sources, GoPlayAlong sync sidecars, and
existing
.feedpak/archive files, all normalized into one internal model. - Edit — a canvas timeline with per-part views: String view (lanes per string), a piano roll (with resolver-assisted fretted authoring), a drum piece-lane grid, and a read-only engraved tab preview. Undo/redo is the edit contract: every mutation is a command with exec/rollback.
- Time — a beat-primary tempo map (
beatOf/timeOfover one anchor set): Tempo Map mode fits the grid to a recording (sync points, tap tempo, metric modulation, beat-lock), snap rides the same converter (grid, onset, swing), and loop regions/bookmarks/count-in ride the transport. The authoritative musical ruler fits bars and beats to fixed source audio; authored musical content aligns through that ruler. Seedocs/TEMPO-MAPPING-DESIGN.mdfor the four timing domains, marker model, assisted mapping, and audition-speed rules. - Companion strips — a fretboard strip for fretted parts (candidate positions from the suggest-position resolver, click to assign) and a drum kit / pad strip for drum parts (GM-mapped, MIDI-monitor capable).
- Advisory lints — drum limb feasibility and fretted playability checks that name physical questions without ever blocking or auto-fixing.
- Build — assembles the finished
.feedpakthrough the host's core libraries, so output packages are compatible everywhere.
| Path | What |
|---|---|
plugin.json |
Manifest: id, screens, scriptType: "module", routes. |
screen.html / screen.js |
The screen DOM and the module entry stub (src/main.js is the real entry). |
src/ |
The frontend, as native ES modules (see below). |
routes.py |
The FastAPI backend: session store, import-* endpoints, save/build. |
goplayalong.py |
GoPlayAlong sync-sidecar parser. |
tests/ |
node --test suites (JS) + pytest suites (backend helpers). |
assets/ |
Icon and the manifest stylesheet. |
npm test # full JS suite (node --test over tests/)
node --test tests/loop_ab.test.js # a single JS suite
npm run lint # ESLint over src/ + tests/ (via npx — see below)
python -m pytest # backend suite (needs fastapi/pyyaml installed)Two rules that surprise newcomers:
- No
node_modules, ever. The desktop bundler copies this whole directory into the packaged app (stripping only.git), sonpm run lintshells out to a pinnednpx eslintinstead of installing anything. - ESLint's
no-undefwith{ typeof: true }is a correctness gate, not style — it exists to catch identifiers left behind by module moves, including thetypeof xreads that fail silently. Don't weaken it.
Frontend edits show on a browser refresh; routes.py changes need a server
restart. In the packaged app the host serves src/ with live-edit caching.
The frontend is ~50 ES modules under src/, orchestrated by a thin
src/main.js. The load-bearing invariants:
src/state.jsexportsS, the single mutable state object. It is never reassigned — only its properties are. Tests seed it withObject.assign.- Every mutation goes through
S.history(src/history.js) as a command object withexec/rollback.editGenbumps once per committed edit; memos over note data key on it. src/host.jsbreaks import cycles: extracted modules reach the few main.js callbacks they need through one hook table. Anything main.js reassigns must be wired as a thunk — read the warning at the top of that file before adding a hook.src/beats.jsis the single time converter (beatOf/timeOf). Beat coordinates are truth; seconds are derived.TempoMapCmdrecomputes seconds from beats,TempoGridCmdre-lifts beats from seconds.- Modules must degrade under node (no DOM, no host): inert defaults, no import-time side effects — that's how the real-import test suites work.
- Global listeners and timers must register with the teardown registry
(
host.addGlobalListener, tracked viawindow.__editorScreenTeardown): the host re-injects the screen, so anything unregistered leaks across re-injection. New chrome rides the rAF draw-coalesce — no per-frame work. - Kind inference drives a part's entire view (string lanes vs piano roll
vs drum grid): keys > drums > bass > guitar, with
KEYS_PATTERNstart-anchored ("Electric Piano" is not a keys name). - Never compose the capo pair blindly:
_soundingPitchPureadds the capo once (matching core'spitch_from_base);_absolutePitchdeliberately omits it (string-move math). Both carry warning comments and a pinned test. - Transient per-note UI marks live in module
WeakSets, not note fields: an underscore field leaks into the save body on solo notes and vanishes on chord notes (reconstructChordsrebuilds via an explicit field mapper).
The backend (routes.py) keeps a _sessions dict keyed by session id, each
owning an unpacked working directory. Every import format normalizes through
its own endpoint into the same model; /build is the only path that writes
to the user's library. _NOTE_TECH_FIELDS is the single source of truth for
authorable note techniques — new ones go there, not in ad-hoc field lists.
When the editor and the host disagree about a field, the
feedpak spec wins.
New suites are real-import ESM (tests/*.test.mjs): import the actual
modules, seed the real S, stub only the DOM slice you need
(tests/_history_env.mjs is the template for history-driven suites). Older
suites slice functions out of the source text — when touching code they
cover, keep new globals typeof-guarded so their environments stay clean.
House rules: never stub the subject under test; round-trip every command (exec → rollback deep-equality → redo); adversarial inputs; and a stateful change needs a test that fails without it.
Every user-visible change adds a CHANGELOG.md entry under [Unreleased].