diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dcd3079..6c4c5f9c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -127,6 +127,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
known the editor *probably* has something but couldn't find which menu it
lives in, this is the answer: type "snap", "tempo", "export", "guide" and
the matching commands surface instantly, fuzzy matching included.
+- **Two new shortcut profiles: Logical and Cableton.** If your hands already
+ know a DAW, the editor can meet them there. **Logical** (Logic-style) puts
+ the metronome on **K**, quantize on **Q**, steps the playhead by beat with
+ **`,` / `.`**, loops the selection with **C**, creates a section with
+ **Alt+'** (the marker key). **Cableton** (Ableton-style) quantizes with
+ **Ctrl+U**, narrows/widens the grid with **Ctrl+1 / Ctrl+2**, toggles snap
+ with **Ctrl+4**, clicks with **O**, follows playback with **Ctrl+Shift+F**,
+ and loops the selection with **Ctrl+L**. Everything a profile doesn't remap
+ keeps its FeedBack key, so editor-specific commands (techniques, tempo
+ mapping, string moves) work identically everywhere — and where a DAW key
+ displaces a FeedBack one, the displaced command *relocates* (Logical: pick
+ direction moves to **Shift+K**, guide claps to **Ctrl+Shift+C**; Cableton:
+ pop moves to **Ctrl+Shift+P**, select-matching to **Ctrl+Shift+L**) and the
+ shortcut panel shows its new key — no command ever loses its keyboard, and
+ no chord is ever double-bound. Logic's Repeat (**Cmd/Ctrl+R**) is
+ deliberately *not* bound: the desktop app's own Reload accelerator owns that
+ chord and would reload the editor out from under you; duplicate stays on
+ **Ctrl+D** in every profile. The old EOF profile is still here as **Legacy
+ (EOF)**. Switch in Help ▸ Shortcut profile, the Shortcuts panel, or the
+ toolbar select; the shortcut panel always shows the keys for whichever
+ profile is live.
+- **Loop toggle and Song Fit are real commands now.** "Toggle loop playback
+ for the selected region" and "Song Fit" joined the command registry, so both
+ are reachable from anywhere commands are listed, Song Fit gained a menu home
+ (**Tempo/Grid ▸ Song Fit**), and profiles can bind keys to them (Logical's C
+ and Cableton's Ctrl+L drive the loop toggle).
- **Scan for tempo zones (preview).** A new **Tempo/Grid ▸ Scan for tempo zones**
action reads the recording and reports the handful of *tempo intents* it finds
— e.g. "3 tempo zones detected: 120 bpm · 140 bpm · rit 140→90". It's the first
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 0e244447..fc14d611 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -5,9 +5,13 @@ grid up to the music, place the notes, mark the tempo and sections, and build a
`.feedpak` the rest of the app can practice against.
This guide is the same content you get in-app from **Help ▸ User Guide**. It
-covers the FeedBack shortcut profile (the default); the EOF Legacy profile
-remaps some keys — switch profiles in **Help ▸ Shortcut profile** or the
-shortcut panel (`?`).
+covers the FeedBack shortcut profile (the default). Three other profiles remap
+some keys to match muscle memory you may already have: **Logical**
+(Logic-style — K clicks the metronome, Q quantizes, `,`/`.` step by beat, C
+loops the selection), **Cableton** (Ableton-style — Ctrl+U quantizes, Ctrl+1/2
+narrow/widen the grid, Ctrl+4 toggles snap, Ctrl+L loops), and **Legacy
+(EOF)**. Anything a profile doesn't remap keeps its FeedBack key. Switch
+profiles in **Help ▸ Shortcut profile** or the shortcut panel (`?`).

diff --git a/screen.html b/screen.html
index d6a111ab..14a2c91a 100644
--- a/screen.html
+++ b/screen.html
@@ -182,7 +182,9 @@
Keys:
FeedBack
- EOF Legacy
+ Logical (Logic-style)
+ Cableton (Ableton-style)
+ Legacy (EOF)
Shortcuts…
@@ -305,7 +307,9 @@
Profile
FeedBack
- EOF Legacy
+ Logical (Logic-style)
+ Cableton (Ableton-style)
+ Legacy (EOF)
Right-click
diff --git a/src/input.js b/src/input.js
index edf3fb78..853c7b49 100644
--- a/src/input.js
+++ b/src/input.js
@@ -19,11 +19,14 @@ import { hitNote } from './hit-test.js';
import { _renderInspector, _selectedChordContext } from './inspector.js';
import { PIANO_LANE_H, _rollLockNotice, _rollReadOnly, isKeysArr, isKeysMode, midiToFret, midiToString, pianoLaneCount, yToMidi } from './keys.js';
import { lanes, _stringCountFor } from './lanes.js';
-import { _editorClampScrollX, _loopNudgeEdge, snapGuidelineAfter, snapTime } from './loop.js';
+import {
+ _editorClampScrollX, _loopNudgeEdge, editorToggleLoopRegion, snapGuidelineAfter, snapTime,
+} from './loop.js';
+import { _editorSongFit } from './song-fit.js';
import { _recState } from './midi-record.js';
import { getMousePos } from './mouse.js';
import { _resizeSustainsForDeltaPure, notes } from './notes.js';
-import { _editorCommandById, _editorEffectiveRightClickBehaviorPure, _editorEofCommandForKeyPure, _editorFeedbackCommandForKeyPure, _editorIsTypingTarget, _editorRenderShortcutPanel, editorRightClickBehavior, editorShortcutProfile } from './shortcuts.js';
+import { EDITOR_PROFILE_OVERRIDES, _editorCommandById, _editorEffectiveRightClickBehaviorPure, _editorEofCommandForKeyPure, _editorFeedbackCommandForKeyPure, _editorIsTypingTarget, _editorRenderShortcutPanel, _editorTableCommandForKeyPure, editorRightClickBehavior, editorShortcutProfile } from './shortcuts.js';
import { SNAP_VALUES, _editorEffectiveSnapValuePure, _editorSnapSubdivisionsPure } from './snap.js';
import { S } from './state.js';
import { _editorShowTabPreview, _tabPreviewKeyPolicyPure } from './tab-preview.js';
@@ -913,6 +916,8 @@ export function _editorRunEofCommand(cmd) {
case 'toggleMetronome': return _editorToggleMetronome();
case 'toggleMixer': return editorToggleMixerPanel();
case 'toggleLoopAB': return _editorToggleLoopAB();
+ case 'toggleLoopRegion': return editorToggleLoopRegion();
+ case 'songFit': _editorSongFit(); return true;
case 'toggleOnsetStrip': return _editorToggleOnsetStrip();
case 'togglePartsView': return host.editorTogglePartsView();
case 'toggleKeyHighlight': return host.editorToggleKeyHighlight();
@@ -1017,8 +1022,19 @@ export function _editorRunEofCommand(cmd) {
}
function _editorDispatchFeedbackShortcut(e) {
- if (editorShortcutProfile !== 'feedback' || _editorIsTypingTarget(e)) return false;
- const cmd = _editorFeedbackCommandForKeyPure(e, S.tempoMapMode ? 'tempoMap' : 'note');
+ if (_editorIsTypingTarget(e)) return false;
+ // The delta profiles (Logical / Cableton) resolve their override table
+ // first and fall through to the FeedBack meaning for everything else;
+ // plain FeedBack skips the table. EOF has its own dispatcher below.
+ let cmd = null;
+ if (editorShortcutProfile === 'feedback') {
+ cmd = _editorFeedbackCommandForKeyPure(e, S.tempoMapMode ? 'tempoMap' : 'note');
+ } else if (EDITOR_PROFILE_OVERRIDES[editorShortcutProfile]) {
+ cmd = _editorTableCommandForKeyPure(e, S.tempoMapMode ? 'tempoMap' : 'note',
+ EDITOR_PROFILE_OVERRIDES[editorShortcutProfile]);
+ } else {
+ return false;
+ }
if (!cmd) return false;
const def = _editorCommandById(cmd);
if (def && def.status !== 'ready') return false;
diff --git a/src/menu-bar.js b/src/menu-bar.js
index deea404c..166df850 100644
--- a/src/menu-bar.js
+++ b/src/menu-bar.js
@@ -187,7 +187,7 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'nextAnchor' },
{ sep: true },
{ hdr: 'Loop' },
- { label: 'Loop region', fn: 'editorToggleLoopRegion' },
+ { cmd: 'toggleLoopRegion' },
{ cmd: 'toggleLoopAB' },
{ label: 'Loop in 3D', fn: 'editorLoopIn3D' },
{ loopClear: true, label: 'Clear loop' },
@@ -228,6 +228,7 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'tempoFullDialog' },
{ cmd: 'tempoRebuildGrid' },
{ sep: true },
+ { cmd: 'songFit' },
{ label: 'Sync tempo to audio', fn: 'editorSyncTempo', audioOnly: true },
{ label: 'Scan for tempo zones…', fn: 'editorScanTempoZones', audioOnly: true },
{ label: 'Apply rough map from tempo zones', fn: 'editorApplyTempoZones', audioOnly: true },
@@ -251,7 +252,7 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'showShortcutHelp' },
{ cmd: 'midiTones' },
{ sep: true },
- { label: 'Shortcut profile: FeedBack ⇄ EOF', fn: '__swapProfile' },
+ { label: 'Shortcut profile: next (FeedBack → Logical → Cableton → Legacy)', fn: '__swapProfile' },
] },
]);
@@ -415,7 +416,9 @@ function dispatch(d) {
if (d.guideVoice) { _editorSetGuideVoiceMode(d.guideVoice); return; }
if (d.gmVoice != null) { editorSetGmVoice(d.gmKind, d.gmVoice); return; }
if (d.fn === '__swapProfile') {
- const next = editorShortcutProfile === 'eof' ? 'feedback' : 'eof';
+ // Cycle the four profiles in a fixed order; the two selects follow.
+ const order = ['feedback', 'logical', 'cableton', 'eof'];
+ const next = order[(order.indexOf(editorShortcutProfile) + 1) % order.length];
if (typeof window.editorSetShortcutProfile === 'function') window.editorSetShortcutProfile(next);
const sel = document.getElementById('editor-shortcut-profile');
if (sel) sel.value = next;
diff --git a/src/shortcuts.js b/src/shortcuts.js
index e7b80659..57fa5b07 100644
--- a/src/shortcuts.js
+++ b/src/shortcuts.js
@@ -1,8 +1,9 @@
/* Slopsmith Arrangement Editor — keyboard shortcuts & right-click behaviour.
*
- * The two shortcut profiles (FeedBack native / EOF legacy), the key→command
- * mapping for each, the right-click behaviour that rides on the profile, their
- * localStorage persistence, and the shortcut-panel renderer.
+ * The four shortcut profiles (FeedBack native / Logical / Cableton / EOF
+ * legacy), the key→command mapping for each, the right-click behaviour that
+ * rides on the profile, their localStorage persistence, and the
+ * shortcut-panel renderer.
*
* `editorShortcutProfile` and `editorRightClickBehavior` are `export let`: they
* are reassigned, but every writer lives here (the two `editorSet*` setters and
@@ -14,7 +15,20 @@ import { setStatus } from './ui.js';
const EDITOR_SHORTCUT_PROFILE_KEY = 'editor.shortcutProfile';
const EDITOR_RIGHT_CLICK_BEHAVIOR_KEY = 'editor.rightClickBehavior';
-const EDITOR_SHORTCUT_PROFILES = new Set(['feedback', 'eof']);
+// Four profiles. 'feedback' and 'eof' keep their battle-tested hand resolvers;
+// 'logical' (Logic-style) and 'cableton' (Ableton-style) are DELTAS over the
+// FeedBack resolver (EDITOR_PROFILE_OVERRIDES below): an override wins, and a
+// key it doesn't claim falls back to its FeedBack meaning — so the DAW
+// muscle-memory keys land where a Logic / Live user expects while every
+// editor-specific command keeps working. 'eof' keeps its internal id for
+// localStorage compat; the UI shows it as "Legacy (EOF)".
+const EDITOR_SHORTCUT_PROFILES = new Set(['feedback', 'logical', 'cableton', 'eof']);
+export const EDITOR_PROFILE_NAMES = Object.freeze({
+ feedback: 'FeedBack',
+ logical: 'Logical (Logic-style)',
+ cableton: 'Cableton (Ableton-style)',
+ eof: 'Legacy (EOF)',
+});
const EDITOR_RIGHT_CLICK_BEHAVIORS = new Set(['context', 'eofEdit']);
export let editorShortcutProfile = 'feedback';
export let editorRightClickBehavior = null;
@@ -32,17 +46,19 @@ export function _editorKeySigPure(e) {
const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'save', label: 'Save project', group: 'File', status: 'ready', keys: { feedback: 'Ctrl+S', eof: 'F2 / Ctrl+S' } },
{ id: 'toggleWaveform', label: 'Show/hide waveform', group: 'View', status: 'ready', keys: { feedback: 'W', eof: 'F5' } },
- { id: 'toggleGuideClap', label: 'Toggle guide claps', group: 'Preview', status: 'ready', keys: { feedback: 'C', eof: 'C' } },
- { id: 'toggleMetronome', label: 'Toggle metronome click', group: 'Preview', status: 'ready', keys: { feedback: '', eof: '' } },
+ { id: 'toggleGuideClap', label: 'Toggle guide claps', group: 'Preview', status: 'ready', keys: { feedback: 'C', logical: 'Ctrl+Shift+C', eof: 'C' } },
+ { id: 'toggleMetronome', label: 'Toggle metronome click', group: 'Preview', status: 'ready', keys: { feedback: '', logical: 'K', cableton: 'O', eof: '' } },
{ id: 'toggleMixer', label: 'Toggle Mixer panel', group: 'Preview', status: 'ready', keys: { feedback: 'Shift+C', eof: 'Shift+C' } },
{ id: 'toggleLoopAB', label: 'Toggle loop A/B compare (recording ↔ guide)', group: 'Preview', status: 'ready', keys: { feedback: 'Alt+B', eof: 'Alt+B' } },
+ { id: 'toggleLoopRegion', label: 'Toggle loop playback for the selected region', group: 'Preview', status: 'ready', keys: { feedback: '', logical: 'C', cableton: 'Ctrl+L', eof: '' } },
+ { id: 'songFit', label: 'Song Fit — line the chart up with the recording', group: 'Tempo map', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'toggleOnsetStrip', label: 'Toggle onset detection strip', group: 'View', status: 'ready', keys: { feedback: 'Shift+W', eof: 'Shift+W' } },
{ id: 'togglePartsView', label: 'Toggle Tracks overview', group: 'View', status: 'ready', keys: { feedback: 'Shift+A', eof: 'Shift+A' } },
{ id: 'toggleKeyHighlight', label: 'Toggle in-key highlight', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'cycleViewMode', label: 'Cycle track view (String / Piano roll)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'showTabPreview', label: 'Preview track as tab (read-only, saved pack)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'toggleDrumDensity', label: 'Toggle drum row density (Full / Compact)', group: 'View', status: 'ready', keys: { feedback: '', eof: '' } },
- { id: 'toggleFollow', label: 'Toggle follow playhead', group: 'View', status: 'ready', keys: { feedback: 'Shift+L', eof: 'Shift+L' } },
+ { id: 'toggleFollow', label: 'Toggle follow playhead', group: 'View', status: 'ready', keys: { feedback: 'Shift+L', cableton: 'Ctrl+Shift+F', eof: 'Shift+L' } },
{ id: 'renamePart', label: 'Rename current track', group: 'Structure', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'movePartEarlier', label: 'Move current track earlier', group: 'Structure', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'movePartLater', label: 'Move current track later', group: 'Structure', status: 'ready', keys: { feedback: '', eof: '' } },
@@ -52,8 +68,8 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'importXml', label: 'Import XML source', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F7' } },
{ id: 'importGp', label: 'Import Guitar Pro source', group: 'File', status: 'ready', keys: { feedback: '', eof: 'F12' } },
{ id: 'exportGp5', label: 'Export track as Guitar Pro (.gp5)', group: 'File', status: 'ready', keys: { feedback: '', eof: '' } },
- { id: 'prevBeat', label: 'Jump to previous beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Up', eof: 'Page Up' } },
- { id: 'nextBeat', label: 'Jump to next beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Down', eof: 'Page Down' } },
+ { id: 'prevBeat', label: 'Jump to previous beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Up', logical: ',', eof: 'Page Up' } },
+ { id: 'nextBeat', label: 'Jump to next beat', group: 'Timeline', status: 'ready', keys: { feedback: 'Page Down', logical: '.', eof: 'Page Down' } },
{ id: 'prevNote', label: 'Jump to previous note', group: 'Timeline', status: 'ready', keys: { feedback: 'Alt+Left', eof: 'Shift+Page Up' } },
{ id: 'nextNote', label: 'Jump to next note', group: 'Timeline', status: 'ready', keys: { feedback: 'Alt+Right', eof: 'Shift+Page Down' } },
{ id: 'nudgeTimeLeft', label: 'Nudge selection earlier one step (playhead when nothing selected)', group: 'Timeline', status: 'ready', keys: { feedback: 'Left', eof: 'Left' } },
@@ -66,9 +82,9 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'setBookmarkDigit', label: 'Set / clear bookmark 1-9 at cursor', group: 'Timeline', status: 'ready', keys: { feedback: 'Shift+Alt+1-9', eof: 'Shift+Alt+1-9' } },
{ id: 'shortenSustain', label: 'Shorten selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: '', eof: '[' } },
{ id: 'lengthenSustain', label: 'Lengthen selected sustain', group: 'Grid and sustain', status: 'ready', keys: { feedback: '', eof: ']' } },
- { id: 'toggleSnap', label: 'Toggle snap on/off', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'G', eof: '' } },
- { id: 'snapDown', label: 'Decrease snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: ',', eof: ',' } },
- { id: 'snapUp', label: 'Increase snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: '.', eof: '.' } },
+ { id: 'toggleSnap', label: 'Toggle snap on/off', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'G', cableton: 'Ctrl+4', eof: '' } },
+ { id: 'snapDown', label: 'Decrease snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: ',', logical: 'Ctrl+,', cableton: 'Ctrl+2', eof: ',' } },
+ { id: 'snapUp', label: 'Increase snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: '.', logical: 'Ctrl+.', cableton: 'Ctrl+1', eof: '.' } },
{ id: 'toggleSnapMode', label: 'Toggle snap target (grid / audio onset)', group: 'Grid and sustain', status: 'ready', keys: { feedback: '', eof: '' } },
{ id: 'editFret', label: 'Edit fret / fingering', group: 'Notes', status: 'ready', keys: { feedback: 'F', eof: 'F / Ctrl+F' } },
{ id: 'suggestFingers', label: 'Suggest fret-hand fingers', group: 'Notes', status: 'ready', keys: { feedback: '', eof: '' } },
@@ -97,16 +113,16 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
{ id: 'toggleAccent', label: 'Toggle accent', group: 'Techniques', status: 'ready', keys: { feedback: 'A', eof: 'Ctrl+Shift+A' } },
{ id: 'toggleIgnore', label: 'Toggle ignore', group: 'Techniques', status: 'ready', keys: { feedback: 'Ctrl+Shift+I', eof: 'Ctrl+Shift+I' } },
{ id: 'toggleTremolo', label: 'Toggle tremolo', group: 'Techniques', status: 'ready', keys: { feedback: 'Ctrl+Shift+O', eof: 'Ctrl+Shift+O' } },
- { id: 'togglePop', label: 'Toggle pop / pluck', group: 'Techniques', status: 'ready', keys: { feedback: 'O', eof: 'Ctrl+Shift+P' } },
+ { id: 'togglePop', label: 'Toggle pop / pluck', group: 'Techniques', status: 'ready', keys: { feedback: 'O', cableton: 'Ctrl+Shift+P', eof: 'Ctrl+Shift+P' } },
{ id: 'toggleSlap', label: 'Toggle slap', group: 'Techniques', status: 'ready', keys: { feedback: 'Shift+O', eof: 'Shift+O' } },
- { id: 'cyclePickDirection', label: 'Cycle pick direction', group: 'Techniques', status: 'ready', keys: { feedback: 'K', eof: 'K' } },
+ { id: 'cyclePickDirection', label: 'Cycle pick direction', group: 'Techniques', status: 'ready', keys: { feedback: 'K', logical: 'Shift+K', eof: 'K' } },
{ id: 'fretUp', label: 'Increase selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl++', eof: 'Ctrl++' } },
{ id: 'fretDown', label: 'Decrease selected fret', group: 'Notes', status: 'ready', keys: { feedback: 'Ctrl+-', eof: 'Ctrl+-' } },
{ id: 'setAnchor', label: 'Set anchor at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+F', eof: 'Shift+F' } },
- { id: 'selectLike', label: 'Select matching string/fret', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+L', eof: 'Ctrl+L' } },
+ { id: 'selectLike', label: 'Select matching string/fret', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+L', cableton: 'Ctrl+Shift+L', eof: 'Ctrl+L' } },
{ id: 'duplicateSelection', label: 'Duplicate selection to next position', group: 'Selection', status: 'ready', keys: { feedback: 'Ctrl+D', eof: 'Ctrl+D' } },
- { id: 'resnapSelection', label: 'Resnap selection to grid', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'Shift+R', eof: 'Shift+R' } },
- { id: 'addSection', label: 'Add section at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+M', eof: 'Shift+S' } },
+ { id: 'resnapSelection', label: 'Resnap selection to grid', group: 'Grid and sustain', status: 'ready', keys: { feedback: 'Shift+R', logical: 'Q', cableton: 'Ctrl+U', eof: 'Shift+R' } },
+ { id: 'addSection', label: 'Add section at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+M', logical: "Alt+'", eof: 'Shift+S' } },
{ id: 'addPhrase', label: 'Add phrase at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Shift+P', eof: 'Shift+P' } },
{ id: 'addToneChange', label: 'Add tone change at cursor', group: 'Structure', status: 'ready', keys: { feedback: 'Ctrl+Shift+T', eof: 'Ctrl+Shift+T' } },
{ id: 'addHandshape', label: 'Add handshape from selection', group: 'Structure', status: 'ready', keys: { feedback: 'Ctrl+H', eof: 'Ctrl+Shift+H' } },
@@ -133,13 +149,18 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([
]);
export function _editorShortcutRowsPure(profile) {
- const p = profile === 'eof' ? 'eof' : 'feedback';
+ const p = EDITOR_SHORTCUT_PROFILES.has(profile) ? profile : 'feedback';
return EDITOR_SHORTCUT_COMMANDS.map(cmd => ({
id: cmd.id,
label: cmd.label,
group: cmd.group,
status: cmd.status,
- key: (cmd.keys && cmd.keys[p]) || '',
+ // A profile entry that is ABSENT inherits the FeedBack key (the delta
+ // model: unoverridden keys keep their FeedBack meaning). An entry that
+ // is EXPLICITLY '' means the profile reassigned that key away and the
+ // command is keyless there — the two must not collapse, or a shadowed
+ // command would display a key that no longer runs it.
+ key: (cmd.keys && (cmd.keys[p] ?? cmd.keys.feedback)) || '',
}));
}
export function _editorEofCommandForKeyPure(e, mode) {
@@ -256,6 +277,69 @@ export function _editorEofCommandForKeyPure(e, mode) {
}
+// ── Logical / Cableton — delta tables over the FeedBack resolver ─────────────
+// Sig (from _editorKeySigPure) → command id. A sig in the table WINS (which is
+// also how a FeedBack meaning gets shadowed: 'K' in Logical is the metronome,
+// so pick-direction cycling is keyless there — its registry entry says so);
+// any sig absent from the table falls through to the FeedBack resolver, so
+// every editor-specific command keeps working under DAW muscle memory.
+// Bindings marked "authentic" are the DAW's own defaults (Logic Pro user
+// guide key-command appendix; Live 12 manual keyboard-shortcut chapter,
+// Windows column) — the rest are derived relocations for shadowed commands.
+export const EDITOR_PROFILE_OVERRIDES = Object.freeze({
+ logical: Object.freeze({
+ 'K': 'toggleMetronome', // authentic: Click
+ 'Q': 'resnapSelection', // authentic: Quantize Selected Events
+ ',': 'prevBeat', // authentic: Rewind
+ '.': 'nextBeat', // authentic: Forward
+ 'C': 'toggleLoopRegion', // authentic: Cycle Mode
+ "Alt+'": 'addSection', // authentic: Create Marker (Option-Apostrophe)
+ 'Ctrl+,': 'snapDown', // relocated (',' now rewinds)
+ 'Ctrl+.': 'snapUp', // relocated ('.' now forwards)
+ 'Shift+K': 'cyclePickDirection', // relocated ('K' now clicks)
+ 'Ctrl+Shift+C': 'toggleGuideClap', // relocated ('C' now cycles)
+ // NOT BOUND: Logic's Repeat is Cmd-R, but the Electron host registers a
+ // {role:'reload'} menu item, whose CmdOrCtrl+R accelerator fires in the
+ // main process BEFORE the renderer keydown — preventDefault() cannot
+ // stop it, so binding it here would reload the editor and drop unsaved
+ // edits. duplicateSelection already answers Ctrl+D in every profile
+ // (input.js, outside the resolvers), so it stays reachable.
+ }),
+ cableton: Object.freeze({
+ 'Ctrl+U': 'resnapSelection', // authentic: Quantize
+ 'Ctrl+1': 'snapUp', // authentic: Narrow Grid (finer)
+ 'Ctrl+2': 'snapDown', // authentic: Widen Grid (coarser)
+ 'Ctrl+4': 'toggleSnap', // authentic: Snap to Grid
+ 'O': 'toggleMetronome', // authentic: Metronome (Live 12)
+ 'Ctrl+Shift+F': 'toggleFollow', // authentic: Follow Playback
+ 'Ctrl+L': 'toggleLoopRegion', // authentic: Loop Selection
+ 'Ctrl+Shift+P': 'togglePop', // relocated ('O' now clicks)
+ 'Ctrl+Shift+L': 'selectLike', // relocated ('Ctrl+L' now loops)
+ }),
+});
+
+// Generic table resolution for the delta profiles: the override wins, then
+// the FeedBack resolver (which owns the tempo-map overlay, the digit/bookmark
+// families, and everything unoverridden). The override sigs are disjoint from
+// the FeedBack tempo-map overlay by construction — pinned by test.
+export function _editorTableCommandForKeyPure(e, mode, overrides) {
+ const hit = overrides ? overrides[_editorKeySigPure(e)] : undefined;
+ if (hit) return hit;
+ return _editorFeedbackCommandForKeyPure(e, mode);
+}
+
+// Validator (test-facing): the effective binding surface of a delta profile —
+// override sigs must be unique (frozen-object keys already are) and must not
+// collide with the FeedBack TEMPO-MAP overlay, whose keys resolve first in
+// spirit (they share the fall-through). Returns colliding sigs; empty = sound.
+export function _editorProfileCollisionsPure(overrides, tempoMapSigs) {
+ const out = [];
+ for (const sig of Object.keys(overrides || {})) {
+ if ((tempoMapSigs || []).includes(sig)) out.push(sig);
+ }
+ return out;
+}
+
export function _editorDefaultRightClickBehaviorPure(profile) {
return profile === 'eof' ? 'eofEdit' : 'context';
}
@@ -415,7 +499,7 @@ export function editorSetShortcutProfile(profile) {
if (panelEl) panelEl.value = editorShortcutProfile;
_editorSyncRightClickBehaviorControls();
_editorRenderShortcutPanel();
- setStatus(editorShortcutProfile === 'eof' ? 'Shortcut profile: EOF Legacy' : 'Shortcut profile: FeedBack');
+ setStatus(`Shortcut profile: ${EDITOR_PROFILE_NAMES[editorShortcutProfile] || 'FeedBack'}`);
}
export function _editorCommandById(id) {
diff --git a/tests/keybind_profiles.test.mjs b/tests/keybind_profiles.test.mjs
new file mode 100644
index 00000000..eedfbab6
--- /dev/null
+++ b/tests/keybind_profiles.test.mjs
@@ -0,0 +1,196 @@
+/*
+ * Four keybind profiles: FeedBack / Logical (Logic-style) / Cableton
+ * (Ableton-style) / Legacy (EOF). The two new profiles are DELTAS over the
+ * FeedBack resolver: an override table (sig → id) wins, everything else falls
+ * through to its FeedBack meaning — so DAW muscle memory lands where a Logic
+ * or Live user expects while every editor-specific command keeps working.
+ *
+ * Pinned here: the authentic DAW bindings (sourced from the Logic key-command
+ * appendix and the Live 12 shortcut chapter), the fall-through inheritance,
+ * the shadow rule (a reassigned key's old command goes keyless or relocates —
+ * and DISPLAYS that, absent-vs-'' preserved), the tempo-map overlay staying
+ * reachable through the fall-through, disjointness of override sigs from the
+ * tempo-map overlay, and the two newly registry-registered commands.
+ *
+ * Fails on main (the tables, resolver, and profiles don't exist there).
+ * Run: node tests/keybind_profiles.test.mjs
+ */
+import assert from 'node:assert';
+
+globalThis.document = globalThis.document || {
+ getElementById: () => null, addEventListener: () => {}, activeElement: null,
+};
+globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} };
+globalThis.window = globalThis.window || globalThis;
+
+const {
+ EDITOR_PROFILE_NAMES, EDITOR_PROFILE_OVERRIDES,
+ _editorProfileCollisionsPure, _editorShortcutRowsPure, _editorTableCommandForKeyPure,
+ editorSetShortcutProfile,
+} = await import('../src/shortcuts.js');
+import * as shortcuts from '../src/shortcuts.js';
+
+let pass = 0, fail = 0;
+function t(name, fn) {
+ try { fn(); pass++; console.log(' ok ' + name); }
+ catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
+}
+
+const ev = (key, m = {}) => ({
+ key, code: m.code || '',
+ ctrlKey: !!m.ctrl, metaKey: false, shiftKey: !!m.shift, altKey: !!m.alt,
+});
+const L = EDITOR_PROFILE_OVERRIDES.logical;
+const A = EDITOR_PROFILE_OVERRIDES.cableton;
+const resolve = (table, e, mode = 'note') => _editorTableCommandForKeyPure(e, mode, table);
+
+// ── the authentic DAW bindings ───────────────────────────────────────
+
+t('Logical: the Logic defaults land — K click, Q quantize, ,/. transport, C cycle', () => {
+ assert.strictEqual(resolve(L, ev('k')), 'toggleMetronome');
+ assert.strictEqual(resolve(L, ev('q')), 'resnapSelection');
+ assert.strictEqual(resolve(L, ev(',')), 'prevBeat');
+ assert.strictEqual(resolve(L, ev('.')), 'nextBeat');
+ assert.strictEqual(resolve(L, ev('c')), 'toggleLoopRegion');
+ assert.strictEqual(resolve(L, ev("'", { alt: true })), 'addSection');
+});
+
+t('Logical does NOT take Ctrl+R for Repeat — the host menu reloads on it', () => {
+ // Logic's Repeat is Cmd-R, but the Electron {role:'reload'} accelerator is
+ // handled in the main process and preventDefault() cannot reclaim it, so the
+ // editor would reload and drop unsaved edits. Ctrl+D still duplicates in
+ // every profile (input.js handles it outside the resolvers).
+ assert.notStrictEqual(resolve(L, ev('r', { ctrl: true })), 'duplicateSelection');
+ assert.strictEqual(L['Ctrl+R'], undefined);
+ const lRows = Object.fromEntries(_editorShortcutRowsPure('logical').map(r => [r.id, r.key]));
+ assert.strictEqual(lRows.duplicateSelection, 'Ctrl+D', 'displays the chord that actually works');
+});
+
+t('Cableton: the Live defaults land — Ctrl+U quantize, Ctrl+1/2 grid, Ctrl+4 snap, O click, Ctrl+L loop', () => {
+ assert.strictEqual(resolve(A, ev('u', { ctrl: true })), 'resnapSelection');
+ assert.strictEqual(resolve(A, ev('1', { ctrl: true })), 'snapUp', 'Narrow Grid = finer');
+ assert.strictEqual(resolve(A, ev('2', { ctrl: true })), 'snapDown', 'Widen Grid = coarser');
+ assert.strictEqual(resolve(A, ev('4', { ctrl: true })), 'toggleSnap');
+ assert.strictEqual(resolve(A, ev('o')), 'toggleMetronome');
+ assert.strictEqual(resolve(A, ev('f', { ctrl: true, shift: true })), 'toggleFollow');
+ assert.strictEqual(resolve(A, ev('l', { ctrl: true })), 'toggleLoopRegion');
+});
+
+// ── inheritance + shadows ────────────────────────────────────────────
+
+t('anything unoverridden falls through to its FeedBack meaning', () => {
+ assert.strictEqual(resolve(L, ev('h')), 'toggleHammerOn');
+ assert.strictEqual(resolve(A, ev('h')), 'toggleHammerOn');
+ assert.strictEqual(resolve(L, ev('R', { shift: true })), 'resnapSelection',
+ 'the old FeedBack key stays as a harmless alias');
+});
+
+t('a shadowed command relocates — and the rows SAY so', () => {
+ // Logical: K now clicks and C now cycles, so both of their old owners move.
+ assert.strictEqual(resolve(L, ev('k')), 'toggleMetronome');
+ assert.strictEqual(resolve(L, ev('k', { shift: true })), 'cyclePickDirection');
+ assert.strictEqual(resolve(L, ev('c', { ctrl: true, shift: true })), 'toggleGuideClap');
+ const lRows = Object.fromEntries(_editorShortcutRowsPure('logical').map(r => [r.id, r.key]));
+ assert.strictEqual(lRows.cyclePickDirection, 'Shift+K', 'relocated off the stolen K');
+ assert.strictEqual(lRows.toggleGuideClap, 'Ctrl+Shift+C', 'relocated off the stolen C');
+ assert.strictEqual(lRows.toggleMetronome, 'K');
+ assert.strictEqual(lRows.save, 'Ctrl+S', 'unoverridden displays the inherited FeedBack key');
+ // Cableton: O clicks, pop relocates; Ctrl+L loops, select-like relocates.
+ assert.strictEqual(resolve(A, ev('p', { ctrl: true, shift: true })), 'togglePop');
+ assert.strictEqual(resolve(A, ev('l', { ctrl: true, shift: true })), 'selectLike');
+ const aRows = Object.fromEntries(_editorShortcutRowsPure('cableton').map(r => [r.id, r.key]));
+ assert.strictEqual(aRows.togglePop, 'Ctrl+Shift+P');
+ assert.strictEqual(aRows.selectLike, 'Ctrl+Shift+L');
+});
+
+// ── the tempo-map overlay stays intact ───────────────────────────────
+
+t('tempo-map keys still resolve through the fall-through in both new profiles', () => {
+ assert.strictEqual(resolve(L, ev('b'), 'tempoMap'), 'tempoSetBpm');
+ assert.strictEqual(resolve(A, ev('g'), 'tempoMap'), 'tempoSuggestFit');
+});
+
+t('override sigs are disjoint from the FeedBack tempo-map overlay', () => {
+ const TEMPO_MAP_SIGS = ['T', 'B', 'M', 'Shift+B', 'N', '[', ']', 'D', 'S', 'G',
+ 'Shift+T', 'Alt+T', 'Ctrl+Shift+T', 'I', 'Insert', 'Delete', 'Backspace'];
+ assert.deepStrictEqual(_editorProfileCollisionsPure(L, TEMPO_MAP_SIGS), []);
+ assert.deepStrictEqual(_editorProfileCollisionsPure(A, TEMPO_MAP_SIGS), []);
+});
+
+// ── the two checks this class of feature actually fails on ───────────
+
+// Build a keydown from an override-table sig, so the table round-trips through
+// the very resolver the dispatcher calls — a table entry that cannot be typed,
+// or that resolves to something else, fails here.
+function evFromSig(sig) {
+ const parts = sig.split('+');
+ let key = parts.pop();
+ if (key === '') key = '+'; // "Ctrl++"
+ return {
+ key: key.length === 1 ? key.toLowerCase() : key, code: '',
+ ctrlKey: parts.includes('Ctrl'), metaKey: false,
+ shiftKey: parts.includes('Shift'), altKey: parts.includes('Alt'),
+ };
+}
+
+t('NO COMMAND LOSES ITS KEYBOARD: a chord a delta profile steals is relocated, never orphaned', () => {
+ const base = Object.fromEntries(_editorShortcutRowsPure('feedback').map(r => [r.id, r.key]));
+ for (const profile of ['logical', 'cableton']) {
+ const rows = _editorShortcutRowsPure(profile);
+ const orphans = rows.filter(r => base[r.id] && !r.key).map(r => r.id);
+ assert.deepStrictEqual(orphans, [], `${profile} strands: ${orphans.join(', ')}`);
+ }
+ // and every override in the table really resolves to the command it claims
+ for (const [profile, table] of Object.entries(EDITOR_PROFILE_OVERRIDES)) {
+ for (const [sig, id] of Object.entries(table)) {
+ assert.strictEqual(resolve(table, evFromSig(sig)), id, `${profile}: ${sig} should run ${id}`);
+ }
+ }
+});
+
+t('NO PROFILE BINDS A HOST-RESERVED CHORD (the Electron menu wins those)', () => {
+ // feedback-desktop's app menu registers roles reload / forceReload /
+ // toggleDevTools / quit / close. A menu accelerator is handled in the MAIN
+ // process before the renderer sees keydown, so preventDefault() cannot take
+ // it back: a chord bound here would reload (losing unsaved edits) instead of
+ // running the command. Ctrl+Shift+I is grandfathered — it is toggleIgnore on
+ // main in BOTH shipped profiles, so it is a pre-existing call, not ours.
+ const RESERVED = ['Ctrl+R', 'Ctrl+Shift+R', 'Ctrl+W', 'Ctrl+Q', 'F5'];
+ for (const [profile, table] of Object.entries(EDITOR_PROFILE_OVERRIDES)) {
+ const hits = Object.keys(table).filter(sig => RESERVED.includes(sig));
+ assert.deepStrictEqual(hits, [], `${profile} binds reserved ${hits.join(', ')}`);
+ }
+});
+
+// ── profile plumbing + the two new registry commands ─────────────────
+
+t('all four profiles are selectable; an unknown value falls back to feedback', () => {
+ editorSetShortcutProfile('logical');
+ assert.strictEqual(shortcuts.editorShortcutProfile, 'logical');
+ editorSetShortcutProfile('cableton');
+ assert.strictEqual(shortcuts.editorShortcutProfile, 'cableton');
+ editorSetShortcutProfile('eof');
+ assert.strictEqual(shortcuts.editorShortcutProfile, 'eof');
+ editorSetShortcutProfile('nonsense');
+ assert.strictEqual(shortcuts.editorShortcutProfile, 'feedback');
+ assert.strictEqual(EDITOR_PROFILE_NAMES.eof, 'Legacy (EOF)');
+});
+
+t('the EOF rows are untouched by the new profiles', () => {
+ const eofRows = Object.fromEntries(_editorShortcutRowsPure('eof').map(r => [r.id, r.key]));
+ assert.strictEqual(eofRows.resnapSelection, 'Shift+R');
+ assert.strictEqual(eofRows.save, 'F2 / Ctrl+S');
+});
+
+t('loop-region toggle and Song Fit are registry commands now (palette-findable)', () => {
+ const rows = _editorShortcutRowsPure('feedback');
+ const loop = rows.find(r => r.id === 'toggleLoopRegion');
+ const fit = rows.find(r => r.id === 'songFit');
+ assert.ok(loop && loop.status === 'ready');
+ assert.ok(fit && fit.status === 'ready');
+ const lRows = Object.fromEntries(_editorShortcutRowsPure('logical').map(r => [r.id, r.key]));
+ assert.strictEqual(lRows.toggleLoopRegion, 'C');
+});
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);