See the harmony of the compiled song where you work — a chord lane on the Map, hints in the source, a chord chip in the transport, and a Lens panel with a Tonnetz that rewrites chord tokens, a voice-leading view, a passage reharmoniser with key-change pivots and a harmony report — exact where the source names a chord, detected where tones are hand-stacked.
Held from the first release. The Lens panel (Chords, Voices and Passage tabs) has no menu, header or shortcut entry in this release; the Map's harmony lane, the editor's harmony hints, the transport chord chip and the MCP
harmony_report/reharmonisetools remain.
What it is#
Every reading comes from one analysis of the compiled song (volta_compiler::harmony_analysis, fed by ui/src/harmonymodel.rs): a chord read from a chord or roman token (c4:maj7, V7) is exact; a chord named from hand-stacked tones ([a3,c4,e4]) is detected and marked ?; a passage with no confident chord (a dyad, a single line, a rest) is a gap, shown as —, never a guess. Drum and sample events, and externally timed tracks, are not part of it.
- The chord chip sits in the transport strip's second row, just left of the contextual workflow cluster (the main bar's tail is the inline scene jumps'): one quiet line,
Fmaj7 · Imaj7 in F major(exact),Am? · vi in C major(detected),—in a gap. It reads the chord at the playhead; a looping schedule, timeline or loose song is wrapped into its first pass, and a stopped transport reads where the transport says the playhead is (bar 1). The key is the declaredkey = "…", else the detected one. In a narrow bar the key and then the numeral drop; the full reading is the hover. A song with no chord anywhere shows no chip. Click it to open the Lens panel. - The Lens panel (the
lenschip, the Harmonic Lens command, ⌘7 / Ctrl+7, or the chord chip) heads with three readouts at the current position —KEY(declared, plussounds like …when the detected key disagrees),CHORD(name, numeral, exact/detected) andTENSION(the analysis's 0–100 % band) — over four tabs: Tonnetz · Voices · Passage · Report. Voices is the voice-leading view (§ Voices); Passage rewrites a whole passage and pivots at key changes (§ The Passage tab); Report is the harmony doctor (§ Harmony report). Under the readouts sit two rows: SKELETON (§ The harmony skeleton) and MIDI CHORD (§ Play a chord to write it). - An input reading. While notes are held on a MIDI port or the keys strip, the chord chip names them instead —
IN Dm7 · iim7 in C major, drawn selected, its hover saying it is the input, not the song — and goes back to the playhead's chord when the last note comes up. Held input shows the chip even in a song with no chords.
The harmony skeleton#
SKELETON ▶ ALONE · ▶ OVER DRUMS plays the analysed chords as plain block chords on a neutral pad (triangle plus a soft octave, low-passed, slow attack), so the analysis can be checked by ear. Over drums adds every track the Voices tab's drum classifier calls a drum (a sample kit, a synth built on a drum model, or a noise/decay voice playing one pitch). ■ STOP ends it; the row says rendering… / playing, or why it could not.
- Never an edit. The audition is a generated temporary program: the compiled song text with a pad patch and a
harmonySkeletontrack appended (a fresh name if the song uses it), rendered offline through the shared pattern-preview slot and played back. The editor buffer, the compiled module and the transport are untouched; editing the song retires the preview, as it does any pattern preview. - Voicing. Each chord's pitch classes sit in C4–B4 with its bass pitch class below in C3–B3 (C → C3 C4 E4 G4; C/E → E3 C4 E4 G4), held for the chord's analysed span; gaps are silent.
- Range. The Map's selected passage when one is set, else bar 1 to the last chord's end, starting on a whole bar and clamped to what the passage renderer takes (≤ 128 bars, about 56 s at the song's tempo).
- Song shapes. In a loose, bare-
songor scene/arrangesong the skeleton is one pattern track whose cycle spans the chords. A writtentimelineplays only the region tracks it lists, so there the skeleton is one region and the temporary copy lists it first intimeline = [ … ](orstack [ … ]); a timeline that is not a literal list is refused with that reason.
Play a chord to write it#
Hold a chord on a MIDI input or the keys strip: the chord chip names it live (above) and the Lens's MIDI CHORD row reads held Dm7 → iim7, or last … once the keys are up. Naming is the analysis's own detector (three or more pitch classes, the lowest note preferred as the root); a hold is everything pressed from the first note down to the last note up, so a rolled chord reads whole.
- Writing. ⌘⇧H (Ctrl+Shift+H) or the row's WRITE chip writes the held chord — or, with nothing held, the last hold's — at the editor caret as one undo step, and the caret moves past it. Inside a pattern literal the token is spaced from its neighbours; outside one it is written as
!"token". ON RELEASE also writes each chord when its last note comes up — off by default, since lifting the hands is how every melody ends too. - Chord token or numeral. On auto the write is a roman numeral when the song declares a
key, the root is a degree of that key's scale (nob/#prefix) and every chord tone is in the scale — Dm7 in C major writesiim7; anything else writes a chord token rooted at the lowest held instance of the root —d4:m7,d4:majfor D major in C. The form chip cycles AUTO · CHORD · ROMAN to force one (a forced numeral counts chromatic roots asbVII/#iv; without akeyit refuses). The numeral is spelled so the compiler resolves it back to the held chord: lower case plus the table quality (iim7), because a bareii7would be a dominant seventh. A chord token has no inversion, so a slash chord is written in root position.
The Tonnetz tab#
A triangular Tonnetz: pitch classes on a lattice, perfect fifths left to right, major thirds up-right, minor thirds up-left. Each upward triangle is a major triad and each downward one a minor triad, and neighbours that share an edge are one neo-Riemannian move apart. The current chord's triangle is filled in the theme's primary and centred; its candidates are ringed in the secondary and labelled on the lattice and as chips beneath it:
| Mark | Move | From C | From Am |
|---|---|---|---|
P | parallel — keeps the fifth | Cm | A |
L | leading-tone exchange — keeps the minor third | Em | F |
R · relative | relative — keeps the major third | Am | C |
TT | tritone substitute, dominants (7, 9) only | — (G7 → Db7) | — |
R is the relative major/minor, so the relative substitute is the R chip, not a second ring.
Which chord is current. The selected Map harmony block when the Map hands one over (the selected_beat seam, unused until Stage A); else the exact chord token under the editor caret; else the chord at the playhead — and in a gap (a stab pattern resting between hits) the last chord before it, so a candidate stays clickable. The line above the figure names the chord and which of these chose it.
Sevenths and extensions. The lattice holds triads, so a seventh chord lights its triad core (Cmaj7 fills the C triangle; its seventh is a ringed node) and P/L/R act on that core. A rewrite keeps the extension where the chord-quality table has a counterpart: maj7 ↔ m7, 6 ↔ m6, maj9 → m9 and m9 → maj9; a dominant 7/9 becomes m7/m9 on a minor target (there is no minor dominant); add9 drops to the plain triad. The tritone substitute keeps the quality exactly. Diminished, augmented, half-diminished and suspended chords have no triangle: their tones light and the tab says no move applies.
Audition and write-back. Click a ringed triangle or its chip to hear the candidate through the bench chord-pad preview (one UiCommand::Audition per tone through the chord's own track's synth, voiced around the token's root); clicking the lit triangle auditions the current chord. Double-click it, press Enter on its focused chip, or click the ↵ write <token> chip to rewrite the source token as one undo step. The token keeps its affixes (=0.8, _, ::tag, a glide sigil), its spelling (flats stay flats, and a flat key — F, Bb, Eb, Ab, Db major and their relative minors — writes flats) and its register — the new root is the one nearest the old, so c4:maj7 → R → a3:min7; the long min… quality names are written unless the token used m…. A roman token is respelled as the numeral in the declared key (I → R → vi).
The structural write is mini::edit_pattern(content, target, SetToken(…)); the splice then replaces only the token's bytes, after checking that the result re-serialises to exactly what SetToken produced, so the rest of the literal keeps its spacing (the Alt+drag seam re-serialises the whole literal). It lands through EditorUi::splice, span-verified against the live buffer.
Read-only, and why. Only an exact chord whose token maps into the live buffer is writable; otherwise the write row says why: hand-stacked — no token to rewrite (a detected chord), a chord call — edit its arguments in the source (chord "c4" "maj7"), the token changed since the last Run — Run, then rewrite (spans index the compiled snapshot; only bytes in the unchanged prefix or suffix of the live buffer are trusted), or not inside a pattern literal the editor has analysed.
The Passage tab#
Rewrites a whole passage at once, and offers pivot chords at key changes (ui/src/passagelens.rs over compiler/src/harmony_moves.rs; Harmony Drives Stages 4–5).
The passage is the shared musical range — drag across the HARMONY lane (or any lane) to set it — wrapped into pass 0; with no range the tab works on the whole song and says so. Pick a move; every rewrite is listed as bar · from → to · numerals · old token → new token. The moves, against the key of the region each chord starts in (the declared key, else the detected region's):
- Modal interchange — the chord on the same scale degree of the parallel mode (major ↔ natural minor), stacked in thirds from that mode; a seventh chord keeps a seventh. In C major: C → Cm, Dm7 → Dm7b5, Em → Eb, F → Fm, Am → Ab, B° → Bb. The dominant (a major-third chord on degree 5) stays — the minor mode raises it in practice. A chromatic chord is left alone.
- Tritone sub — every dominant (major third + minor seventh:
7,9) moves a tritone, same quality: G7 → Db7. - Relative — every chord with a major or minor triad core swaps to its relative (the Tonnetz R): C → Am, Am7 → Cmaj7, G7 → Em7.
- Darker — major-core chords take their parallel minor (
maj7→m7,6→m6,9/maj9→m9); dominants keep their pull. - Brighter — minor-core chords take their parallel major (
m7→maj7,m6→6,m9→maj9).
Only exact chords are rewritten; hand-stacked ones are counted (1 hand-stacked skipped) and never touched, and a token the Tonnetz could not write (a chord call, a token edited since the Run) is a read-only row with its reason. A token that plays more than once (a repeated cell, a looping pattern) is one row, and when some of its plays fall outside the passage the row warns that the rewrite changes every one. The summary counts rewrites, skipped, unchanged and read-only chords.
▶ after renders the song with every rewrite applied, ▶ before the song as it is, over the passage (the range's bars, else the first 8 bars), every track, offline through the shared pattern-audio preview slot (patternaudio, owner Lens passage), and repeats it like a loop; a status row with STOP PREVIEW appears under the tab. Apply writes every rewrite as one undo step — each token through the Tonnetz's guarded splice (tonnetz::candidate_splice: octave form, affixes and spelling kept, checked against mini::edit_pattern(SetToken)), all of them through EditorUi::splice_all.
Key changes. At every boundary between adjacent key regions detected in different keys (harmony_moves::key_boundaries), the tab names the change (bar 11 · C major → G major) and lists the pivots (harmony_moves::pivots): each diatonic triad the two keys share, with its numeral in the old and the new key (Am · vi→ii), in the new key's degree order, then the new key's V and V7 (D7 · II7→V7 · setup, the dominant setup; a V both keys hold is listed once, as common). Click a pivot to hear it; write rewrites the last exact chord starting before the boundary, inside the old region, to that chord — one token, one undo step. Nothing is inserted: when that region has no exact chord the row says nothing to rewrite. A key change between two scenes is found the same way only when the detected regions change there.
The harmony lane (Map, Arrange and the Bay dock)#
A HARMONY row under the bar strip of both Map bodies — the scene Map and Arrange for a written timeline — and of the Bay's expanded Arrange dock (under its seek strip, above the track rows) paints the Lens analysis core (compiler/src/harmony_analysis.rs, fed by ui/src/harmonymodel.rs) on the same bar axis, zoom and scroll window as the lanes beneath it (ui/src/harmonylane.rs; Lens Overhaul §2 A):
- Chord blocks. An exact chord — read from a chord or roman token such as
c4:maj7— is a solid block; a detected one (hand-stacked[a3,c4,e4]) is an outline whose name carries a?(Am?). Gaps are honest: dyads, single lines and unpitched tracks name no chord. Adjacent spans of one harmony are joined into one block (display_blocks): the analysis slices at every onset, so a walking bass under a held chord would otherwise paintA#/C,A#,A#/D#slivers; a joined block whose basses differ reads in root position, and two exact spans join only when they come from the same token. - Labels adapt to the width (
harmonylane::plan). Each block prints its name over its roman numeral, then the roman alone, then the name alone (no roman), then nothing — a chord symbol is never elided, sinceCmaj…reads as another chord. A block hanging off the left edge labels the part on screen. When fewer than half the chords on screen could print anything, the lane steps down a tier: merged — consecutive identical chords (same name, roman and exactness, from any tokens, across rests — a stab patternc4:maj7 ~is the usual row of slivers) draw as one block with a notch at each re-strike, and a click opens the run's FIRST chord; then keys — each key region prints its key name (elided like a Map clip's phrase) across the row's upper line; a run still wide enough for one line (a long held chord among stabs) keeps its label beneath it, and the rest shrink to a tick strip along the foot that still clicks, drags and hovers. Text never overlaps. - Key regions are a faint band behind the blocks; where the detected key disagrees with the declared
key = "…"(another pitch collection — never spelling, and never a relative:a minordetected asC majoris said on hover only, since profile detection cannot tell relatives apart), an alert rule runs along that region's head. The gutter shows the declared key (or the first detected one, with a?). - Tension is a thin step curve along the row's foot, one level per chord.
- Key changes — where two adjacent regions are detected in different keys, a small accent wedge marks the row's head (where no chord block covers it, a hover names the change); its pivots are in the Lens Passage tab.
- Click a block → the editor selects its source token. The span indexes the compiled snapshot, so the shell maps it onto the live buffer through the step lights' safe projection (
playhead::project_span_len) and refuses when those bytes have changed since the Run. - Drag across blocks → the shared musical range (
workflow::MusicalRange), snapped to the edges of the chords it starts and ends in (bar lines in a gap), so Play selection / Loop apply; the selection is shaded over the row as over the lanes. - Hover → name, roman and key, exact vs detected, bars, contributing tracks, and tones outside the key.
- A looping scene schedule shows one pass, like its lanes; a selection made while a later pass is on screen lands in that pass.
- The header's HARMONY chip (accessible name Harmony lane) hides or shows the row. It defaults to shown when the song has any pitched notes and is remembered as the shell preference
tlharmony; the Bay dock's header carries the same chip on the same preference (the shell mirrors it in and reads it back each frame), but the dock draws the lane only when the preference is explicitly on: its height is fixed, and a default-on lane pushed its first clips out of view. - Accessible names: each block is
chord Cmaj7 · Imaj7 — bars 1 to 2; a merged runchords Cmaj7 · Imaj7 ×2 — bars 1 to 3; a key region in the keys tierkey c major — bars 1 to 9 · 8 chords; the row isharmony lane — drag across chords to select them. Tests:ui/tests/harmony_lane.rs(the tier each surface last drew isharmonylane::drawn_tier(ctx, "map" | "arrange" | "bay")).
Harmony hints in the editor#
Display ▸ Editor & notebook ▸ Harmony hints (beside Type hints; persisted as harmonyhints=1 in the shell prefs, off by default) annotates the source with the compiled song's harmony (Lens Overhaul §2 B):
- after every chord or roman token that sounds, a faint label in the accent's whisper:
c4:maj7→Cmaj7 · Imaj7,IV→F · IV— the name, and the roman numeral when a key is known (the declaredkey, else the detected one). A chord is spelled for its key, a borrowed one as its numeral reads (Ab · VIin C minor,Ab · bVIin C major, neverG#), as are the chord chip, the Map's HARMONY lane, detected keys, the Passage moves and the tokens they and the Tonnetz write (harmony_analysis::spells_flat); - after a hand-stacked
[…]stack (a group with a top-level comma), the chord its own tones name by detection (name_chord, lowest tone preferred as root), marked?([a3,c4,e4]→Am? · vi,[e3,g3,c4]→C/E? · V); a dyad is a gap and gets nothing; - under every single-note atom outside the key of the region it sounds in, a dotted hairline whose hover reads
F# is outside c major (declared). A detected key below 0.6 correlation underlines nothing; chord tokens are never underlined (their label carries the harmony).
Labels share the type hints' painting path: dimmed text after the end of the row that holds the token, so no glyph moves and the caret grid is untouched; several labels on one row read left to right in token order. A token that sounds in many cycles or scenes gets ONE label; differing readings of one token (a roman under two keys, a stack transposed downstream) join with /, at most three. When a row's labels outrun the pane the numerals drop first (Cmaj7 · Imaj7 → Cmaj7), then the tail elides as +N.
The data is harmonymodel::harmony_for (chords and key regions) plus harmonymodel::notes_of (sounding atoms), read from the last Run. Their spans index the compiled ENTRY snapshot, so ui/src/harmonyhints.rs projects every anchor onto the live buffer through playhead::Projection (the playing-step lights' safe projection) and drops any anchor the edit did not leave intact — an edited buffer never shows a hint on the wrong text; the next Run restores them. Imported tracks' atoms carry no entry span and get no hints. Without a playback sidecar only chord-token labels whose live text still reads as the same chord survive. Cached per (module generation, buffer revision). Tests: harmonyhints::tests (exact token + roman, roman token, stacked ?, out-of-key underline, stale-anchor drop and shift) and behavior::harmony_hints_toggle_paints_the_chord_after_its_token.
How it's implemented#
- Analysis —
ui/src/harmonymodel.rsharmony_for(info, source, key)(cached per module generation) andchord_at(analysis, beat); spans are(byte offset, len)into the compiled ENTRY snapshot (lens::snapshot).lens::pass_beats/pass0/playhead_beatwrap the transport beat into pass 0. - Panel —
ui/src/lens.rsLensUi::ui(ui, theme, LensCtx)→LensOut(an audition and a toast for the right column,ui/src/app/rightcol.rs, to apply). Tabs areLensTab; Report drawsHarmonyReportUi::show, and Voices is drawn by the right column under the tabs (VoltaUi::voice_lens_tab, which applies its reveal, transpose, fix write-back and fix audition) whenLensUi::tab()is Voices.chip_textis the chip's reading; a rest under a beat holds the chord before it, so stab chords do not flicker to—. - Tonnetz —
ui/src/tonnetz.rs: puretriad_core,flip_quality,neighbours,rewrite_atom,SpanMap,token_site,candidate_splice(the guardedVerbSplice),write_candidate,voicing, then the lattice paint (theme tokens andkitstrokes only).EditorUi::pattern_atom_at(ui/src/editor/pointer.rs) addresses any atom in an analysed literal. - Passage —
compiler/src/harmony_moves.rs: pureMove,apply_move,reharmonise(analysis, range, move) -> Reharmonisation { proposals, skipped, unchanged }(oneProposal { span, from, to, root, quality, plays, outside, … }per token),pivots(from, to) -> Vec<Pivot>andkey_boundaries(analysis).ui/src/passagelens.rs:plan(proposals resolved to splices against the live buffer),applied(the audition's after-text),apply(onesplice_all),pivot_splice, andPassageUi; the right column runs the audition (VoltaUi::lens_passage_audition→patternaudio::start). MCP:reharmonise(move + optional bars, each proposal with an apply_editfix) andharmony_report'skey_changes(app-native/src/mcp/harmony.rs). - Chip —
VoltaUi::chord_chipinui/src/app/transport.rs, painted bynav_strip(ui/src/app/header.rs), aChipStyle::Readoutchip in a fixed width; its accessible name ischord <reading>. - Skeleton —
ui/src/harmonyskeleton.rs:voice(pcs, bass),drum_tracks(info)andbuild(info, song, analysis, mode, range)→Skeleton { source, track, passage, chords, drums }; the shell'sVoltaUi::start_skeleton(ui/src/app/harmony_drive.rs) hands it topatternaudio::start(ownerharmony skeleton), andpatternaudio::statefeeds the row's status. - Chord input —
MidiIn::down_pitches(ui/src/midimap.rs) is the set of notes physically down, synth or not;ui/src/chordinput.rshasname_held,ChordInput::observe(the hold state machine),token_for(the auto/chord/roman rule),insertionandwrite_at_caret(the guardedEditorUi::splice, one undo snapshot).VoltaUi::pump_chord_inputruns after the MIDI pump each frame and owns ⌘⇧H and ON RELEASE. - Pattern write-back seam (lens Stage 6) —
compiler/src/mini.rsserialize_pattern(content)(aSteptree → canonical mini-notation, idempotent) andedit_pattern(content, target, op)withTranspose(i32)orSetToken(String), proven over every corpus literal bycompiler/tests/mini_roundtrip.rs. The editor's Alt+drag transpose (pat_transpose_hit/pat_transpose_move) takes one undo snapshot at gesture start and live-splices per semitone (8 px each);write_back_patternis the whole-literal variant. - Tests —
ui/src/tonnetz.rs(C major → P Cm, L Em, R Am; sevenths and the tritone sub; each neighbour shares an edge on the lattice; token rewrite affixes/octave/roman;SpanMap; a write is one undo step and keeps the literal's spacing; a stacked chord and an edited token are read-only),ui/src/lens.rs(chip text exact / detected / gap at a seek on a compiled song, pass-0 wrap, readouts),ui/tests/monitor_surfaces.rs(the panel's tabs and readouts, the chip at seeks including a wrapped pass, the chip opens the Lens, a candidate rewrites the token, a stacked chord says why),ui/tests/harmony_skeleton.rs(the generated track's notes equal the analysis chords voiced around C4, over-drums adds the drum track, a Map range bounds the passage, scene and timeline songs sound it, and an offline render's loudest pitch classes are the chord's — measured, not listened to),ui/src/chordinput.rs(naming, the token rule, every written token resolving back to the held chord, holds and rolled chords, spacing, one undo step) andui/tests/harmony_drives_ui.rs(the skeleton chips render a generated program and leave the buffer alone; synthetic MIDI note events name the chipIN …and ⌘⇧H writesiim7, thend4:majafter release),compiler/tests/harmony_moves.rs(each move, range and hand-stacked counting, C major → G major pivots Am/Em/C/G + D/D7, key boundaries),ui/tests/passage_lens.rs(a move applies as one undo step, a range limits it, a pivot rewrites the last exact chord, the tab previews and applies; a move on a declaredprogressionrewrites its numerals;VOLTA_PASSAGE_SHOTS=<dir>renders review frames).
Harmony report (the harmony doctor)#
volta_compiler::harmony_report::report(analysis, notes, source) turns a Lens analysis into advisory findings, like volta doctor: it is on demand and never a compile diagnostic. Each Finding has a kind, a severity (Info or Warn), a beat range, a message, the atom span to reveal ((offset, len) into the analysed text), a count and an optional Fix, which is a plain text edit (span + replacement). report_with takes ReportOpts: bar length, progression sections, and the floors below.
- Key mismatch. A detected key region at confidence 0.7 or more whose pitch collection is not the declared key's. The relative key and modes of the same collection (C major and A minor, D dorian and C major) are not mismatches, and neither is a declared pentatonic inside the detected scale. One tone swapped (C major read as G major) is
Info; more isWarn. When one detected key covers 60% of the song, theWarnfinding carries a fix that rewrites thekey = "…"literal; its label warns that degrees and numerals follow the key. With no declared key there is oneInfonaming what the notes read as, and no fix. - Clash (
Warn). Notes of different tracks a minor second or minor ninth apart, or a tritone over the lowest sounding note, sharing at least ½ beat above velocity 0.15. It is skipped when both tones belong to the governing exact chord token (the source named that chord) and for a diminished chord's own tritone. When a detected chord holds both tones (A under A# in A#maj7) it is anInfo"rub inside A#maj7" instead. Repeats of one atom pair are one finding with a count. The span is the later atom. - Out of key (
Info). Notes outside the declared key (a minor key's raised sixth and seventh count as in key), clustered by atom (or by track and pitch class when spanless). Chord and numeral tokens are skipped (a borrowedbb3:majis the author's), and so are notes inside aWarnkey-mismatch region. A plain-note atom that sounds as written gets the nearest in-key pitch as its fix. On a tie, the chord tone wins, else the lower note. - Progression (
Info). One line per section (the markers, else the Map's scene bands, else every eight bars): the roman numerals, or chord names without a key, with consecutive repeats collapsed. A section that loops one progression reads as(I · V · vi · IV) ×3, and identical adjacent sections merge as×N.
Clash and out-of-key findings are capped at eight per kind (heaviest first, listed in song order), plus an "…and N more" line.
ui/src/harmonyreport.rs is the shared feed: report_for(info, source, key) (cached per module generation) runs report_with over harmonymodel::harmony_for and notes_of, with sections from sections_of. HarmonyReportUi draws the findings grouped by kind. Each row shows its bars, its message, show (selects and flashes the atom through EditorUi::jump_to_decl) and fix (one guarded EditorUi::splice, so one undo step). Both actions map the span from the compiled ENTRY snapshot onto the live buffer with playhead::project_span, the projection the step lights use. A fix is enabled only while its span maps AND still reads the text it was computed from. HarmonyReportUi::show keeps its state in egui memory for the Lens panel's Report tab. MCP harmony_report (app-native/src/mcp/harmony.rs, Use Volta from your own AI agent (MCP)) returns the same report with bar positions and source line:col.
Historical TypeScript workbench (removed)#
The TypeScript Lens — the analysis engine, the panel with its Tonnetz, circle of fifths and wavescape, the CodeMirror chord/degree annotations, the BroadcastChannel producer and the pop-out audience window — was deleted with the TypeScript workbench (2026-08-10) and its pages (2026-08-21); see git history. runtime/src/lens.ts and runtime/src/harmony-analysis.ts survive only because runtime/src/og-card.ts renders share-card wavescapes from them.
Voices#
The voice-leading view (ui/src/voicelens.rs, Lens Overhaul §2 D): pitch × time over a passage, for pitched tracks only.
- Passage. The shared Map/Arrange selection (
workflow::MusicalRange) when one is set; otherwise the four-bar phrase holding the playhead (bars 1–4, 5–8, …, the playhead wrapped into one pass of the song; bars 1–4 when stopped). The header says which, with the bar span, voice count and flag count. - Voices. The notes come from
harmonymodel::notes_of(the harmony analysis feed). Each track is one voice. A polyphonic track is split by register: at each onset its notes are ranked top to bottom, unison doubles collapse, and rank k joins voice k (pad · voice 2). Each voice is drawn in its track's lane colour, as note bars joined by a line. - Interval bands. At every slice between attacks and releases, each pair of register-adjacent sounding pitches gets a translucent band. The band is coloured by consonance class, the same colours the species board uses (
counterlens::consonance_color): perfectbrt, imperfectpri, dissonantalert; the fourth counts as dissonant. A wide enough band shows its figure (3, 5, 10, …). - Flags. These are the compiler's own counterpoint checks, exposed from
compiler/src/solve/patternpred.rsand read over each voice pair's verticals (the union of both voices' attacks and releases; rests add nothing) bycompiler/src/solve/voicefix.rs(voicefix::flags, which the view and the fix share):- P5 / P8 (
noParallels,parallel_perfects) between voices of different tracks. The flag is named by the interval it lands on. Parallels between two voices of ONE track (a pad planing block chords) are idiom, so they are off by default; the header's within-track chip adds them back. - bass doubling OK (header chip, on by default): a parallel octave or unison whose lower note is the lowest sounding note AND the chord's root at both verticals is idiom too: a bass line doubled by a pad's root. The root comes from the harmony analysis (
harmonymodel::harmony_for); with no chord there, the bass note counts as its own root. Fifths are never spared. Onbloombars 5–8 the switch takes the flags from 43 to 23. The rest are mostly parallel fifths between the sub and the chord's fifth, plus the pluck's octave leaps. - X (
noCross,crossings) between voices of different tracks. A track's own voices are ranked by register, so they cannot cross. - L (
maxLeap,leap_exceeds) for a melodic move wider than an octave within one voice. Hover a flag to see the rule, its explanation and the two voices. Flags are advisory and never produce a compile diagnostic. Both chips persist in the shell state (voicewithin=1,voicebassok=0; only a non-default value is written).
- P5 / P8 (
- Fix. Click a flag to propose a fix for it, or use the header's fix passage chip to fix every flag in the passage.
voicefix::fixre-voices the passage with the voicing CSP's domain, which is octave placements. Each source atom may move by 0, ±12 or ±24 semitones, so every pitch class and the harmony stay put. Only the direction of motion, the register order and the span of a move change. The solver is a chronological DFS seeded with the current voicing, with windowed pruning and branch and bound on total semitone motion (Σ |shift| over every note moved). It is exact, deterministic and capped by node budgets (per pass and per click); past a budget the best plan found so far stands. The hard rules: every target flag clears; no new flag appears; each pair of tracks keeps the side it starts on (no wholesale register swap); and a track's voices keep their top-to-bottom order at each onset, so the fixed passage splits into the same voices. Pins follow the voicing CSP's two-pass shape. Pass 1 moves only the flagged notes. Only if that admits nothing does pass 2 free the editable notes around them: every note sounding across the flags, plus each voice's note on either side. The proposal then says "moves unflagged notes too". A passage fix tries every flag at once, then falls back to fixing flag by flag in passage order (voicefix::fix_passage). A flag it cannot clear is left in place, and the proposal row names the reason.- Before/after. Each moved note is drawn as an accent-outlined ghost at its new octave with its shift (
+12), joined to where it sits now. The row under the canvas readsfix <what> · N notes by octaves, S semitones · flags B → A. It also says when an atom also plays outside the passage: it moves there too. hear before / hear after render the passage's voiced tracks A/B offline through the pattern roll's preview slot (patternaudio::start; A = the live buffer, B = the fixed one). Nothing is written until you click apply fix. cancel, an edit, a recompile or a passage change drops the proposal. - Write-back. The moves become guarded splices (
voicelens::fix_edit). For each pattern literal, one parse takes every transpose (mini::edit_pattern_many, the multi-atom twin of the Alt+drag'sedit_pattern(Transpose)). Only the moved tokens' bytes are spliced when that re-serialises to the same structure, so the literal keeps its spacing. Otherwise the literal's canonical content replaces it whole. The batch lands throughEditorUi::splice_all: atomic, ONE undo step. - What can move. A note's atom is its compiled span projected into the live buffer (
SpanProjection). It is kept only when it lands on a transposable pitch in a pattern literal. Imported notes, spans edited since the compile, and degree or roman tokens stay fixed. A chord token's tones share one atom, so the fix moves the whole chord by an octave or leaves it. An atom that plays several notes (a repeated cell, or a chord) counts each note in the motion total.
- Before/after. Each moved note is drawn as an accent-outlined ghost at its new octave with its shift (
- Gestures. Hovering a note shows its voice, pitch and bar. Clicking it reveals its atom in the song editor (
reveal_source). Alt+vertical-drag shows a ghost note at the new pitch with the semitone count; on release the atom is transposed throughEditorUi::transpose_atom_at. That is the editor's own Alt+drag edit (pattern-tree hit, thenmini::edit_pattern(Transpose), then a guarded splice) and makes one undo step. A chord token's tones share one atom, so dragging any of them moves the chord's root. Escape cancels a drag. - Source gate. A note's compiled ENTRY span is mapped into the live buffer through
playhead::SpanProjection, the same projection the source lights use. A span that no longer lands inside a live pattern literal is refused with a one-line notice ("source moved since the last compile"). Imported tracks have no entry span, so their notes are drawn dimmed and are read-only. - Drum tracks left out. Drum hits written with pitches (
!"c2 ~ c2 ~"into a kick patch) are not voices. The view leaves out a track whose synth is a sample kit ($sampler) or a drum model (Drumnode), or whose synth has aNoisesource or aDecaysweep and plays one pitch over the whole pass (voicelens::drum_voice). The header counts the tracks left out. This is the view's own heuristic; the shared feed (harmonymodel) is unchanged. - Limits. The view shows at most 24 voices. Externally timed tracks and unpitched events are absent (the same as the harmony feed). Register ranking can hand a line from one voice to another when a track's chord size changes mid-passage.
- Tests:
ui/src/voicelens.rs(voice splitting, parallel fifths on a hand-made two-voice passage, octaves/crossing/leap, bands, and the drag edit landing on the right atom with one undo, including a buffer edited since the compile);ui/tests/voicelens.rs(kittest: the header counts the flag, a click asks to reveal the atom, an Alt+drag asks for exactly one transpose of that atom, a plain drag asks for none; a flag click proposes a fix whose hear-after and apply ask for exactly its splices, which land and undo as one step; the bass-doubling chip);compiler/src/solve/voicefix.rs(fifths and a crossing cleared by one octave move, unsatisfiable and unpinnable flags refused with a reason, pass 2 freeing an unflagged note, the bass-doubling rule, a passage fix skipping what it cannot clear);ui/src/voicelens.rs(a multi-atom fix edit in one undo step, a chord token moving as a block);ui/src/app/prefs.rs(the chips persist);compiler/tests/counterpoint.rs(the exposed checks).
Notes & limits#
- Symbolic only: notated pitch from the compiled IR, never an FFT of the audio; 12-TET pitch classes (a microtonal note rounds).
- A rewrite edits the source; you hear it after the next Run.
- The skeleton plays the analysis, not the song's own voicing or rhythm: a stab pattern's chord is held across its span, and a detected chord sounds exactly the pitch classes the analysis heard. Meter changes are not followed (the skeleton's cycle is the song's base bar).
- The caret precedence means a caret parked on a chord token holds the Tonnetz on that chord while the playhead moves on; move the caret off the token to follow the playhead.
- The historical TypeScript Lens (Tonnetz, circle of fifths, wavescape, annotations, pop-out window) was deleted 2026-08-10;
runtime/src/lens.tsandruntime/src/harmony-analysis.tssurvive only forog-card.ts's share cards.