Skip to content

feat(editor): author per-note bend shape (bt + bnv) (PR-C) - #13

Merged
byrongamatos merged 1 commit into
mainfrom
feat/bend-shape-editor
Jun 20, 2026
Merged

feat(editor): author per-note bend shape (bt + bnv) (PR-C)#13
byrongamatos merged 1 commit into
mainfrom
feat/bend-shape-editor

Conversation

@byrongamatos

Copy link
Copy Markdown
Collaborator

What

PR-C (final) of the per-note bend-shape feature (feedpak spec §6.2.1). The editor authored only the scalar bend peak (bn); this adds authoring of the bend intent (bt) and the time-stamped bend curve (bnv), round-tripping through the wire shape. Builds on PR-A (#531, wire+import) and PR-B (#532, highway render).

Changes

Backend — routes.py

  • _arr_dict_to_wire._note emits bt (default-omit when 0) and bnv (via the new _safe_bend_curve, default-omit when empty), matching core's note_to_wire. Chord member notes inherit through _note_in_chord.
  • _NOTE_TECH_FIELDS gains bend_intent (drives the generic load _tech_dict + the content signature). bend_values is deliberately kept out of that tuple — a list would break the hashable content-signature tuples (_obj_note_sig/_dict_note_sig) — and is carried explicitly in _tech_dict, so an imported/authored curve loads back into the editor.

Frontend — screen.js

  • A bend modal (_editorBendModal): peak input, intent dropdown, and an interactive drag-point curve editor — left-click adds a point, drag moves it, right-click removes it; x = time across the note, y = semitones — plus intent presets and a clear button.
  • promptBend opens it for the right-clicked note (or the whole selection when it's part of it) and commits via SetBendShapeCmd (one undo step; snapshot/restore of bn/bt/bnv).
  • Inspector gains a bend-intent dropdown (SetBendIntentCmd — changes only bt, so a multi-select edit doesn't flatten each note's distinct peak/curve) and an "Edit curve…" button.
  • Pure helpers bendPresetCurve (intent→curve) and sanitizeBendCurve (empty → null, never []; no magnitude clamp, mirroring core).

Scope note

Per the agreed decision, this covers the wire/sloppak round-trip only. The archive-XML path (_note_attrs_xml) still emits the scalar bend as before — a bend-curve XML representation is a follow-up.

Testing

  • tests/bend_shape.test.js (new) — preset generation for all five intents + curve sanitization (drop-bad, sort, round, empty→null).
  • tests/test_xml_export.py — wire emit, default-omit, sanitization, chord-note inheritance, and the _NOTE_TECH_FIELDS field-set invariant.
  • Editor pytest62 passed, 2 skipped; all JS test files pass; node --check screen.js clean.
  • Codex preflight (read-only): no P1/P2 on the authoring round-trip.

Caveat

Live UI verification of the curve-editor modal was not performed (it needs the running editor in a browser). Coverage here is the pure-helper unit tests + backend wire round-trip tests + node --check. Worth driving the modal in a build to confirm the canvas interactions feel right.

Part of got-feedBack/feedBack#334

🤖 Generated with Claude Code

PR-C of the bend-shape feature (feedpak §6.2.1). The editor authored only the
scalar bend peak; this adds the bend intent (bt) and the time-stamped bend
curve (bnv), round-tripping through the wire shape.

Backend (routes.py):
- `_arr_dict_to_wire._note` emits `bt` (default-omit when 0) and `bnv` (via the
  new `_safe_bend_curve`, default-omit when empty), matching core's note_to_wire.
  Chord member notes inherit through `_note_in_chord`.
- `_NOTE_TECH_FIELDS` gains `bend_intent` (drives the generic load `_tech_dict`
  + content signature). `bend_values` is deliberately kept OUT of that tuple — a
  list would break the hashable content-signature tuples — and carried
  explicitly in `_tech_dict` instead, so an imported/authored curve loads back.

Frontend (screen.js):
- A bend modal (`_editorBendModal`): peak input, intent dropdown, and an
  interactive drag-point curve editor (left-click adds, drag moves, right-click
  removes; x = time across the note, y = semitones), plus intent-presets.
- `promptBend` opens it for the right-clicked note (or the whole selection) and
  commits via `SetBendShapeCmd` (one undo step, snapshot/restore of bn/bt/bnv).
- Inspector gains a bend-intent dropdown (`SetBendIntentCmd` — changes only bt
  so a multi-select edit doesn't clobber each note's peak/curve) and an
  "Edit curve…" button.
- Pure helpers `bendPresetCurve` / `sanitizeBendCurve` (empty -> null, never []).

Tests: tests/bend_shape.test.js (preset generation + sanitization) and
tests/test_xml_export.py (wire emit, default-omit, sanitization, chord-note
inheritance, field-set invariant). Editor pytest 62 passed; all JS tests pass;
node --check screen.js clean.

Part of got-feedBack/feedBack#334

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos
byrongamatos merged commit 8224269 into main Jun 20, 2026
@byrongamatos
byrongamatos deleted the feat/bend-shape-editor branch June 20, 2026 21:47
byrongamatos added a commit that referenced this pull request Jun 21, 2026
…6.2.2) (#16)

Author the three optional per-note teaching marks, mirroring the bend-shape
authoring (#13). Display only — these never affect grading.

- screen.js: a @pure:teaching-marks block (FRET_FINGER_OPTIONS +
  nextUnusedStrumGroup) and SetTeachingMarkCmd (one undoable batch edit per
  field, snapshot/rollback per note). Inspector gains a fret-hand-finger picker
  (fg, -1..4), a scale-degree override field (sd, -1..11; blank/-1 = auto), and
  Group/Ungroup-as-strum buttons (ch — Group assigns the next unused key across
  the selection, Ungroup clears it). All routed through S.history.exec.
- routes.py: fret_finger/strum_group/scale_degree join _NOTE_TECH_FIELDS so they
  load (_tech_dict) and feed the content signature; the wire serializer emits
  fg/ch/sd default-omitted (matching core), and the chart-XML export writes
  fretFinger (core's _parse_note reads it). getattr-over-fields sites default to
  -1 so a core build predating the marks doesn't break load/alignment.

Tests: tests/teaching_marks.test.js (@pure extract — FRET_FINGER_OPTIONS +
nextUnusedStrumGroup edge cases); test_xml_export.py round-trip for fg/ch/sd
through the wire + fretFinger through chart XML, plus default-omit assertions.

Part of got-feedBack/feedBack#334

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos added a commit that referenced this pull request Jun 21, 2026
…eservation (feedpak-spec §8) (#12)

* feat(editor): read + write .jsonc arrangements (feedpak-spec §8)

feedpak-spec §8 (FEP #3 / PR #13) allows .jsonc arrangements (JSON with
C-style // line and /* */ block comments). The editor reads the existing
arrangement on save to preserve anchors/handshapes/phrases/tones, and re-writes
the arrangement file — so both sides need comment handling for .jsonc.

Read path (routes.py, the save_song preserve-on-save read):
- _parse_jsonc(text) + _load_arrangement_json(path): string-aware strip regex
  (mirrors the spec reference validator + core lib/jsonc.py); .jsonc by suffix,
  plain .json through json.loads. Replaces the json.loads(...read_text...) at
  the preserve-on-save read site.

Write path (routes.py, the arrangement file write in save_song):
- _extract_jsonc_top_comments + _preserve_jsonc_comments: capture header,
  before-top-level-key, trailing (before closing }), and footer comments from
  the original .jsonc text and re-insert them into the freshly serialized
  (indent=2) JSON. Phase-1 scope: top-level comments only — comments inside
  nested arrays/objects (e.g. inside notes[]) are dropped, the documented
  limitation (the editor replaces note content, so re-anchoring nested
  comments to the right note is unreliable). A phase-2 swap can replace
  _preserve_jsonc_comments without touching call sites.
- .jsonc writes serialize indent=2 (so comments land at top-level-key line
  boundaries); .json writes stay byte-identical (compact separators=(',',':')).
- Orphan cleanup now also globs *.jsonc so a removed .jsonc arrangement
  doesn't linger on disk.

Tests: tests/test_jsonc_save.py (18 tests) — _parse_jsonc unit cases,
_load_arrangement_json (.jsonc/.json/anchors-through-comments),
_extract_jsonc_top_comments (header/footer/before-key/trailing/nested-drop),
_preserve_jsonc_comments round-trips (full preservation, removed-key drop,
identity-no-comments, nested-drop), and a combined read→modify→write round
trip simulating save_song on a .jsonc arrangement (comments survive at top
level, preserved anchors survive in data, edited note count correct). 75
editor tests pass (2 skipped, pre-existing).

* feat(editor): preserve fully-nested .jsonc comments on save (phase 2)

Phase 1 preserved only top-level comments (header / before-key / trailing /
footer) and dropped anything inside nested arrays/objects. Phase 2 preserves
nested comments too, on a best-effort basis: each comment is anchored to a
structural identity in the original and re-inserted at the matching spot in
the freshly serialized (indent=2) JSON.

Anchoring (routes.py, replacing the phase-1 top-level extractor):
- Header / footer comments re-insert at the file head / tail.
- A comment before a key (at any depth) anchors to (*path, 'before-key', key)
  and re-inserts on its own line before the key, indented to match.
- A comment before an array element anchors to (*path, 'before-elem', sig)
  where sig is the element's content signature: objects with a numeric 't'
  key anchor by t=<json value> (the primary key for notes/beats/sections/
  anchors — stable across edits to the element's OTHER fields); other objects
  anchor by a compact sorted-JSON hash; scalars/strings anchor by their JSON
  rendering. Because the anchor is content identity (not index), the comment
  follows the element across reorders / additions / removals of other
  elements. Editing the anchored element's t (or any field of a t-less
  element) changes its signature and the comment drifts or drops — the
  documented best-effort limit (a hand-author who nests a comment deep inside
  an array of notes owns the consequence of editing that note).
- A trailing comment (before a closing }/]) anchors to (*path, 'trailing')
  and re-inserts one indent level deeper than the close bracket (matching the
  element/key indent, as a hand-author writes it). Empty containers rendered
  inline ([]/{}) are expanded so a // line comment doesn't eat the close
  bracket ([ // c ] strips to [ , which is invalid).
- A comment between : and a value (rare) anchors to (*path, 'before-value',
  key) and re-inserts inline after :.

Path components for array-element levels are element signatures (not
indices), so a comment nested inside an array element (e.g. a before-key
comment on a note object inside notes[]) re-anchors correctly even when notes
are added/removed before it.

Implementation: a single-pass JSONC scanner (_jsonc_skip_ws_comments,
_jsonc_read_string, _jsonc_read_scalar, _jsonc_find_matching) shared by the
build pass (_jsonc_extract_comments / _jsonc_process_obj / _jsonc_process_arr)
and the re-emit pass (_jsonc_collect_obj / _jsonc_collect_arr), plus
_jsonc_elem_signature and _preserve_jsonc_comments. _parse_jsonc /
_load_arrangement_json (the read helpers) are unchanged.

Tests: tests/test_jsonc_save.py rewritten for phase 2 (26 tests) —
parse_jsonc / load_arrangement_json unit cases; _jsonc_extract_comments
anchor structure (header/footer/before-key/trailing/before-elem/nested-
before-key/nested-trailing); top-level round trips; nested round trips
(before-key inside a note survives a non-t field edit; before-elem survives
unchanged; comment follows element across an insertion; comment drops when
the anchored note's t is edited; deeply nested t-less chord-note comment
survives unchanged / drops when edited; trailing in nested array at element
indent; empty-array trailing expands); and the combined read->modify->write
save round trip with a nested before-key comment surviving. 83 editor tests
pass (2 skipped, pre-existing).

* fix(editor): never let .jsonc comment preservation corrupt a save (#12 review)

Post-review of the .jsonc save path: _preserve_jsonc_comments could emit text
that no longer parses (an inline comment between a key's ':' and value yields
`"capo": // pickme 0,`, which strips to invalid JSON), and the write site had no
try/except, no fallback, and no round-trip validation — so a comment-placement
edge silently wrote an unreadable arrangement (data loss).

- New _jsonc_save_text(original, wire): the single writer for .jsonc arrangements.
  Re-inserts comments best-effort but NEVER returns text that fails to parse back
  to `wire` — on any throw or round-trip mismatch it falls back to the plain
  comment-less pretty JSON. A dropped comment is fine; an unreadable file is not.
  The save site now delegates to it.
- New _jsonc_inline_comment(c): renders a `//` line comment for INLINE placement
  as a CLOSED `/* … */` block comment. The old `c.replace("//","/*",1)` dropped
  the closing `*/`, producing an unterminated comment that ate the rest of the
  file; both inline-fallback sites now use this.
- Removed the stale "phase-1 limitation, nested dropped" write-site comment
  (nested preservation is implemented).

+9 tests: the guard always round-trips (incl. the previously-corrupting
before-value case → safe), safe comments are still preserved, and the inline
renderer closes line comments. 35 jsonc tests + full editor suite pass.

---------

Co-authored-by: byrongamatos <xasiklas@gmail.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