diff --git a/App.tsx b/App.tsx
index a3e0837..414d725 100644
--- a/App.tsx
+++ b/App.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { audioEngine } from './services/audioEngine';
import { DistortionSwitch } from './components/DistortionSwitch';
@@ -10,13 +10,87 @@ import { RiffBar } from './components/RiffBar';
import { useChordStore } from './stores/chordStore';
import { useRiffStore, MAX_RIFF_STEPS } from './stores/riffStore';
import { Chord, TuningMode, VibeMode } from './types';
-import { transposeChord } from './utils/musicTheory';
-import { syncUrlState } from './utils/urlState';
+import { resolveLibraryChord, tuningForMode } from './utils/libraryVoicing';
+import { handProfileHash } from './engine/handProfile';
+import { useHandProfileStore } from './stores/handProfileStore';
+import { parseUrlState, syncUrlState, type AppView } from './utils/urlState';
import { getChords } from './constants';
const CHORDS_PER_BATCH = 6;
const RELATED_CHORDS_COUNT = 6;
+const loadVoicingFinder = () => import('./components/VoicingFinder');
+const loadHandProfilePanel = () => import('./components/HandProfilePanel');
+const loadRhythmLab = () => import('./components/RhythmLab');
+const VoicingFinder = lazy(() => loadVoicingFinder().then((m) => ({ default: m.VoicingFinder })));
+const HandProfilePanel = lazy(() => loadHandProfilePanel().then((m) => ({ default: m.HandProfilePanel })));
+const RhythmLab = lazy(() => loadRhythmLab().then((m) => ({ default: m.RhythmLab })));
+
+const RHYTHM_LAB_ENABLED = true;
+
+const initialView = (): AppView => {
+ const view = parseUrlState().view ?? 'library';
+ return view === 'rhythm' && !RHYTHM_LAB_ENABLED ? 'library' : view;
+};
+
+const LazyFallback: React.FC = () => (
+
Loading...
+);
+
+interface ViewSwitchProps {
+ view: AppView;
+ onChange: (view: AppView) => void;
+ isDistorted: boolean;
+ showRhythmLab: boolean;
+}
+
+const ViewSwitch: React.FC = ({ view, onChange, isDistorted, showRhythmLab }) => {
+ const options: { id: AppView; label: string; prefetch?: () => void }[] = [
+ { id: 'library', label: 'LIBRARY' },
+ { id: 'finder', label: 'VOICING FINDER', prefetch: () => void loadVoicingFinder() },
+ ...(showRhythmLab ? [{ id: 'rhythm' as const, label: 'RHYTHM LAB', prefetch: () => void loadRhythmLab() }] : [])
+ ];
+
+ return (
+
+
+
+ WORKBENCH
+
+
+
+ {options.map((option) => {
+ const active = option.id === view;
+ return (
+
onChange(option.id)}
+ onPointerEnter={option.prefetch}
+ onFocus={option.prefetch}
+ aria-pressed={active}
+ className={`
+ relative h-11 md:h-10 px-1 flex items-center justify-center font-['Oswald'] tracking-widest text-xs font-bold transition-all duration-200
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:z-10
+ ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}
+ ${active
+ ? (isDistorted ? 'bg-neutral-800 text-white border border-rose-900/50' : 'bg-neutral-800 text-white border border-cyan-900/50')
+ : 'text-neutral-400 hover:text-neutral-200 border border-transparent'
+ }
+ `}
+ >
+ {option.label}
+ {active && (
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
+
const App: React.FC = () => {
const {
isDistorted,
@@ -53,6 +127,23 @@ const App: React.FC = () => {
const relatedSectionRef = useRef(null);
const [favoriteChords, setFavoriteChords] = useState([]);
+ const storedProfile = useHandProfileStore((state) => state.profile);
+ // Cards depend only on the fretting-hand fields: a tempo change must not reload the grid
+ const voicingProfileKey = handProfileHash({ ...storedProfile, comfortableSixteenthBpm: 0 });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const handProfile = useMemo(() => storedProfile, [voicingProfileKey]);
+ const tuning = tuningForMode(tuningMode);
+
+ const [view, setView] = useState(initialView);
+ const [handProfileOpen, setHandProfileOpen] = useState(false);
+ const [handProfileMounted, setHandProfileMounted] = useState(false);
+
+ const openHandProfile = useCallback(() => {
+ setHandProfileMounted(true);
+ setHandProfileOpen(true);
+ }, []);
+
+ const closeHandProfile = useCallback(() => setHandProfileOpen(false), []);
const riffSteps = useRiffStore((state) => state.steps);
const metronomeOn = useRiffStore((state) => state.metronomeOn);
@@ -74,10 +165,10 @@ const App: React.FC = () => {
resetLockState();
}, [tuningMode, vibeMode, resetLockState]);
- // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark
+ // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark&view=finder
useEffect(() => {
- syncUrlState(selectedRoot, tuningMode, vibeMode);
- }, [selectedRoot, tuningMode, vibeMode]);
+ syncUrlState(selectedRoot, tuningMode, vibeMode, view);
+ }, [selectedRoot, tuningMode, vibeMode, view]);
const handleUserInteraction = useCallback(async () => {
if (!isAudioReady) {
@@ -94,6 +185,8 @@ const App: React.FC = () => {
}, [isDistorted, isAudioReady, handleUserInteraction, setIsDistorted]);
const playChord = useCallback(async (chord: Chord) => {
+ // Unplayable cards have no shape, so there is nothing honest to play
+ if (!chord.voicing) return;
await handleUserInteraction();
audioEngine.playChord(chord.notes);
setActiveChordId(chord.id);
@@ -118,16 +211,10 @@ const App: React.FC = () => {
const loadFavoriteChords = async () => {
try {
const baseChords = await getChords(tuningMode, vibeMode);
- const transposed = baseChords
+ const resolved = baseChords
.filter((chord: Chord) => favorites.includes(chord.id))
- .map((chord: Chord) => {
- try {
- return transposeChord(chord, selectedRoot, tuningMode);
- } catch {
- return chord;
- }
- });
- if (!cancelled) setFavoriteChords(transposed);
+ .map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile));
+ if (!cancelled) setFavoriteChords(resolved);
} catch {
if (!cancelled) setFavoriteChords([]);
}
@@ -137,7 +224,7 @@ const App: React.FC = () => {
return () => {
cancelled = true;
};
- }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot]);
+ }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot, handProfile]);
const handleLockToggle = useCallback(async (chord: Chord) => {
if (lockedChordId === chord.id) {
@@ -149,16 +236,14 @@ const App: React.FC = () => {
const baseChords = await getChords(tuningMode, vibeMode);
- const originalParent = baseChords.find((p: Chord) => {
- const transposed = transposeChord(p, selectedRoot, tuningMode);
- return transposed.id === chord.id;
- });
+ // Ids are stable across roots, so the displayed id is the base id
+ const originalParent = baseChords.find((p: Chord) => p.id === chord.id);
if (originalParent && originalParent.relatedChords && originalParent.relatedChords.length > 0) {
- const transposedRelated = originalParent.relatedChords
+ const resolvedRelated = originalParent.relatedChords
.slice(0, RELATED_CHORDS_COUNT)
- .map((relatedChord: Chord) => transposeChord(relatedChord, selectedRoot, tuningMode));
- setRelatedChords(transposedRelated);
+ .map((relatedChord: Chord) => resolveLibraryChord(relatedChord, selectedRoot, tuningMode, handProfile));
+ setRelatedChords(resolvedRelated);
setTimeout(() => {
relatedSectionRef.current?.scrollIntoView({
@@ -170,7 +255,7 @@ const App: React.FC = () => {
setRelatedChords([]);
}
}
- }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, setLockedChordId, setRelatedChords]);
+ }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, handProfile, setLockedChordId, setRelatedChords]);
const isChordLocked = useCallback((chord: Chord) => {
if (!lockedChordId) return false;
@@ -181,10 +266,13 @@ const App: React.FC = () => {
setIsLoadingChords(true);
setDisplayedChords([]);
setChordsToLoad(6);
+ // A quick root/tuning/vibe change must not let an older load overwrite newer cards
+ let cancelled = false;
const loadChords = async () => {
try {
const baseChords = await getChords(tuningMode, vibeMode);
+ if (cancelled) return;
if (!baseChords || baseChords.length === 0) {
setDisplayedChords([]);
@@ -194,33 +282,22 @@ const App: React.FC = () => {
setTotalChordsAvailable(baseChords.length);
+ // Every tab on screen comes from an engine shape, so cards render already resolved
const firstBatch = baseChords.slice(0, CHORDS_PER_BATCH);
- const strippedChords = firstBatch.map((chord: Chord) => ({
- ...chord,
- relatedChords: undefined
- }));
-
- setDisplayedChords(strippedChords as Chord[]);
+ setDisplayedChords(firstBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile)));
setIsLoadingChords(false);
-
- setTimeout(() => {
- const transposed = firstBatch.map((chord: Chord) => {
- try {
- return transposeChord(chord, selectedRoot, tuningMode);
- } catch {
- return chord;
- }
- });
- setDisplayedChords(transposed);
- }, 150);
} catch {
+ if (cancelled) return;
setDisplayedChords([]);
setIsLoadingChords(false);
}
};
loadChords();
- }, [selectedRoot, tuningMode, vibeMode, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]);
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedRoot, tuningMode, vibeMode, handProfile, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]);
const loadMoreChords = useCallback(async () => {
if (isLoadingChords) return;
@@ -241,31 +318,14 @@ const App: React.FC = () => {
return;
}
- setDisplayedChords([...displayedChords, ...(nextBatch as Chord[])]);
+ const resolved = nextBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile));
+ setDisplayedChords([...displayedChords, ...resolved]);
incrementChordsToLoad(CHORDS_PER_BATCH);
-
- setTimeout(() => {
- const transposed = nextBatch.map((chord: Chord) => {
- try {
- return transposeChord(chord, selectedRoot, tuningMode);
- } catch {
- return chord;
- }
- });
-
- const newChords = [...displayedChords];
- const startIndex = displayedChords.length;
- transposed.forEach((chord: Chord, i: number) => {
- newChords[startIndex + i] = chord;
- });
- setDisplayedChords(newChords);
- }, 100);
-
setIsLoadingChords(false);
} catch {
setIsLoadingChords(false);
}
- }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]);
+ }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, handProfile, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]);
const gridChords = showFavoritesOnly ? favoriteChords : displayedChords;
@@ -368,13 +428,39 @@ const App: React.FC = () => {
isDistorted={isDistorted}
/>
-
+ {view === 'library' && (
+
+ )}
-
+
+
void loadHandProfilePanel()}
+ onFocus={() => void loadHandProfilePanel()}
+ aria-haspopup="dialog"
+ aria-expanded={handProfileOpen}
+ className={`
+ relative flex items-center gap-2 px-3 h-8 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200
+ after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']
+ bg-neutral-900/50 border-white/10 text-neutral-400 hover:text-neutral-200 hover:border-white/20
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black
+ ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}
+ `}
+ >
+
+
+
+
+
+
+ Hand profile
+
+ {view === 'library' && (
setShowFavoritesOnly(!showFavoritesOnly)}
@@ -405,6 +491,7 @@ const App: React.FC = () => {
Favorites{favorites.length > 0 ? ` (${favorites.length})` : ''}
+ )}
{
onSelectRoot={setSelectedRoot}
isDistorted={isDistorted}
/>
+
+
+ {view === 'finder' && (
+
+ }>
+
+
+
+ )}
+
+ {view === 'rhythm' && RHYTHM_LAB_ENABLED && (
+
+ }>
+
+
+
+ )}
+
+ {view === 'library' && (
+ <>
{showFavoritesOnly && gridChords.length === 0 ? (
{
>
{
-
+
{lockedChord.name}
@@ -573,6 +686,7 @@ const App: React.FC = () => {
>
{
)}
+ >
+ )}
{
+
+ {handProfileMounted && (
+
+ Loading...
+
+ }
+ >
+
+
+ )}
);
};
diff --git a/UPGRADE.md b/UPGRADE.md
index c75e7ea..f166c87 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -60,6 +60,10 @@ Production'da debug loglari kalmamali.
- [ ] 4-fret spread optimizasyonunu implement et VEYA
- [ ] Yorumlanmis kodu tamamen sil
+**Durum (2026-10-03): Gecersiz, `engine/` ile asildi.** `transposeTabs` kaldirildi. Kart tablari artik
+`engine/` (parmak atamasi, mm cinsinden uzanma, barresiz uretim) tarafindan `utils/libraryVoicing.ts`
+uzerinden uretiliyor; perde-acikligi kurali yerine kisisel el profili kullaniliyor.
+
---
## Orta Oncelikli Upgrades
diff --git a/components/ChordCard.tsx b/components/ChordCard.tsx
index 10eeb44..580dc47 100644
--- a/components/ChordCard.tsx
+++ b/components/ChordCard.tsx
@@ -1,10 +1,13 @@
import React, { useRef, useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Chord } from '../types';
-import { buildChordExplorerUrl } from '../utils/chordExplorer';
+import type { Tuning } from '../engine/types';
+import { buildExplorerUrl } from '../utils/chordExplorer';
+import { VoicingTab } from './VoicingTab';
interface ChordCardProps {
chord: Chord;
+ tuning: Tuning;
isDistorted: boolean;
onPlay: (chord: Chord) => void;
onLockToggle?: (chord: Chord) => void;
@@ -19,6 +22,7 @@ interface ChordCardProps {
export const ChordCard: React.FC = ({
chord,
+ tuning,
isDistorted,
onPlay,
onLockToggle,
@@ -35,6 +39,12 @@ export const ChordCard: React.FC = ({
const [glowPosition, setGlowPosition] = useState({ x: 0, y: 0 });
const [isHovering, setIsHovering] = useState(false);
const [hasAnimated, setHasAnimated] = useState(skipInitialAnimation);
+ // Link by what sounds; Chord Explorer is E Standard only, so a Drop D shape stays out of the link.
+ // No link when Chord Explorer has no type for the sound.
+ const explorerTarget = chord.voicing?.explorer ?? chord.explorer;
+ const explorerUrl = explorerTarget
+ ? buildExplorerUrl(explorerTarget, chord.voicing && tuning.id === 'e-standard' ? chord.voicing.shape : undefined)
+ : null;
// Mark as animated after first render
useEffect(() => {
@@ -73,6 +83,7 @@ export const ChordCard: React.FC = ({
: 'rgba(8, 145, 178, 0.4)';
const accentColor = isDistorted ? 'rose' : 'cyan';
+ const addable = canAddToRiff && !!chord.voicing;
return (
= ({
}}
/>
- {/* Main Card */}
- = ({
}
)
}}
- whileTap={{ scale: 0.98 }}
- transition={{ type: 'spring', stiffness: 400, damping: 25 }}
- onClick={(e) => {
- const target = e.target as HTMLElement;
- if (target.closest('button[aria-label="Lock as main chord"]')) {
- e.stopPropagation();
- e.preventDefault();
- return;
- }
- }}
>
{/* Border Glow on Hover */}
= ({
{/* Background Text Faded - First 2 letters of chord name */}
= ({
e.stopPropagation();
return;
}
- onPlay(chord);
+ if (chord.voicing) onPlay(chord);
}}
className="relative z-10 w-full text-left flex-1 flex flex-col cursor-pointer"
style={{ pointerEvents: 'auto' }}
@@ -182,7 +184,7 @@ export const ChordCard: React.FC = ({
= ({
{
e.stopPropagation();
- if (canAddToRiff) onAddToRiff(chord);
+ if (addable) onAddToRiff(chord);
}}
style={{
zIndex: 10,
@@ -296,28 +298,14 @@ export const ChordCard: React.FC = ({
{/* Lock Button - Animated */}
{onLockToggle && (
- {
- e.stopPropagation();
- e.preventDefault();
- e.nativeEvent.stopImmediatePropagation();
- if (onLockToggle) onLockToggle(chord);
- }}
- onMouseDown={(e) => {
- e.stopPropagation();
- e.preventDefault();
- }}
- >
+
= ({
transition={{ duration: 2, repeat: isLocked ? Infinity : 0 }}
onClick={(e) => {
e.stopPropagation();
- e.preventDefault();
- if (onLockToggle) onLockToggle(chord);
+ onLockToggle(chord);
}}
>
{/* Lock Icon SVG */}
@@ -384,7 +371,7 @@ export const ChordCard: React.FC = ({
}}
/>
-
+
)}
@@ -398,12 +385,13 @@ export const ChordCard: React.FC = ({
{
e.stopPropagation();
- onPlay(chord);
+ if (chord.voicing) onPlay(chord);
}}
className={`
- shrink-0 w-8 h-8 flex items-center justify-center rounded-full border
+ shrink-0 w-8 h-8 flex items-center justify-center rounded-full border disabled:opacity-30 disabled:cursor-default
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black
${isDistorted
? 'border-rose-900 text-rose-500 focus-visible:ring-rose-500'
@@ -425,63 +413,82 @@ export const ChordCard: React.FC = ({
- {/* Fretboard Data */}
-
+ {/* Fretboard Data: tab and audio both come from chord.voicing.shape */}
+
+
TABLATURE
-
e.stopPropagation()}
- className={`
- w-7 h-7 flex items-center justify-center rounded-full border border-white/10 text-neutral-500
- transition-colors duration-200
- focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black
- ${isDistorted ? 'hover:text-rose-400 hover:border-rose-500/50 focus-visible:ring-rose-500' : 'hover:text-cyan-400 hover:border-cyan-500/50 focus-visible:ring-cyan-500'}
- `}
- whileHover={{ scale: 1.15 }}
- whileTap={{ scale: 0.9 }}
- >
- e.stopPropagation()}
+ className={`
+ w-7 h-7 flex items-center justify-center rounded-full border border-white/10 text-neutral-500
+ transition-colors duration-200
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black
+ ${isDistorted ? 'hover:text-rose-400 hover:border-rose-500/50 focus-visible:ring-rose-500' : 'hover:text-cyan-400 hover:border-cyan-500/50 focus-visible:ring-cyan-500'}
+ `}
+ whileHover={{ scale: 1.15 }}
+ whileTap={{ scale: 0.9 }}
>
-
-
-
-
-
+
+
+
+
+
+
+ )}
-
- {chord.fretboard}
-
+ {chord.voicing && (
+
+ SOUNDS {chord.voicing.soundsAs}
+
+ )}
+
+ {chord.voicing ? (
+ <>
+
+ {chord.voicing.relaxed && (
+
+ Closest playable: {chord.voicing.relaxed.join(', ')}
+
+ )}
+ >
+ ) : (
+
+ No playable shape for your hand profile here. {' '}
+ {chord.unplayableReason}
+
+ )}
-
+
);
};
diff --git a/components/DistortionSwitch.tsx b/components/DistortionSwitch.tsx
index a06c3b8..eceedb8 100644
--- a/components/DistortionSwitch.tsx
+++ b/components/DistortionSwitch.tsx
@@ -90,9 +90,13 @@ const ChannelButton: React.FC = ({
return (
Array.from({ length: hi - lo + 1 }, (_, i) => lo + i);
+
+const presetIdFor = (mm: number): string | null => SCALE_LENGTH_PRESETS.find((p) => Math.abs(p.mm - mm) < 0.05)?.id ?? null;
+
+const useIsDesktop = (): boolean => {
+ const query = '(min-width: 768px)';
+ const [matches, setMatches] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches);
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const onChange = () => setMatches(mql.matches);
+ onChange();
+ mql.addEventListener('change', onChange);
+ return () => mql.removeEventListener('change', onChange);
+ }, []);
+ return matches;
+};
+
+/** Clipboard API first, then a hidden textarea + execCommand for older or locked-down browsers. */
+const copyText = async (text: string, host: HTMLElement | null): Promise => {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // Permission denied or insecure context: try the fallback
+ }
+ try {
+ const area = document.createElement('textarea');
+ area.value = text;
+ area.setAttribute('readonly', '');
+ area.style.position = 'fixed';
+ area.style.top = '0';
+ area.style.left = '0';
+ area.style.opacity = '0';
+ (host ?? document.body).appendChild(area);
+ area.select();
+ const ok = document.execCommand('copy');
+ area.remove();
+ return ok;
+ } catch {
+ return false;
+ }
+};
+
+const ringClass = (isDistorted: boolean): string =>
+ `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}`;
+
+const SectionLabel: React.FC<{ id?: string; children: React.ReactNode }> = ({ id, children }) => (
+
+ {children}
+
+);
+
+interface RadioOption {
+ value: string;
+ label: string;
+ ariaLabel?: string;
+}
+
+interface SegmentedRadioProps {
+ labelledBy: string;
+ describedBy?: string;
+ options: readonly RadioOption[];
+ value: string;
+ onChange: (value: string) => void;
+ isDistorted: boolean;
+}
+
+/** Single-select segmented control with radio semantics: one tab stop, arrow keys move the selection. */
+const SegmentedRadio: React.FC = ({ labelledBy, describedBy, options, value, onChange, isDistorted }) => {
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
+ const selected = options.findIndex((o) => o.value === value);
+ const tabStop = selected >= 0 ? selected : 0;
+
+ const select = (index: number) => {
+ const next = (index + options.length) % options.length;
+ onChange(options[next].value);
+ refs.current[next]?.focus();
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent, index: number) => {
+ const moves: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
+ if (e.key in moves) {
+ e.preventDefault();
+ select(index + moves[e.key]);
+ } else if (e.key === 'Home') {
+ e.preventDefault();
+ select(0);
+ } else if (e.key === 'End') {
+ e.preventDefault();
+ select(options.length - 1);
+ }
+ };
+
+ return (
+
+ {options.map((option, i) => {
+ const checked = i === selected;
+ return (
+ {
+ refs.current[i] = el;
+ }}
+ type="button"
+ role="radio"
+ aria-checked={checked}
+ aria-label={option.ariaLabel}
+ tabIndex={i === tabStop ? 0 : -1}
+ onClick={() => select(i)}
+ onKeyDown={(e) => onKeyDown(e, i)}
+ className={`
+ relative h-11 md:h-10 min-w-0 flex items-center justify-center font-['Oswald'] tracking-widest text-xs font-bold transition-all duration-200
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:z-10 ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}
+ ${checked
+ ? `bg-neutral-800 text-white border ${isDistorted ? 'border-rose-900/50' : 'border-cyan-900/50'}`
+ : 'text-neutral-400 hover:text-neutral-200 border border-transparent'}
+ `}
+ >
+ {option.label}
+ {checked && }
+
+ );
+ })}
+
+ );
+};
+
+interface SwitchRowProps {
+ label: string;
+ description: string;
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ isDistorted: boolean;
+}
+
+const SwitchRow: React.FC = ({ label, description, checked, onChange, isDistorted }) => {
+ const labelId = useId();
+ const descriptionId = useId();
+ return (
+ onChange(!checked)}
+ className={`w-full min-h-[44px] flex items-center justify-between gap-4 px-3 py-2.5 rounded-lg border text-left transition-colors duration-200 ${ringClass(isDistorted)}
+ ${checked
+ ? isDistorted ? 'border-rose-500/40 bg-rose-500/[0.07]' : 'border-cyan-500/40 bg-cyan-500/[0.07]'
+ : 'border-white/10 bg-neutral-900/50 hover:border-white/25'}`}
+ >
+
+ {label}
+ {description}
+
+
+
+ {checked ? 'On' : 'Off'}
+
+
+
+
+
+
+ );
+};
+
+interface StepperRowProps {
+ label: string;
+ unit: string;
+ value: number;
+ min: number;
+ max: number;
+ step: number;
+ onChange: (value: number) => void;
+ isDistorted: boolean;
+}
+
+const StepperRow: React.FC = ({ label, unit, value, min, max, step, onChange, isDistorted }) => {
+ const labelId = useId();
+ const clamp = (v: number) => Math.min(max, Math.max(min, v));
+ const buttonClass = `w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border border-white/15 font-mono text-base text-neutral-200 hover:border-white/40 transition-colors duration-200 disabled:opacity-30 disabled:cursor-default ${ringClass(isDistorted)}`;
+ return (
+
+
{label}
+
+ onChange(clamp(value - step))} className={buttonClass}>
+ −
+
+
+ {value}
+ {unit}
+
+ = max} onClick={() => onChange(clamp(value + step))} className={buttonClass}>
+ +
+
+
+
+ );
+};
+
+interface HandProfilePanelProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+/** Hand profile drawer: right panel on md+, bottom sheet on mobile. Modal, focus-trapped, Escape closes. */
+export const HandProfilePanel: React.FC = ({ open, onClose }) => {
+ const isDistorted = useChordStore((s) => s.isDistorted);
+ const profile = useHandProfileStore((s) => s.profile);
+ const calibration = useHandProfileStore((s) => s.calibration);
+ const setCalibration = useHandProfileStore((s) => s.setCalibration);
+ const resetProfile = useHandProfileStore((s) => s.resetProfile);
+ const exportProfileJson = useHandProfileStore((s) => s.exportProfileJson);
+
+ const reduceMotion = useReducedMotion();
+ const isDesktop = useIsDesktop();
+ const titleId = useId();
+ const scaleLabelId = useId();
+ const lowQuestionId = useId();
+ const highQuestionId = useId();
+ const customInputId = useId();
+ const customErrorId = useId();
+
+ const panelRef = useRef(null);
+ const titleRef = useRef(null);
+ const copyButtonRef = useRef(null);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+
+ const [customMode, setCustomMode] = useState(() => presetIdFor(calibration.scaleLengthMm) === null);
+ const [customText, setCustomText] = useState(() => String(Math.round(calibration.scaleLengthMm * 10) / 10));
+ const [status, setStatus] = useState('');
+
+ // Focus in on open, trap Tab, close on Escape, and hand focus back to the opener on close
+ useEffect(() => {
+ if (!open) return;
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ const frame = requestAnimationFrame(() => titleRef.current?.focus());
+
+ // Tab order only: roving radios (tabindex -1) and the programmatic title focus are not stops
+ const focusables = (): HTMLElement[] =>
+ panelRef.current ? Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter((el) => el.tabIndex >= 0) : [];
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ onCloseRef.current();
+ return;
+ }
+ if (e.key !== 'Tab') return;
+ const items = focusables();
+ if (items.length === 0) {
+ e.preventDefault();
+ return;
+ }
+ const index = items.indexOf(document.activeElement as HTMLElement);
+ if (e.shiftKey && index <= 0) {
+ e.preventDefault();
+ items[items.length - 1].focus();
+ } else if (!e.shiftKey && (index === -1 || index === items.length - 1)) {
+ e.preventDefault();
+ items[0].focus();
+ }
+ };
+ // Anything that still lands focus outside (a click elsewhere, assistive tech) is pulled back in
+ const onFocusIn = (e: FocusEvent) => {
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) titleRef.current?.focus();
+ };
+
+ document.addEventListener('keydown', onKeyDown);
+ document.addEventListener('focusin', onFocusIn);
+ return () => {
+ cancelAnimationFrame(frame);
+ document.removeEventListener('keydown', onKeyDown);
+ document.removeEventListener('focusin', onFocusIn);
+ document.body.style.overflow = previousOverflow;
+ if (opener && opener.isConnected) opener.focus();
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!status) return;
+ const timer = setTimeout(() => setStatus(''), 4000);
+ return () => clearTimeout(timer);
+ }, [status]);
+
+ const scaleValue = customMode ? CUSTOM_SCALE_ID : presetIdFor(calibration.scaleLengthMm) ?? CUSTOM_SCALE_ID;
+ const customNumber = Number(customText);
+ const customValid = customText.trim() !== '' && Number.isFinite(customNumber) && customNumber >= SCALE_MIN_MM && customNumber <= SCALE_MAX_MM;
+
+ const onScaleChange = (id: string) => {
+ if (id === CUSTOM_SCALE_ID) {
+ setCustomMode(true);
+ setCustomText(String(Math.round(calibration.scaleLengthMm * 10) / 10));
+ return;
+ }
+ const preset = SCALE_LENGTH_PRESETS.find((p) => p.id === id);
+ if (!preset) return;
+ setCustomMode(false);
+ setCalibration({ scaleLengthMm: preset.mm });
+ };
+
+ const onCustomChange = (text: string) => {
+ setCustomText(text);
+ const mm = Number(text);
+ if (text.trim() !== '' && Number.isFinite(mm) && mm >= SCALE_MIN_MM && mm <= SCALE_MAX_MM) setCalibration({ scaleLengthMm: mm });
+ };
+
+ const onCopy = useCallback(async () => {
+ const ok = await copyText(exportProfileJson(), panelRef.current);
+ copyButtonRef.current?.focus();
+ setStatus(ok ? 'Profile JSON copied. Paste it into Chord Explorer to use the same hand there.' : 'Copy failed: the browser blocked clipboard access.');
+ }, [exportProfileJson]);
+
+ const onReset = () => {
+ resetProfile();
+ setCustomMode(false);
+ setStatus('Hand profile reset to the defaults.');
+ };
+
+ const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400';
+ const pillClass = `flex items-center justify-center gap-1.5 px-4 h-11 md:h-9 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 ${ringClass(isDistorted)}`;
+
+ const transition = reduceMotion ? { duration: 0.12 } : { duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] as const };
+ const hidden = reduceMotion ? { opacity: 0 } : isDesktop ? { x: '100%' } : { y: '100%' };
+ const shown = reduceMotion ? { opacity: 1 } : isDesktop ? { x: 0 } : { y: 0 };
+
+ const lowReach = Math.round(profile.reachAtLowMm);
+ const highReach = Math.round(profile.reachAtHighMm);
+ const stretchOn = calibration.allowStretches;
+
+ return (
+
+ {open && (
+
+
+
+
+
+
+ Hand profile
+
+
+ Calibrate reach so every shape fits your fretting hand.
+
+
+
+
+
+
+
+
+
+
+
+
+ Scale length
+ ({ value: p.id, label: p.label, ariaLabel: `${p.label.replace('"', '')} inch scale` })),
+ { value: CUSTOM_SCALE_ID, label: 'Custom', ariaLabel: 'Custom scale length' }
+ ]}
+ value={scaleValue}
+ onChange={onScaleChange}
+ isDistorted={isDistorted}
+ />
+ {scaleValue === CUSTOM_SCALE_ID && (
+
+
+ Scale length in mm ({SCALE_MIN_MM} to {SCALE_MAX_MM})
+
+
+ onCustomChange(e.target.value)}
+ aria-invalid={!customValid}
+ aria-describedby={customValid ? undefined : customErrorId}
+ className={`w-32 h-11 md:h-10 px-3 rounded-lg bg-neutral-900/80 border font-mono text-sm text-neutral-100 ${ringClass(isDistorted)}
+ ${customValid ? 'border-white/15' : 'border-amber-400/70'}`}
+ />
+ {customValid && (
+ = {(customNumber / MM_PER_INCH).toFixed(2)}"
+ )}
+
+ {!customValid && (
+
+ Enter a length between {SCALE_MIN_MM} and {SCALE_MAX_MM} mm.
+
+ )}
+
+ )}
+
+
+
+ Reach check 1
+
+ Index finger on fret {CALIBRATION_LOW_INDEX_FRET} of the low string. Which is the highest fret your pinky
+ reaches comfortably on the same string?
+
+ ({ value: String(f), label: String(f), ariaLabel: `Fret ${f}` }))}
+ value={String(calibration.lowPinkyFret)}
+ onChange={(v) => setCalibration({ lowPinkyFret: Number(v) })}
+ isDistorted={isDistorted}
+ />
+
+
+
+ Reach check 2
+
+ Index finger on fret {CALIBRATION_HIGH_INDEX_FRET} of the low string. Which is the highest fret your pinky
+ reaches comfortably on the same string?
+
+ ({ value: String(f), label: String(f), ariaLabel: `Fret ${f}` }))}
+ value={String(calibration.highPinkyFret)}
+ onChange={(v) => setCalibration({ highPinkyFret: Number(v) })}
+ isDistorted={isDistorted}
+ />
+
+
+
+ Rules
+ setCalibration({ noBarre })}
+ isDistorted={isDistorted}
+ />
+ setCalibration({ allowStretches })}
+ isDistorted={isDistorted}
+ />
+ setCalibration({ allowOpenStrings })}
+ isDistorted={isDistorted}
+ />
+ setCalibration({ maxFret })}
+ isDistorted={isDistorted}
+ />
+ setCalibration({ comfortableSixteenthBpm })}
+ isDistorted={isDistorted}
+ />
+
+
+
+ Your reach
+
+
+
Low reach
+
+ {lowReach} mm (fret {CALIBRATION_LOW_INDEX_FRET} → {calibration.lowPinkyFret})
+
+
+
+
High reach
+
+ {highReach} mm (fret {CALIBRATION_HIGH_INDEX_FRET} → {calibration.highPinkyFret})
+
+
+
+
With stretch
+
+ {Math.round(profile.reachAtLowMm * STRETCH_TOLERANCE_ALLOW)} / {Math.round(profile.reachAtHighMm * STRETCH_TOLERANCE_ALLOW)} mm{' '}
+ ({stretchOn ? 'allowed' : 'off'})
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {HAND_PROFILE_DISCLAIMER}
+
+
+
+
+
+
+
+ Copy profile JSON
+
+
+ Reset
+
+
+ {status}
+
+
+
+
+
+ )}
+
+ );
+};
diff --git a/components/RhythmLab.tsx b/components/RhythmLab.tsx
new file mode 100644
index 0000000..b835978
--- /dev/null
+++ b/components/RhythmLab.tsx
@@ -0,0 +1,245 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { audioEngine } from '../services/audioEngine';
+import { useChordStore } from '../stores/chordStore';
+import { useRiffStore } from '../stores/riffStore';
+import { scheduleRhythmRestart, useRhythmHandProfile, useRhythmStore } from '../stores/rhythmStore';
+import { gridUnitTicks } from '../engine/rhythm/grid';
+import { rhythmToPlaybackEvents } from '../engine/rhythm/playback';
+import { validateRhythm } from '../engine/rhythm/validate';
+import type { HarmonySlot } from '../engine/types';
+import { tuningForMode } from '../utils/libraryVoicing';
+import { rhythmToMidi } from '../utils/midi';
+import { clickTimes, implicitPowerSlot, loopSeconds, secondsPerTick, toHarmonySlots } from '../utils/rhythmSlots';
+import { RhythmStylePanel } from './rhythm/RhythmStylePanel';
+import { RhythmTransport } from './rhythm/RhythmTransport';
+import { RhythmSlotsStrip } from './rhythm/RhythmSlotsStrip';
+import { RhythmGrid } from './rhythm/RhythmGrid';
+import { rhythmTheme } from './rhythm/theme';
+
+/** Restart debounce while a slider is dragged during playback. */
+const RESTART_DELAY_MS = 60;
+
+/**
+ * Metal Rhythm Lab: seeded rhythm patterns over up to four chord slots, with playback, metronome, MIDI
+ * export, an editable grid and the fingering optimizer. Self-contained: reads the chord, riff and rhythm stores.
+ */
+export const RhythmLab: React.FC = () => {
+ const isDistorted = useChordStore((s) => s.isDistorted);
+ const tuningMode = useChordStore((s) => s.tuningMode);
+ const selectedRoot = useChordStore((s) => s.selectedRoot);
+ const riffStepCount = useRiffStore((s) => s.steps.length);
+ const profile = useRhythmHandProfile();
+
+ const pattern = useRhythmStore((s) => s.pattern);
+ const slots = useRhythmStore((s) => s.slots);
+ const bpm = useRhythmStore((s) => s.bpm);
+ const isPlaying = useRhythmStore((s) => s.isPlaying);
+ const metronomeOn = useRhythmStore((s) => s.metronomeOn);
+ const setBpm = useRhythmStore((s) => s.setBpm);
+ const setMetronome = useRhythmStore((s) => s.setMetronome);
+ const setIsPlaying = useRhythmStore((s) => s.setIsPlaying);
+ const newIdea = useRhythmStore((s) => s.newIdea);
+ const mutate = useRhythmStore((s) => s.mutate);
+
+ const theme = rhythmTheme(isDistorted);
+ const tuning = tuningForMode(tuningMode);
+ const implicitSlot = useMemo(() => implicitPowerSlot(selectedRoot, tuning, profile), [selectedRoot, tuning, profile]);
+ const harmony: HarmonySlot[] = useMemo(
+ () => (slots.length > 0 ? toHarmonySlots(slots) : implicitSlot ? toHarmonySlots([implicitSlot]) : []),
+ [slots, implicitSlot]
+ );
+ const slotLabels = useMemo(() => slots.map((s) => s.label), [slots]);
+
+ const [announcement, setAnnouncement] = useState('');
+ const announce = useCallback((message: string) => {
+ // Clearing first makes screen readers repeat an identical message
+ setAnnouncement('');
+ window.setTimeout(() => setAnnouncement(message), 30);
+ }, []);
+
+ // Slots follow the app tuning (Standard / Drop): re-voice the ones made for the other tuning
+ useEffect(() => {
+ const dropped = useRhythmStore.getState().retuneSlots(tuning, profile);
+ if (dropped > 0) announce(`${dropped} slot${dropped === 1 ? '' : 's'} had no playable voicing in ${tuning.name} and were removed.`);
+ }, [tuning, profile, announce]);
+
+ const latest = useRef({ harmony, tuning });
+ latest.current = { harmony, tuning };
+
+ const play = useCallback(() => {
+ const state = useRhythmStore.getState();
+ const { pattern: current, bpm: tempo } = state;
+ const events = rhythmToPlaybackEvents(current, latest.current.harmony, latest.current.tuning, tempo);
+ audioEngine.playRhythm(events, {
+ loopSeconds: loopSeconds(current, tempo),
+ bpm: tempo,
+ clickTimes: clickTimes(current, tempo),
+ metronome: state.metronomeOn,
+ stepSeconds: gridUnitTicks(current.params.grid) * secondsPerTick(tempo),
+ onStep: (unit) => useRhythmStore.getState().setPlayheadUnit(unit)
+ });
+ }, []);
+
+ const startPlayback = useCallback(async () => {
+ const chord = useChordStore.getState();
+ if (!chord.isAudioReady || !audioEngine.isReady()) {
+ await audioEngine.init();
+ audioEngine.setDistortion(chord.isDistorted);
+ chord.setIsAudioReady(true);
+ }
+ // The RiffBar shares the Transport: stop it and reset its state before the rhythm takes over
+ const riff = useRiffStore.getState();
+ if (riff.isPlaying || audioEngine.isSequencePlaying()) {
+ audioEngine.stopSequence();
+ riff.setIsPlaying(false);
+ riff.setCurrentStep(-1);
+ }
+ play();
+ setIsPlaying(true);
+ }, [play, setIsPlaying]);
+
+ const stopPlayback = useCallback(() => {
+ audioEngine.stopRhythm();
+ setIsPlaying(false);
+ }, [setIsPlaying]);
+
+ // The RiffBar (playSequence) stops the rhythm through the engine: mirror it in the store
+ useEffect(
+ () =>
+ audioEngine.onRhythmStopped(() => {
+ useRhythmStore.getState().setIsPlaying(false);
+ }),
+ []
+ );
+
+ // Restart cleanly (same loop position) when the pattern, slots, tuning or tempo change mid-playback
+ useEffect(() => {
+ if (!useRhythmStore.getState().isPlaying) return;
+ return scheduleRhythmRestart(play, RESTART_DELAY_MS);
+ }, [pattern, harmony, tuning, bpm, play]);
+
+ useEffect(
+ () => () => {
+ if (audioEngine.isRhythmPlaying()) audioEngine.stopRhythm();
+ useRhythmStore.getState().setIsPlaying(false);
+ },
+ []
+ );
+
+ const handleMetronome = () => {
+ const next = !metronomeOn;
+ setMetronome(next);
+ audioEngine.setRhythmMetronome(next);
+ };
+
+ const handleNewIdea = () => {
+ newIdea();
+ const { pattern: next, lockedBars } = useRhythmStore.getState();
+ announce(`New idea: ${next.name}${lockedBars.length > 0 ? `, locked bars kept: ${lockedBars.map((b) => b + 1).join(', ')}` : ''}.`);
+ };
+
+ const handleMutate = () => {
+ const changed = mutate();
+ announce(changed ? 'Mutated: one small change outside the locked bars.' : 'No mutation found outside the locked bars.');
+ };
+
+ const handleExportMidi = () => {
+ const { pattern: current, bpm: tempo } = useRhythmStore.getState();
+ const bytes = rhythmToMidi(current, harmony, tuning, tempo);
+ const blob = new Blob([bytes], { type: 'audio/midi' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = `rhythm-${current.params.style}-${tempo}bpm.mid`;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+ announce(`Exported ${anchor.download}.`);
+ };
+
+ const handleUseRiffChords = () => {
+ const made = useRhythmStore.getState().useRiffBarChords(tuning, profile);
+ announce(made > 0 ? `${made} RiffBar chord${made === 1 ? '' : 's'} loaded as slots.` : 'No playable RiffBar chords to load.');
+ };
+
+ const warnings = useMemo(
+ () => validateRhythm(pattern, { bpm, comfortableSixteenthBpm: profile.comfortableSixteenthBpm }).warnings,
+ [pattern, bpm, profile.comfortableSixteenthBpm]
+ );
+
+ const meter = `${pattern.meter.numerator}/${pattern.meter.denominator}`;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {warnings.map((w) => (
+
+ Tempo note
+ {w.message} Comfortable up to about {w.bpmLimit} BPM for this pattern. This is comfort guidance, not a
+ medical or safety assessment. Stop if you feel pain or tension.
+
+ ))}
+
+
+ {announcement}
+
+
+
+ );
+};
diff --git a/components/RiffBar.tsx b/components/RiffBar.tsx
index c900aec..5e50229 100644
--- a/components/RiffBar.tsx
+++ b/components/RiffBar.tsx
@@ -285,7 +285,7 @@ export const RiffBar: React.FC = () => {
`}
>
{index + 1}
- {step.name}
+ {step.name}
+ `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}`;
+
+const activePill = (isDistorted: boolean): string =>
+ isDistorted
+ ? 'bg-rose-500/20 border-rose-500/60 text-rose-400 shadow-[0_0_12px_rgba(244,63,94,0.3)]'
+ : 'bg-cyan-500/20 border-cyan-500/60 text-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.3)]';
+
+const inactivePill = 'bg-neutral-900/50 border-white/10 text-neutral-400 hover:text-neutral-200 hover:border-white/30';
+
+const profileSummary = (profile: HandProfile, isDistorted: boolean): string =>
+ [
+ profile.noBarre ? 'No barre' : 'Barres allowed',
+ `reach ${Math.round(profile.reachAtLowMm)} mm${profile.stretchTolerance > 1 ? ' + stretch' : ''}`,
+ `up to fret ${profile.maxFret}`,
+ profile.allowOpenStrings ? null : 'no open strings',
+ isDistorted ? 'ranked for gain' : 'ranked for clean'
+ ]
+ .filter(Boolean)
+ .join(' · ');
+
+interface SegmentedProps {
+ label: string;
+ options: readonly { id: string; label: string }[];
+ value: string;
+ onChange: (value: string) => void;
+ isDistorted: boolean;
+}
+
+/** The app's segmented selector (aria-pressed buttons), sized for 44 px touch targets on mobile. */
+const Segmented: React.FC = ({ label, options, value, onChange, isDistorted }) => (
+
+ {options.map((option) => {
+ const active = option.id === value;
+ return (
+ onChange(option.id)}
+ className={`
+ relative h-11 md:h-10 min-w-0 px-1 flex items-center justify-center font-['Oswald'] tracking-widest text-xs font-bold uppercase transition-all duration-200
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:z-10 ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}
+ ${active
+ ? `bg-neutral-800 text-white border ${isDistorted ? 'border-rose-900/50' : 'border-cyan-900/50'}`
+ : 'text-neutral-400 hover:text-neutral-200 border border-transparent'}
+ `}
+ >
+ {option.label}
+ {active && }
+
+ );
+ })}
+
+);
+
+const ControlLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+);
+
+const NoveltyMeter: React.FC<{ novelty: number; isDistorted: boolean }> = ({ novelty, isDistorted }) => {
+ const percent = noveltyPercent(novelty);
+ const lit = Math.round((percent / 100) * NOVELTY_SEGMENTS);
+ return (
+
+ Unusual
+
+ {Array.from({ length: NOVELTY_SEGMENTS }, (_, i) => (
+
+ ))}
+
+ {percent}%
+
+ );
+};
+
+interface FinderCardProps {
+ voicing: GeneratedVoicing;
+ index: number;
+ root: string;
+ family: VoicingFamily;
+ tuning: Tuning;
+ profile: HandProfile;
+ showNovelty: boolean;
+ canAdd: boolean;
+ canSlot: boolean;
+ isDistorted: boolean;
+ reduceMotion: boolean;
+ onPlay: (voicing: GeneratedVoicing) => void;
+ onAdd: (voicing: GeneratedVoicing) => void;
+ onSlot: (voicing: GeneratedVoicing) => void;
+}
+
+const FinderCard: React.FC = ({
+ voicing,
+ index,
+ root,
+ family,
+ tuning,
+ profile,
+ showNovelty,
+ canAdd,
+ canSlot,
+ isDistorted,
+ reduceMotion,
+ onPlay,
+ onAdd,
+ onSlot
+}) => {
+ const tab = toRiffForgeTab(voicing.shape);
+ const explorerUrl = finderExplorerUrl(root, family.id, voicing, tuning);
+ const tags = voicingTagLabels(voicing.tags);
+ const ring = ringClass(isDistorted);
+ const roundButton = `shrink-0 w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border transition-colors duration-200 ${ring}`;
+
+ return (
+
+
+
+
{voicing.name}
+
+ Degrees low to high:
+ {voicing.degrees}
+
+
+ {showNovelty &&
}
+
+
+
+
+
+
+ {explainVoicing(voicing, profile)}
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
onPlay(voicing)}
+ className={`${roundButton} ${isDistorted ? 'border-rose-900 text-rose-400 hover:border-rose-500' : 'border-neutral-700 text-cyan-400 hover:border-cyan-400'}`}
+ >
+
+
+
+
+
onAdd(voicing)}
+ className={`flex items-center gap-1.5 px-3 h-11 md:h-9 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 disabled:opacity-40 disabled:cursor-default ${ring} ${inactivePill}`}
+ >
+
+
+
+
+ Riff
+
+
onSlot(voicing)}
+ className={`flex items-center gap-1.5 px-3 h-11 md:h-9 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 disabled:opacity-40 disabled:cursor-default ${ring} ${inactivePill}`}
+ >
+
+
+
+
+
+ Slot
+
+ {explorerUrl && (
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+interface VoicingFinderProps {
+ onOpenHandProfile: () => void;
+ handProfileOpen?: boolean;
+}
+
+/** Playable voicings of one family on the selected root and tuning, ranked for the stored hand profile. */
+export const VoicingFinder: React.FC = ({ onOpenHandProfile, handProfileOpen = false }) => {
+ const isDistorted = useChordStore((s) => s.isDistorted);
+ const selectedRoot = useChordStore((s) => s.selectedRoot);
+ const tuningMode = useChordStore((s) => s.tuningMode);
+ const profile = useHandProfileStore((s) => s.profile);
+ const setCalibration = useHandProfileStore((s) => s.setCalibration);
+ const riffLength = useRiffStore((s) => s.steps.length);
+ const addStep = useRiffStore((s) => s.addStep);
+ const slotCount = useRhythmStore((s) => s.slots.length);
+ const addSlot = useRhythmStore((s) => s.addSlot);
+ const reduceMotion = useReducedMotion() ?? false;
+
+ const titleId = useId();
+ const statusRef = useRef(null);
+ const [familyId, setFamilyId] = useState(FINDER_DEFAULT_FAMILY);
+ const [sort, setSort] = useState('easiest');
+ const [stringGroup, setStringGroup] = useState('all');
+ const [pedalOnly, setPedalOnly] = useState(false);
+ // "One more muted inner string" is not a calibration answer: it stays a finder-only override for one query
+ const [extraMuteFor, setExtraMuteFor] = useState(null);
+ const [actionMessage, setActionMessage] = useState('');
+
+ const tuning = tuningForMode(tuningMode);
+ const family = getFamily(familyId) ?? (getFamily(FINDER_DEFAULT_FAMILY) as VoicingFamily);
+ const overrideKey = `${selectedRoot}|${family.id}|${tuning.id}`;
+ const extraMute = extraMuteFor === overrideKey;
+
+ const effectiveProfile = useMemo(
+ () => (extraMute ? { ...profile, maxInteriorMutes: profile.maxInteriorMutes + 1 } : profile),
+ [profile, extraMute]
+ );
+
+ const query: FinderQuery = useMemo(
+ () => ({ root: selectedRoot, familyId: family.id, tuning, profile: effectiveProfile, distortion: isDistorted, sort }),
+ [selectedRoot, family.id, tuning, effectiveProfile, isDistorted, sort]
+ );
+ const result = useMemo(() => findVoicings(query), [query]);
+ const filtered = useMemo(() => filterVoicings(result.voicings, { stringGroup, pedalOnly }), [result, stringGroup, pedalOnly]);
+ const shown = filtered.slice(0, FINDER_SHOW_LIMIT);
+ const hints = useMemo(() => (result.voicings.length === 0 ? relaxationHints(query, extraMute) : []), [result, query, extraMute]);
+
+ const filtersActive = stringGroup !== 'all' || pedalOnly;
+ const capped = !filtersActive && result.voicings.length >= FINDER_GENERATE_LIMIT;
+ const announcement = resultAnnouncement(filtered.length, selectedRoot, family);
+ const canAdd = riffLength < MAX_RIFF_STEPS;
+
+ useEffect(() => {
+ if (!actionMessage) return;
+ const timer = setTimeout(() => setActionMessage(''), 3000);
+ return () => clearTimeout(timer);
+ }, [actionMessage]);
+
+ const playVoicing = useCallback(
+ async (voicing: GeneratedVoicing) => {
+ const chord = useChordStore.getState();
+ if (!chord.isAudioReady) {
+ await audioEngine.init();
+ audioEngine.setDistortion(chord.isDistorted);
+ chord.setIsAudioReady(true);
+ }
+ // Same shape as the tab on the card
+ audioEngine.playChord(shapeToMidi(voicing.shape, tuning).map(midiToName));
+ },
+ [tuning]
+ );
+
+ const addVoicing = useCallback(
+ (voicing: GeneratedVoicing) => {
+ const tab = toRiffForgeTab(voicing.shape);
+ const added = addStep({
+ id: `finder:${family.id}:${tab}`,
+ name: voicing.name,
+ subtext: `${family.label} · ${tab}`,
+ notes: shapeToMidi(voicing.shape, tuning).map(midiToName)
+ });
+ setActionMessage(added ? `Added ${voicing.name} to the riff.` : `The riff is full (${MAX_RIFF_STEPS} steps).`);
+ },
+ [addStep, family, tuning]
+ );
+
+ const slotVoicing = useCallback(
+ (voicing: GeneratedVoicing) => {
+ const added = addSlot({
+ label: voicing.name,
+ root: selectedRoot,
+ source: { kind: 'family', familyId: voicing.familyId },
+ shape: [...voicing.shape],
+ tuningId: tuning.id
+ });
+ setActionMessage(
+ added
+ ? `${voicing.name} is now Rhythm Lab slot ${useRhythmStore.getState().slots.length}.`
+ : `The Rhythm Lab already has ${MAX_RHYTHM_SLOTS} chord slots.`
+ );
+ },
+ [addSlot, selectedRoot, tuning]
+ );
+
+ const applyHint = (hint: RelaxationHint) => {
+ if (hint.kind === 'calibration') setCalibration(hint.patch);
+ else setExtraMuteFor(overrideKey);
+ // The hint button disappears with the empty state; keep keyboard focus on the result status
+ requestAnimationFrame(() => statusRef.current?.focus());
+ };
+
+ const clearFilters = () => {
+ setStringGroup('all');
+ setPedalOnly(false);
+ requestAnimationFrame(() => statusRef.current?.focus());
+ };
+
+ const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400';
+ const ring = ringClass(isDistorted);
+
+ return (
+
+
+
+
+
+ Voicing finder
+
+
+ {selectedRoot}
+ ·
+ in
+ {tuning.name}
+
+
{profileSummary(effectiveProfile, isDistorted)}
+
+
+
+ Hand profile
+
+
+
+
+ {FAMILY_GROUPS.map((group) => (
+
+
{group.label}
+
+ {group.familyIds.map((id) => {
+ const f = getFamily(id);
+ if (!f) return null;
+ const active = id === family.id;
+ return (
+ setFamilyId(id)}
+ className={`min-h-[44px] min-w-[44px] md:min-h-[32px] md:min-w-0 px-3 rounded-full border font-mono text-xs transition-all duration-200 ${ring} ${active ? activePill(isDistorted) : inactivePill}`}
+ >
+ {f.label}
+
+ );
+ })}
+
+
+ ))}
+
+
+
{family.description}
+
+
+
+ Sort
+ setSort(v as VoicingSort)} isDistorted={isDistorted} />
+
+
+ Strings
+ setStringGroup(v as StringGroupFilter)}
+ isDistorted={isDistorted}
+ />
+
+
+ Pedal
+ setPedalOnly(!pedalOnly)}
+ title="Low string unused or open on the root or fifth, so a pedal note can ring under the shape"
+ className={`w-full md:w-auto flex items-center justify-center gap-2 px-4 h-11 md:h-[50px] rounded-lg border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 ${ring} ${pedalOnly ? activePill(isDistorted) : inactivePill}`}
+ >
+
+ Pedal compatible
+
+
+
+
+
+
+
+
+ {filtered.length}
+ {capped ? '+' : ''}
+ {' '}
+ {filtered.length === 1 ? 'voicing' : 'voicings'} for {familyTitle(selectedRoot, family)}
+ {filtered.length > shown.length ? ` · showing ${shown.length}` : ''}
+ {filtersActive && result.voicings.length > filtered.length ? ` · ${result.voicings.length - filtered.length} filtered out` : ''}
+
+ {extraMute && (
+
setExtraMuteFor(null)}
+ aria-label="Remove the extra muted inner string"
+ className={`flex items-center gap-1.5 px-3 h-11 md:h-8 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 ${ring} ${activePill(isDistorted)}`}
+ >
+ +1 inner mute
+
+
+
+
+
+ )}
+
+
+
+ {announcement}
+
+
+ {actionMessage}
+
+
+ {shown.length > 0 ? (
+
+ {shown.map((voicing, index) => (
+
+ ))}
+
+ ) : (
+
+ {result.voicings.length === 0 ? (
+ <>
+
No playable shape here
+
+ {result.empty?.message ?? 'No voicing fits this recipe and your hand profile.'}
+
+ {hints.length > 0 && (
+
+ {hints.map((hint) => (
+ applyHint(hint)}
+ className={`flex items-center gap-1.5 px-4 min-h-[44px] md:min-h-[36px] rounded-full border font-mono text-[11px] tracking-wide transition-all duration-200 ${ring} ${activePill(isDistorted)}`}
+ >
+ {hintText(hint)}
+
+ ))}
+
+ )}
+ >
+ ) : (
+ <>
+
No voicings match these filters
+
+ {result.voicings.length} {result.voicings.length === 1 ? 'voicing fits' : 'voicings fit'} your hand without them.
+
+
+ Clear filters
+
+ >
+ )}
+
+ )}
+
+ );
+};
+
+const HandIcon: React.FC = () => (
+
+
+
+
+
+
+);
diff --git a/components/VoicingTab.tsx b/components/VoicingTab.tsx
new file mode 100644
index 0000000..6c40479
--- /dev/null
+++ b/components/VoicingTab.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import type { FingerNumber, Tuning } from '../engine/types';
+import { pitchClassName } from '../engine/pitch';
+
+interface VoicingTabProps {
+ shape: readonly (number | null)[];
+ fingers: readonly (FingerNumber | null)[];
+ degreesByString: readonly (string | null)[];
+ tuning: Tuning;
+ isDistorted: boolean;
+ compact?: boolean;
+}
+
+const FINGER_LABEL: Record = { 0: 'T', 1: '1', 2: '2', 3: '3', 4: '4' };
+const FINGER_NAME: Record = { 0: 'thumb', 1: 'index', 2: 'middle', 3: 'ring', 4: 'pinky' };
+
+/** Screen-reader summary, low string first: "E string open, A string fret 2 middle finger, ...". */
+export const describeVoicing = (
+ shape: readonly (number | null)[],
+ fingers: readonly (FingerNumber | null)[],
+ tuning: Tuning
+): string =>
+ shape
+ .map((fret, s) => {
+ const string = `${pitchClassName(tuning.openMidi[s])} string`;
+ if (fret === null) return `${string} muted`;
+ if (fret === 0) return `${string} open`;
+ const finger = fingers[s];
+ return `${string} fret ${fret}${finger !== null && finger !== undefined ? ` ${FINGER_NAME[finger]} finger` : ''}`;
+ })
+ .join(', ');
+
+/** Tab grid, low string on the left: string names, frets, finger numbers and degrees per string. */
+export const VoicingTab: React.FC = ({ shape, fingers, degreesByString, tuning, isDistorted, compact = false }) => {
+ const accent = isDistorted ? 'text-rose-500' : 'text-cyan-500';
+ const glow = isDistorted ? '0 0 8px rgba(225, 29, 72, 0.6)' : '0 0 8px rgba(8, 145, 178, 0.6)';
+ const cell = compact ? 'w-6' : 'w-7';
+ const rowLabel = 'w-11 shrink-0 text-left font-mono text-[11px] uppercase tracking-wider text-neutral-400';
+
+ return (
+
+
{describeVoicing(shape, fingers, tuning)}
+
+
+ Str
+ {shape.map((_, s) => (
+
+ {pitchClassName(tuning.openMidi[s])}
+
+ ))}
+
+
+ Fret
+ {shape.map((fret, s) => (
+
+ {fret === null ? 'x' : fret}
+
+ ))}
+
+
+ Fing
+ {fingers.map((finger, s) => (
+
+ {finger === null || finger === undefined ? '·' : FINGER_LABEL[finger]}
+
+ ))}
+
+
+ Deg
+ {degreesByString.map((degree, s) => (
+
+ {degree ?? ''}
+
+ ))}
+
+
+
+ );
+};
diff --git a/components/rhythm/RhythmGrid.tsx b/components/rhythm/RhythmGrid.tsx
new file mode 100644
index 0000000..159a1db
--- /dev/null
+++ b/components/rhythm/RhythmGrid.tsx
@@ -0,0 +1,452 @@
+import React, { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { useReducedMotion } from 'framer-motion';
+import type { RhythmPattern } from '../../engine/types';
+import { barUnits, buildGroups, cycleGrouping, isPolymeter } from '../../engine/rhythm/grid';
+import { useRhythmStore } from '../../stores/rhythmStore';
+import {
+ describeCell,
+ gridColumns,
+ laneIdOf,
+ rhythmLanes,
+ toggleTargetFor,
+ unitPosition,
+ type GridColumn,
+ type RhythmLane,
+ type UnitPosition
+} from '../../utils/rhythmSlots';
+import { SECTION_LABEL, SEGMENT_GROUP, segmentClass, type RhythmTheme } from './theme';
+
+type EditKind = 'hit' | 'accent' | 'palmMute' | 'clear';
+
+const TOOLS: { id: EditKind; label: string; key: string }[] = [
+ { id: 'hit', label: 'Hit', key: 'Space' },
+ { id: 'accent', label: 'Accent', key: 'A' },
+ { id: 'palmMute', label: 'PM', key: 'P' },
+ { id: 'clear', label: 'Clear', key: 'Del' }
+];
+
+const TOOL_NAMES: Record = { hit: 'Toggle hit', accent: 'Cycle accent', palmMute: 'Toggle palm mute', clear: 'Clear cell' };
+
+interface RhythmGridProps {
+ theme: RhythmTheme;
+ slotLabels: readonly string[];
+ onAnnounce: (message: string) => void;
+}
+
+const GLYPH: Record = { pedal: '●', slot: '■', dyad: 'dy', dead: 'x' };
+
+interface CellProps {
+ laneIndex: number;
+ lane: RhythmLane;
+ column: GridColumn;
+ position: UnitPosition;
+ groupStart: boolean;
+ locked: boolean;
+ focused: boolean;
+ label: string;
+ theme: RhythmTheme;
+ register: (key: string, el: HTMLDivElement | null) => void;
+ onActivate: (laneIndex: number, unit: number) => void;
+ onFocusCell: (laneIndex: number, unit: number) => void;
+}
+
+const Cell: React.FC = memo(
+ ({ laneIndex, lane, column, position, groupStart, locked, focused, label, theme, register, onActivate, onFocusCell }) => {
+ const own = column.lane === lane.id ? column.events.filter((e) => laneIdOf(e.target) === lane.id) : [];
+ const first = own[0];
+ const held = !first && column.heldLane === lane.id;
+ const attack = first && !first.tie;
+ const kind = first?.target.kind;
+ const separator = position.isBarStart
+ ? 'border-l-2 border-l-white/40'
+ : position.isBeatStart
+ ? 'border-l border-l-white/20'
+ : 'border-l border-l-white/[0.05]';
+ const hitStyle = !first
+ ? ''
+ : first.tie
+ ? `border border-dashed ${theme.border}`
+ : kind === 'pedal'
+ ? 'bg-neutral-700/60 border border-neutral-400/50'
+ : kind === 'dead'
+ ? 'border border-dashed border-neutral-400/70'
+ : `border ${theme.hitChord}`;
+
+ return (
+ register(`${laneIndex}:${column.unit}`, el)}
+ role="gridcell"
+ tabIndex={focused ? 0 : -1}
+ aria-label={label}
+ onClick={() => onActivate(laneIndex, column.unit)}
+ onFocus={() => onFocusCell(laneIndex, column.unit)}
+ className={`relative shrink-0 w-[var(--cell)] h-11 md:h-10 cursor-pointer select-none ${separator} ${
+ locked ? theme.lockedCell : ''
+ } hover:bg-white/[0.04] ${theme.ringInset}`}
+ >
+ {groupStart && !position.isBarStart && (
+
+ )}
+ {held && }
+ {first && (
+
+ {attack && first.accent > 0 ? '>'.repeat(first.accent) : ''}
+
+ {first.tie ? '~' : GLYPH[kind ?? 'slot']}
+
+
+ {attack && first.palmMute ? 'PM' : ''}
+ {attack ? own.filter((e) => !e.tie).map((e) => (e.pick === 'down' ? '↓' : '↑')).join('') : ''}
+
+
+ )}
+
+ );
+ }
+);
+Cell.displayName = 'RhythmGridCell';
+
+/** Playhead column highlight; subscribes on its own so the grid does not re-render on every step. */
+const Playhead: React.FC<{ theme: RhythmTheme; reduceMotion: boolean }> = ({ theme, reduceMotion }) => {
+ const unit = useRhythmStore((s) => s.playheadUnit);
+ const isPlaying = useRhythmStore((s) => s.isPlaying);
+ const previous = useRef(-1);
+ const wrapped = unit < previous.current;
+ useEffect(() => {
+ previous.current = unit;
+ }, [unit]);
+ if (!isPlaying || unit < 0) return null;
+ return (
+
+ );
+};
+
+const groupSummary = (pattern: RhythmPattern): string => {
+ const sizes = cycleGrouping(pattern.params, pattern.seed);
+ const unit = pattern.params.grid === '16th-triplet' ? '16th triplets' : '16ths';
+ const meter = `${pattern.meter.numerator}/${pattern.meter.denominator}`;
+ const cycle = sizes.reduce((a, b) => a + b, 0);
+ return isPolymeter(pattern.params)
+ ? `Accent groups ${sizes.join('+')} (${unit}): a ${cycle}-unit cycle drifting over ${meter}`
+ : `Accent groups ${sizes.join('+')} (${unit}) per ${cycle === barUnits(pattern.meter, pattern.params.grid) ? 'bar' : 'cycle'} in ${meter}`;
+};
+
+/**
+ * The rhythm grid: one lane per target (Pedal, Slot 1..N, Dead), one column per grid unit. A single tab stop
+ * (role="grid", roving tabindex): arrows move, Home/End jump, PageUp/PageDown move a bar, Space or Enter
+ * toggles a hit in the lane, A cycles the accent, P toggles palm mute, Delete/Backspace clears. Clicks and
+ * taps apply the selected tool.
+ */
+export const RhythmGrid: React.FC = ({ theme, slotLabels, onAnnounce }) => {
+ const pattern = useRhythmStore((s) => s.pattern);
+ const lockedBars = useRhythmStore((s) => s.lockedBars);
+ const toggleLockBar = useRhythmStore((s) => s.toggleLockBar);
+ const reduceMotion = useReducedMotion() ?? false;
+ const hintId = useId();
+ const [tool, setTool] = useState('hit');
+
+ const columns = useMemo(() => gridColumns(pattern), [pattern]);
+ const positions = useMemo(
+ () => columns.map((c) => unitPosition(c.unit, pattern.meter, pattern.params.grid)),
+ [columns, pattern.meter, pattern.params.grid]
+ );
+ const lanes = useMemo(() => rhythmLanes(pattern.params.slotCount, slotLabels), [pattern.params.slotCount, slotLabels]);
+ const groups = useMemo(() => buildGroups(pattern.params, pattern.seed), [pattern.params, pattern.seed]);
+ const groupStarts = useMemo(() => new Map(groups.map((g) => [g.startUnit, g.length])), [groups]);
+ const unitsPerBar = barUnits(pattern.meter, pattern.params.grid);
+ const lockedSet = useMemo(() => new Set(lockedBars), [lockedBars]);
+
+ const [focus, setFocus] = useState({ lane: 0, unit: 0 });
+ const laneIndex = Math.min(focus.lane, lanes.length - 1);
+ const unitIndex = Math.min(focus.unit, columns.length - 1);
+ const cells = useRef(new Map());
+ const pendingFocus = useRef(false);
+
+ const register = useCallback((key: string, el: HTMLDivElement | null) => {
+ if (el) cells.current.set(key, el);
+ else cells.current.delete(key);
+ }, []);
+
+ useEffect(() => {
+ if (!pendingFocus.current) return;
+ pendingFocus.current = false;
+ const el = cells.current.get(`${laneIndex}:${unitIndex}`);
+ el?.focus();
+ el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }, [laneIndex, unitIndex]);
+
+ // Latest values for the stable callbacks the memoized cells receive
+ const latest = useRef({ lanes, columns, tool, pattern });
+ latest.current = { lanes, columns, tool, pattern };
+
+ const applyEdit = useCallback(
+ (kind: EditKind, l: number, u: number) => {
+ const { lanes: ls, columns: cs } = latest.current;
+ const lane = ls[l];
+ const column = cs[u];
+ if (!lane || !column) return;
+ const store = useRhythmStore.getState();
+ const before = store.pattern;
+ if (kind === 'hit') store.toggleHitAt(u, toggleTargetFor(lane, column));
+ else if (kind === 'accent') store.cycleAccentAt(u);
+ else if (kind === 'palmMute') store.togglePalmMuteAt(u);
+ else store.clearHitAt(u);
+
+ const after = useRhythmStore.getState().pattern;
+ const position = unitPosition(u, after.meter, after.params.grid);
+ const nextColumn = gridColumns(after)[u];
+ if (JSON.stringify(after.events) === JSON.stringify(before.events)) {
+ onAnnounce(`${TOOL_NAMES[kind]}: nothing to change here. ${describeCell(position, lane, nextColumn)}`);
+ return;
+ }
+ const nextLanes = rhythmLanes(after.params.slotCount, slotLabels);
+ const owner = nextLanes.find((x) => x.id === nextColumn.lane) ?? lane;
+ onAnnounce(describeCell(position, owner, nextColumn));
+ },
+ [onAnnounce, slotLabels]
+ );
+
+ const onFocusCell = useCallback((l: number, u: number) => {
+ setFocus((f) => (f.lane === l && f.unit === u ? f : { lane: l, unit: u }));
+ }, []);
+
+ const onActivate = useCallback(
+ (l: number, u: number) => {
+ setFocus({ lane: l, unit: u });
+ applyEdit(latest.current.tool, l, u);
+ },
+ [applyEdit]
+ );
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.altKey || e.metaKey) return;
+ let l = laneIndex;
+ let u = unitIndex;
+ const key = e.key;
+ if ((key === ' ' || key === 'Enter') && !e.ctrlKey) {
+ e.preventDefault();
+ applyEdit('hit', l, u);
+ return;
+ }
+ if ((key === 'a' || key === 'A') && !e.ctrlKey) {
+ e.preventDefault();
+ applyEdit('accent', l, u);
+ return;
+ }
+ if ((key === 'p' || key === 'P') && !e.ctrlKey) {
+ e.preventDefault();
+ applyEdit('palmMute', l, u);
+ return;
+ }
+ if (key === 'Delete' || key === 'Backspace') {
+ e.preventDefault();
+ applyEdit('clear', l, u);
+ return;
+ }
+ switch (key) {
+ case 'ArrowUp':
+ l -= 1;
+ break;
+ case 'ArrowDown':
+ l += 1;
+ break;
+ case 'ArrowLeft':
+ u -= 1;
+ break;
+ case 'ArrowRight':
+ u += 1;
+ break;
+ case 'Home':
+ u = 0;
+ if (e.ctrlKey) l = 0;
+ break;
+ case 'End':
+ u = columns.length - 1;
+ if (e.ctrlKey) l = lanes.length - 1;
+ break;
+ case 'PageUp':
+ u -= unitsPerBar;
+ break;
+ case 'PageDown':
+ u += unitsPerBar;
+ break;
+ default:
+ return;
+ }
+ e.preventDefault();
+ const next = { lane: Math.max(0, Math.min(lanes.length - 1, l)), unit: Math.max(0, Math.min(columns.length - 1, u)) };
+ pendingFocus.current = true;
+ setFocus(next);
+ if (next.lane === laneIndex && next.unit === unitIndex) {
+ pendingFocus.current = false;
+ cells.current.get(`${laneIndex}:${unitIndex}`)?.focus();
+ }
+ };
+
+ const bars = Array.from({ length: pattern.bars }, (_, b) => b);
+
+ return (
+
+
+
+
+ Tap tool
+
+
+ {TOOLS.map((t) => (
+ setTool(t.id)}
+ className={segmentClass(theme, tool === t.id)}
+ >
+ {t.label}
+ {tool === t.id && }
+
+ ))}
+
+
+
+ Keys: arrows move, Home/End jump, PgUp/PgDn bar, Space hit, A accent, P palm mute, Del clears.
+
+
+
+
+
+ {/* Bars with lock toggles: outside the grid so the grid stays one tab stop */}
+
+
+ Bar
+
+ {bars.map((b) => {
+ const locked = lockedSet.has(b);
+ return (
+
+
Bar {b + 1}
+
toggleLockBar(b)}
+ className={`inline-flex items-center gap-1 px-2 min-h-[44px] md:min-h-0 md:h-7 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-colors ${theme.ring} ${
+ locked ? theme.pillActive : 'border-white/10 text-neutral-400 hover:text-neutral-100 hover:border-white/30'
+ }`}
+ >
+
+
+
+
+ {locked ? 'Locked' : 'Lock'}
+
+
+ );
+ })}
+
+
+ {/* Accent groups (visual brackets; the summary below says the same in words) */}
+
+
+ Groups
+
+ {columns.map((c) => {
+ const length = groupStarts.get(c.unit);
+ return (
+
+ {length ?? ''}
+
+ );
+ })}
+
+
+
+
+
+
+ Count
+
+ {positions.map((p, u) => (
+
+ {p.label}
+
+ ))}
+
+ {lanes.map((lane, l) => (
+
+
+ {lane.id === 'pedal' ? 'Ped' : lane.id === 'dead' ? 'Dead' : `S${Number(lane.id.slice(5)) + 1}`}
+ {lane.label}
+
+ {columns.map((column, u) => (
+
|
+ ))}
+
+ ))}
+
+
+
+
+
+
{groupSummary(pattern)}
+
+ {GLYPH.slot} chord dy dyad{' '}
+ {GLYPH.pedal} pedal x dead{' '}
+ ~ tie > >> accent{' '}
+ PM palm mute {'↓↑'} pick{' '}
+ — held
+
+
+
+ );
+};
diff --git a/components/rhythm/RhythmSlotsStrip.tsx b/components/rhythm/RhythmSlotsStrip.tsx
new file mode 100644
index 0000000..cfbd5c4
--- /dev/null
+++ b/components/rhythm/RhythmSlotsStrip.tsx
@@ -0,0 +1,171 @@
+import React from 'react';
+import type { FingerNumber, HandProfile, Tuning } from '../../engine/types';
+import { getTuning } from '../../engine/tuning';
+import { degreesByString } from '../../engine/naming';
+import { midiToName, parsePitchClass } from '../../engine/pitch';
+import { toRiffForgeTab } from '../../engine/shape';
+import { MAX_RHYTHM_SLOTS, useRhythmStore, type RhythmSlot } from '../../stores/rhythmStore';
+import { displayFingering, slotCandidates, slotShapesKey } from '../../utils/rhythmSlots';
+import { VoicingTab } from '../VoicingTab';
+import { SECTION_LABEL, pillClass, type RhythmTheme } from './theme';
+
+interface RhythmSlotsStripProps {
+ theme: RhythmTheme;
+ isDistorted: boolean;
+ tuning: Tuning;
+ profile: HandProfile;
+ /** Power chord on the selected root, played by slot hits while there are no slots. */
+ implicitSlot: RhythmSlot | null;
+ onAnnounce: (message: string) => void;
+}
+
+const slotTuning = (slot: RhythmSlot, fallback: Tuning): Tuning => getTuning(slot.tuningId) ?? fallback;
+
+const SlotTab: React.FC<{ slot: RhythmSlot; fingers: readonly (FingerNumber | null)[]; tuning: Tuning; isDistorted: boolean }> = ({
+ slot,
+ fingers,
+ tuning,
+ isDistorted
+}) => (
+
+);
+
+/** Up to four harmony slots with finger numbers, voicing cycling, removal and the fingering optimizer. */
+export const RhythmSlotsStrip: React.FC = ({ theme, isDistorted, tuning, profile, implicitSlot, onAnnounce }) => {
+ const slots = useRhythmStore((s) => s.slots);
+ const optimization = useRhythmStore((s) => s.optimization);
+ const removeSlot = useRhythmStore((s) => s.removeSlot);
+ const setSlotShape = useRhythmStore((s) => s.setSlotShape);
+ const optimizeSlots = useRhythmStore((s) => s.optimizeSlots);
+
+ const optimized = optimization !== null && optimization.shapesKey === slotShapesKey(slots) ? optimization : null;
+
+ const fingersFor = (slot: RhythmSlot, index: number): (FingerNumber | null)[] =>
+ optimized?.fingers[index] ?? displayFingering(slot.shape, slotTuning(slot, tuning), profile)?.fingers ?? slot.shape.map(() => null);
+
+ const cycleVoicing = (slot: RhythmSlot, index: number) => {
+ const candidates = slotCandidates(slot, tuning, profile);
+ const keys = candidates.map((c) => toRiffForgeTab(c.shape));
+ const current = keys.indexOf(toRiffForgeTab(slot.shape));
+ const next = candidates[(current + 1) % Math.max(1, candidates.length)];
+ if (!next || candidates.length < 2) {
+ onAnnounce(`Slot ${index + 1}: no other playable voicing for your hand profile.`);
+ return;
+ }
+ setSlotShape(slot.id, next.shape);
+ onAnnounce(`Slot ${index + 1}, ${slot.label}: now ${toRiffForgeTab(next.shape)}, ${next.name}.`);
+ };
+
+ const handleOptimize = () => {
+ const out = optimizeSlots(profile, tuning);
+ onAnnounce(out.ok === true ? `Fingerings optimized. ${out.text}` : out.message);
+ };
+
+ const handleRemove = (slot: RhythmSlot, index: number) => {
+ removeSlot(slot.id);
+ onAnnounce(`Removed slot ${index + 1}, ${slot.label}.`);
+ };
+
+ return (
+
+
+
+ Chord slots {slots.length}/{MAX_RHYTHM_SLOTS}
+
+
+ Optimize fingerings
+
+
+
+ {optimized && (
+
+ Optimized: {optimized.text}
+
+ )}
+
+ {slots.length === 0 ? (
+
+
+
No chord slots yet.
+
+ Send voicings here from the Voicing Finder, or press “Use RiffBar chords as slots”. Up to four
+ slots; accented hits move the harmony between them.
+
+ {implicitSlot && (
+
+ Until then, chord hits play {implicitSlot.label} on the selected root.
+
+ )}
+
+ {implicitSlot && (
+
+ null)}
+ tuning={tuning}
+ isDistorted={isDistorted}
+ />
+
+ )}
+
+ ) : (
+
+ {slots.map((slot, index) => {
+ const own = slotTuning(slot, tuning);
+ return (
+
+
+
+
Slot {index + 1}
+
+ {slot.label}
+
+
+ Pedal {slot.pedalMidi === null ? 'bass note' : midiToName(slot.pedalMidi)}
+
+
+
+ cycleVoicing(slot, index)}
+ aria-label={`Try another voicing for slot ${index + 1}, ${slot.label}`}
+ title="Next playable voicing"
+ className={`w-11 h-11 md:w-8 md:h-8 flex items-center justify-center rounded-full border border-white/10 text-neutral-300 hover:text-white hover:border-white/30 font-mono text-[10px] uppercase transition-colors ${theme.ring}`}
+ >
+ Alt
+
+ handleRemove(slot, index)}
+ aria-label={`Remove slot ${index + 1}, ${slot.label}`}
+ className={`w-11 h-11 md:w-8 md:h-8 flex items-center justify-center rounded-full border border-white/10 text-neutral-400 hover:text-white hover:border-white/30 transition-colors ${theme.ring}`}
+ >
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+};
diff --git a/components/rhythm/RhythmStylePanel.tsx b/components/rhythm/RhythmStylePanel.tsx
new file mode 100644
index 0000000..48bdc48
--- /dev/null
+++ b/components/rhythm/RhythmStylePanel.tsx
@@ -0,0 +1,177 @@
+import React, { useId } from 'react';
+import type { PickingMode, RhythmGrid, RhythmStyleId } from '../../engine/types';
+import { RHYTHM_STYLES, RHYTHM_STYLE_IDS } from '../../engine/rhythm/styles';
+import { RHYTHM_BAR_OPTIONS, useRhythmStore } from '../../stores/rhythmStore';
+import { SECTION_LABEL, SEGMENT_GROUP, segmentClass, type RhythmTheme } from './theme';
+
+interface RhythmStylePanelProps {
+ theme: RhythmTheme;
+ onAnnounce: (message: string) => void;
+}
+
+interface SegmentOption {
+ value: T;
+ label: string;
+ aria: string;
+}
+
+const GRID_OPTIONS: SegmentOption[] = [
+ { value: '16th', label: '16th', aria: 'Sixteenth-note grid' },
+ { value: '16th-triplet', label: '16th trip', aria: 'Sixteenth-triplet grid' }
+];
+
+const PICKING_OPTIONS: SegmentOption[] = [
+ { value: 'alternate', label: 'Alternate', aria: 'Alternate picking' },
+ { value: 'downstrokes', label: 'Downs', aria: 'All downstrokes' }
+];
+
+const Segmented = ({
+ label,
+ options,
+ value,
+ onChange,
+ theme
+}: {
+ label: string;
+ options: SegmentOption[];
+ value: T;
+ onChange: (value: T) => void;
+ theme: RhythmTheme;
+}): React.ReactElement => (
+
+
+ {label}
+
+
+ {options.map((option) => {
+ const active = option.value === value;
+ return (
+ onChange(option.value)}
+ className={segmentClass(theme, active)}
+ >
+ {option.label}
+ {active && }
+
+ );
+ })}
+
+
+);
+
+const Slider: React.FC<{
+ label: string;
+ value: number;
+ onChange: (value: number) => void;
+ theme: RhythmTheme;
+ hint: string;
+}> = ({ label, value, onChange, theme, hint }) => {
+ const id = useId();
+ const percent = Math.round(value * 100);
+ return (
+
+
+ {label}
+ {percent}%
+
+ onChange(Number(e.target.value) / 100)}
+ className={`w-full h-11 md:h-6 cursor-pointer bg-transparent ${theme.rangeAccent} ${theme.ring}`}
+ />
+
+ );
+};
+
+/** Style presets, density / syncopation sliders, grid, bars and picking. */
+export const RhythmStylePanel: React.FC = ({ theme, onAnnounce }) => {
+ const params = useRhythmStore((s) => s.params);
+ const setStyle = useRhythmStore((s) => s.setStyle);
+ const setParam = useRhythmStore((s) => s.setParam);
+ const style = RHYTHM_STYLES[params.style];
+
+ const chooseStyle = (id: RhythmStyleId) => {
+ setStyle(id);
+ onAnnounce(`${RHYTHM_STYLES[id].label}: ${RHYTHM_STYLES[id].description}`);
+ };
+
+ return (
+
+
+
+ Style preset
+
+
+ {RHYTHM_STYLE_IDS.map((id) => {
+ const active = id === params.style;
+ return (
+ chooseStyle(id)}
+ className={segmentClass(theme, active)}
+ >
+ {RHYTHM_STYLES[id].label}
+ {active && }
+
+ );
+ })}
+
+
{style.description}
+
+
+
+ setParam({ density })}
+ theme={theme}
+ hint="how many grid cells get a hit"
+ />
+ setParam({ syncopation })}
+ theme={theme}
+ hint="offbeat accents and pushes"
+ />
+
+
+
+ setParam({ grid })}
+ theme={theme}
+ />
+ ({ value: bars, label: String(bars), aria: `${bars} bar${bars === 1 ? '' : 's'}` }))}
+ value={params.bars}
+ onChange={(bars) => setParam({ bars })}
+ theme={theme}
+ />
+ setParam({ picking })}
+ theme={theme}
+ />
+
+
+ );
+};
diff --git a/components/rhythm/RhythmTransport.tsx b/components/rhythm/RhythmTransport.tsx
new file mode 100644
index 0000000..9f2214d
--- /dev/null
+++ b/components/rhythm/RhythmTransport.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { MAX_BPM, MIN_BPM } from '../../stores/rhythmStore';
+import { SECTION_LABEL, pillClass, type RhythmTheme } from './theme';
+
+export const BPM_STEP = 5;
+
+interface RhythmTransportProps {
+ theme: RhythmTheme;
+ isPlaying: boolean;
+ metronomeOn: boolean;
+ bpm: number;
+ riffStepCount: number;
+ onPlayToggle: () => void;
+ onMetronomeToggle: () => void;
+ onBpmChange: (bpm: number) => void;
+ onNewIdea: () => void;
+ onMutate: () => void;
+ onExportMidi: () => void;
+ onUseRiffChords: () => void;
+}
+
+const roundButton = (theme: RhythmTheme, active: boolean): string => `
+ flex items-center justify-center w-11 h-11 rounded-full border transition-all duration-200 ${theme.ring}
+ ${active ? `${theme.pillActive}` : 'border-white/15 text-neutral-300 hover:border-white/40 hover:text-white'}
+`;
+
+const Icon: React.FC<{ path: React.ReactNode; className?: string }> = ({ path, className = 'w-3.5 h-3.5' }) => (
+
+ {path}
+
+);
+
+/** Play/stop, metronome, BPM, idea actions and MIDI export. */
+export const RhythmTransport: React.FC = ({
+ theme,
+ isPlaying,
+ metronomeOn,
+ bpm,
+ riffStepCount,
+ onPlayToggle,
+ onMetronomeToggle,
+ onBpmChange,
+ onNewIdea,
+ onMutate,
+ onExportMidi,
+ onUseRiffChords
+}) => (
+
+
+
+ {isPlaying ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ >} />
+
+
+
+
+
onBpmChange(bpm - BPM_STEP)}
+ disabled={bpm <= MIN_BPM}
+ className={`${roundButton(theme, false)} text-base disabled:opacity-30 disabled:cursor-not-allowed md:w-8 md:h-8`}
+ >
+ −
+
+
+ {bpm}
+ BPM
+
+
onBpmChange(bpm + BPM_STEP)}
+ disabled={bpm >= MAX_BPM}
+ className={`${roundButton(theme, false)} text-base disabled:opacity-30 disabled:cursor-not-allowed md:w-8 md:h-8`}
+ >
+ +
+
+
+
+
+
+ >} />
+ New idea
+
+
+ >} />
+ Mutate
+
+
+ >} />
+ MIDI
+
+
+ >} />
+ Use RiffBar chords as slots
+
+
+
+);
diff --git a/components/rhythm/theme.tsx b/components/rhythm/theme.tsx
new file mode 100644
index 0000000..b02f24c
--- /dev/null
+++ b/components/rhythm/theme.tsx
@@ -0,0 +1,88 @@
+import type React from 'react';
+
+/** Accent classes for the Rhythm Lab. Clean channel = cyan, Distortion channel = rose (full literals for Tailwind). */
+export interface RhythmTheme {
+ ring: string;
+ ringColor: string;
+ ringInset: string;
+ text: string;
+ textStrong: string;
+ border: string;
+ segmentActive: string;
+ underline: string;
+ pillActive: string;
+ rangeAccent: string;
+ hitChord: string;
+ hitGlyph: string;
+ groupMark: string;
+ groupLine: string;
+ playhead: string;
+ lockedCell: string;
+ scrollbar: React.CSSProperties;
+ glow: string;
+}
+
+const CYAN: RhythmTheme = {
+ ring: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:ring-cyan-500',
+ ringColor: 'focus-visible:ring-cyan-500',
+ ringInset: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cyan-400',
+ text: 'text-cyan-400',
+ textStrong: 'text-cyan-500',
+ border: 'border-cyan-500/60',
+ segmentActive: 'bg-neutral-800 text-white border border-cyan-900/50',
+ underline: 'bg-cyan-500',
+ pillActive: 'bg-cyan-500/20 border-cyan-500/60 text-cyan-400 shadow-[0_0_12px_rgba(34,211,238,0.3)]',
+ rangeAccent: 'accent-cyan-500',
+ hitChord: 'bg-cyan-500/25 border-cyan-400/70',
+ hitGlyph: 'text-cyan-100',
+ groupMark: 'border-cyan-400 text-cyan-300',
+ groupLine: 'border-cyan-500/40',
+ playhead: 'bg-cyan-400/15 border-x border-cyan-400/70',
+ lockedCell: 'bg-cyan-500/[0.04]',
+ scrollbar: { scrollbarWidth: 'thin', scrollbarColor: 'rgba(34,211,238,0.3) transparent' },
+ glow: '0 0 8px rgba(8, 145, 178, 0.6)'
+};
+
+const ROSE: RhythmTheme = {
+ ring: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black focus-visible:ring-rose-500',
+ ringColor: 'focus-visible:ring-rose-500',
+ ringInset: 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-400',
+ text: 'text-rose-400',
+ textStrong: 'text-rose-500',
+ border: 'border-rose-500/60',
+ segmentActive: 'bg-neutral-800 text-white border border-rose-900/50',
+ underline: 'bg-rose-500',
+ pillActive: 'bg-rose-500/20 border-rose-500/60 text-rose-400 shadow-[0_0_12px_rgba(244,63,94,0.3)]',
+ rangeAccent: 'accent-rose-500',
+ hitChord: 'bg-rose-500/25 border-rose-400/70',
+ hitGlyph: 'text-rose-100',
+ groupMark: 'border-rose-400 text-rose-300',
+ groupLine: 'border-rose-500/40',
+ playhead: 'bg-rose-400/15 border-x border-rose-400/70',
+ lockedCell: 'bg-rose-500/[0.05]',
+ scrollbar: { scrollbarWidth: 'thin', scrollbarColor: 'rgba(244,63,94,0.3) transparent' },
+ glow: '0 0 8px rgba(225, 29, 72, 0.6)'
+};
+
+export const rhythmTheme = (isDistorted: boolean): RhythmTheme => (isDistorted ? ROSE : CYAN);
+
+/** Small meaningful labels use neutral-400 (>= 4.5:1 on the dark panels). */
+export const SECTION_LABEL = 'text-[10px] font-mono text-neutral-400 uppercase tracking-widest';
+
+/** RiffBar pill, with a 44 px touch height below md. */
+export const pillClass = (theme: RhythmTheme, active = false): string => `
+ inline-flex items-center justify-center gap-1.5 px-3 min-h-[44px] md:min-h-0 md:h-8 rounded-full border
+ font-mono text-[10px] uppercase tracking-widest transition-all duration-200
+ disabled:opacity-40 disabled:cursor-not-allowed ${theme.ring}
+ ${active ? theme.pillActive : 'border-white/10 text-neutral-300 hover:text-white hover:border-white/30'}
+`;
+
+/** Segmented-selector option (TuningSelector / VibeSelector look). */
+export const segmentClass = (theme: RhythmTheme, active: boolean): string => `
+ relative min-h-[44px] md:min-h-0 md:h-10 px-2 flex items-center justify-center text-center
+ font-['Oswald'] tracking-widest text-xs font-bold uppercase transition-all duration-200
+ focus-visible:outline-none focus-visible:ring-2 focus-visible:z-10 ${theme.ringColor}
+ ${active ? theme.segmentActive : 'text-neutral-400 hover:text-neutral-200 border border-transparent'}
+`;
+
+export const SEGMENT_GROUP = 'grid gap-1 bg-neutral-900/50 p-1 rounded-lg border border-white/5';
diff --git a/engine/README.md b/engine/README.md
new file mode 100644
index 0000000..7d0bb2b
--- /dev/null
+++ b/engine/README.md
@@ -0,0 +1,187 @@
+# RiffForge playability engine
+
+Pure TypeScript engine that answers two questions for one guitarist's hand:
+
+1. Which voicings of a chord color can I actually play here, without a barre, and how hard is each one?
+2. Which metal rhythm patterns go with them, and how hard are the changes between them?
+
+No React, no Tone.js, no DOM, no Node APIs and no runtime dependencies. Everything is deterministic:
+the same input gives the same output in the same order, and the only randomness is a seeded PRNG.
+The engine is developed here and vendored into Chord Explorer (`lib/engine/`) by that repo's
+`scripts/sync-engine.mjs`. Bump `ENGINE_VERSION` in `index.ts` on any behaviour change.
+
+The hand profile is comfort guidance, not a medical or safety assessment. Stop if you feel pain or tension.
+
+## Conventions
+
+- **String order:** index 0 is the lowest-pitched string, everywhere in the engine.
+- **Tuning:** `{ id, name, openMidi }`, low to high. Presets: `E_STANDARD`, `DROP_D`, `D_STANDARD`, `DROP_C`,
+ `C_STANDARD`. `validateTuning` accepts 4..8 strictly ascending strings; the UI exposes the first two.
+- **Shape:** `(number | null)[]` per string, `null` muted, `0` open.
+- **Pitch:** `shapeToMidi(shape, tuning)` is the only shape -> pitch conversion (`openMidi[s] + fret`). Tabs and audio
+ must both come from it. Note names are spelled with sharps; flats are accepted when parsing (`Bb2`).
+- **Adapters:** `toRiffForgeTab` / `fromRiffForgeTab` ("0 2 2 x x x", low -> high) and `toChordExplorerFrets` /
+ `fromChordExplorerFrets` (high -> low, `-1` muted).
+- **Imports:** relative only. Never the `@/` alias (it means something else in Chord Explorer).
+- **Narrowing:** the apps' tsconfig is not `strict`, so `if (!result.ok)` does not narrow the result unions.
+ Write `result.ok === false` / `=== true`.
+
+## Modules
+
+| File | Purpose |
+|---|---|
+| `types.ts` | Shared types (shapes, profiles, fingerings, costs, families, generation, transitions, rhythm). |
+| `pitch.ts` | Pitch classes, MIDI <-> names, interval names, degree labels (`1 b2 2 b3 3 4 b5 5 #5 6 b7 7`). |
+| `tuning.ts` | Tuning presets and validation. |
+| `random.ts` | `mulberry32`, `seedFromString` (FNV-1a), `createRng` (int, chance, pick, weighted, shuffle, fork). |
+| `geometry.ts` | Fret wire distance `d(n) = L - L / 2^(n/12)`, fingertip model, spans, reachable frets, scale presets. |
+| `shape.ts` | `shapeToMidi` and shape helpers (sounding/fretted/open strings, interior mutes, fret range), adapters. |
+| `handProfile.ts` | `HandProfile`, defaults, calibration conversion, `maxReachMm`, pair limits, validation, migration, hash. |
+| `weights.ts` | `PLAYABILITY_WEIGHTS`: every cost weight with the reasoning for its default. |
+| `fingering.ts` | Exhaustive finger assignment: barre detection, finger order, reach and pair limits, physical cost. |
+| `voicingSpec.ts` | Voicing families (interval recipes), drone variants, UI groups, `matchFamily`, legacy recipes, relaxation ladder. |
+| `naming.ts` | Honest degree strings, confident-only chord symbols, descriptive names. |
+| `playability.ts` | Full cost (physical, right hand, musical), novelty score, human cost explanations. |
+| `generateVoicings.ts` | Windowed, pruned search for playable voicings; diversification; empty-result diagnosis. |
+| `transition.ts` | Transition cost between fingered shapes; exact minimax Viterbi; `optimizeSequence`. |
+| `rhythm/` | Rhythm grid math, style presets, seeded generator, variation, editing helpers, validation, playback and MIDI note events. |
+| `index.ts` | Public barrel and `ENGINE_VERSION`. |
+
+## Main entry points
+
+```ts
+generateVoicings({ root, family, tuning, profile, distortion, context, musical, limit, sort, seed, referenceShapes, diversify })
+ -> { voicings: GeneratedVoicing[]; stats; empty?: { constraint, message } }
+findClosestVoicing(params) // exact recipe, then the relaxation ladder; { result, relaxed, exactEmpty? }
+findBestFingering(shape, profile) // -> { ok: true, fingering } | { ok: false, reason, detail }
+classifyShape(shape, profile) // needs-barre classification for curated shapes
+fromLegacyNotes(notes, baseRoot) // RiffForge JSON `notes` -> recipe
+nameVoicing(shape, tuning, rootPc) // degrees, symbol, name
+scoreVoicing / explainCost(breakdown, fingering, profile) / noveltyScore
+resolveFamilyId(id) // registered and relaxed ('power5_b2~anyBass') family ids
+transitionCost / optimizeSequence(candidates, timing, { profile }) / describeTransition
+generateRhythm / mutateRhythm / regenerateRhythm / validateRhythm / setSlotCount / buildGroups
+toggleHit / cycleAccent / togglePalmMute / clearHit / eventAtUnit / eventsInUnit
+rhythmToPlaybackEvents / rhythmToMidiNotes
+profileFromCalibration / calibrationFromProfile / validateHandProfile / migrateHandProfile / handProfileHash
+```
+
+## How generation works
+
+1. For every window anchored at its lowest fretted fret `w` (1..maxFret), the window reaches up to
+ `highestReachableFret(w, maxReachMm(w) * stretchTolerance)`. One extra pass covers open-string-only shapes.
+2. Each string can be muted, open (if allowed and its pitch class fits) or fretted inside the window on an allowed
+ pitch class.
+3. Depth-first search with pruning: missing required pitch classes vs strings left, note count, fretted notes vs
+ available fingers (no-barre), interior mutes, and a structural lower bound on the fingers a shape needs.
+4. Leaves go through `matchFamily` (exact pitch classes, nothing outside required + optional, bass by pitch, drone
+ rule), then `findBestFingering`, then `scoreVoicing`. Above `LAZY_SEARCH_MIN_SHAPES` (2048) feasible shapes the
+ fingering search is lazy and exact (same voicings, partial fingering-stage counts in `stats`).
+5. Candidates are sorted by cost (`easiest`) or novelty (`unusual`), then picked greedily for diversity (+0.6 per
+ already-picked voicing on the same string set within 2 frets). Results come back in that pick order, so a later
+ voicing can be cheaper than an earlier one when the earlier one opened a new position or string set.
+6. An empty result runs a wider diagnostic search and names the rule that blocks the most nearly-valid shapes
+ (`REACH`, `NEEDS_BARRE`, `NO_DRONE_STRING`, ...).
+
+Fingering rules: each fretted note needs a finger (1 index .. 4 pinky; thumb 0 only when allowed, lowest string
+only). In no-barre mode every finger presses exactly one string, so more than 4 fretted notes is `NEEDS_BARRE`.
+A barre is detected from the assignment (one finger on 2+ strings), never from a name. Lower-numbered fingers may
+not sit on higher frets than higher-numbered ones. The fingertip span must fit `maxReachMm(lowestFret) *
+stretchTolerance`, and each consecutive finger pair must fit its share of that span.
+
+## Assumptions
+
+Defaults chosen where the spec left room. Change them deliberately; most have tests.
+
+- Scale length defaults to 25.5" (647.7 mm). The fingertip sits 70 percent of the way from the previous fret wire.
+- Default reach = fingertip span index fret 1 -> pinky fret 4 (99.0 mm) and index fret 7 -> pinky fret 11 (90.8 mm),
+ linear in between and constant outside.
+- Pair limits as fractions of the allowed span: index-middle 0.65, middle-ring 0.35, ring-pinky 0.40. Non-adjacent
+ fingers get the sum of the limits between them. The original plan said index-middle 0.45, but that rejected
+ chord-book no-barre shapes such as `x 3 5 5 5 x` and `x 5 7 7 7 x` (index two frets behind the middle finger), so
+ it was raised. With these defaults middle (fret 1) and ring (fret 2) are 0.3 mm over their limit, so first-position
+ shapes sometimes use the pinky instead of the ring finger.
+- Fretting fingers sit side by side along the neck in hand order, each inside its own fret space, at least 12.5 mm
+ apart (`MIN_FINGER_SPACING_MM`). On a 25.5" scale three fingers share one fret only up to fret 7 and four never do;
+ a shape that needs more is `NEEDS_BARRE` (a barre in disguise).
+- The pair-stretch cost measures stretch beyond the relaxed one-fret-per-finger spacing, not the raw ratio to the
+ limit; the raw ratio would make the ordinary hand frame look maximally hard.
+- Thumb (only when `allowThumb`): lowest string only, within one fret of the lowest finger, excluded from reach, pair
+ limits and finger order.
+- Two-string partial barres (only when `allowTwoStringPartialBarre`) need adjacent strings at the same fret.
+- Barre mode (only when `noBarre` is false, used to classify curated shapes): the index barres at the shape's lowest
+ fret over a contiguous range whose ends are at that fret and that contains no open or muted string. Only when no
+ fingering exists that way, fingers 2-4 may also flatten over 2-3 adjacent strings at one fret (A-shape barres).
+- `classifyShape(...).needsBarre` is true only when the shape fails without a barre because it needs one, not when it is
+ out of reach for other reasons.
+- Families: triads, sus2/sus4 and add9/m(add9) allow up to 6 notes so open-string voicings such as `0 2 2 1 0 0` and
+ `0 2 4 0 0 0` are included; shells, quartal and clusters cap at 4; power5, dyad_b2, tritone and fourth at 4;
+ power5_b2 at 5; phrygian at 6.
+- Drone variants (power5, fourth, sus2, m(add9), quartal, phrygian) require an open string sounding the root or fifth,
+ and the fifth only counts when the recipe allows it. Without such a string the result is empty with `NO_DRONE_STRING`.
+- Legacy recipes (`fromLegacyNotes`): pitch classes of the JSON `notes` relative to `baseRoot`, the lowest parsed note
+ as the bass interval, note count capped to 6. The curated `fretboard` strings are not trusted (most do not sound
+ their own `notes`).
+- Relaxation ladder (cumulative): any bass note, then no 5th, then no extensions (9/11/13), then any note count.
+ Musical relaxations only; the hand profile is never relaxed behind the player's back. A step is skipped when it would
+ leave an invertible dyad over a free bass (inverting a dyad changes its only interval), so power chords, dyads,
+ sus and triads mostly fall back to "any note count" or stay empty with their binding constraint.
+- Chord symbols only for confident template matches. `(no5)` only for a perfect fifth, `(no3)` only for `7` and
+ `maj7`, `(no root)` only when the caller's root is absent; any omission still needs 3 sounding pitch classes.
+ Dyads and clusters get descriptive names (`E + F (minor 2nd)`, `E cluster (1 b2 2)`). An inverted dyad names the
+ interval that sounds from the bass up (`E over A (perfect 5th)`). The words "cluster" and "Quartal" are only used
+ when the sounding notes really contain a close second or stacked fourths.
+- `explainCost(..., profile)` reports reach as a share of the comfortable reach (stretch tolerance excluded) and adds
+ "(a stretch)" above 100 percent. Without a profile it says "percent of your allowed reach".
+- Novelty compares relative fret patterns against common open/CAGED/power shapes (and optional references); moving
+ shapes match at any position, open-string shapes only as written. It is a ranking axis, never part of difficulty.
+- Transition cost = finger travel along the neck, string changes, fingers lifted and placed, slides (discounted) and
+ hand shift; anchors (same finger, string and fret) discount the whole change by up to 50 percent. It is divided by
+ the time available (`beats * 60 / bpm`, floored at 0.05 s). Thumbs follow the hand and are ignored per finger.
+- `optimizeSequence` minimizes the hardest transition first (exact two-phase minimax), then the sum; it treats the
+ riff as a loop by default. With `{ profile }` it also chooses each slot's fingering together with its neighbours
+ (the attached fingering or the cheapest valid one per other finger set) and returns them in `fingerings`.
+- Rhythm: 480 PPQ; 16th = 120 ticks, 16th triplet = 80, 32nd = 60. One monophonic event stream; rests are gaps.
+ Alternate picking follows the 16th grid (even down, odd up) for plain 16ths. A run faster than 16ths (32nds,
+ sixteenth triplets) follows its own subdivision (tick/60 or tick/80 parity, bar-relative), so runs alternate
+ strictly, gaps leave ghost strokes and every beat start is a downstroke; isolated eighth-note triplets alternate by
+ event. `halftimeStomp` defaults to downstrokes.
+- Rhythm harmony: slots change only on accented hits, and every slot is used when there are enough accents.
+ `regenerateRhythm` keeps the locked bars' events and their sound (entry slot, ties of pushes) and changes harmony only
+ on accents across the merge. Grid edits act on whole cells (a 32nd tremolo cell is one cell).
+- Rhythm grouping `even`: one group per beat in x/4; odd x/8 as 2+2+...+3 eighths (7/8 = 4+4+6 sixteenths).
+ `fiveOverFour` repeats a 5-sixteenth [3,2] cycle across barlines.
+- Rhythm playback: base velocity 0.7, accent +0.12 / +0.22, palm mute x0.75 velocity and 35 percent duration, dead
+ notes 0.3 velocity and at most 30 ms, ties extend the sounding note. Pedal hits use the active slot's pedal note,
+ falling back to its lowest sounding note.
+- `tooFastForProfile` is a warning only: it fires when the fastest attack rate at the given BPM exceeds the profile's
+ comfortable sixteenth-note tempo.
+
+- RiffForge app defaults (outside the engine, recorded here so they live next to the engine's):
+ - A library card's title is the engine's name for what sounds. The curated nickname ("Drop " removed, its root
+ moved to the selected root) and descriptive subtext ("m2 Clash") form the secondary line; key-bound symbols such
+ as "Bb(VI)" or "Em(add9)" are dropped because the card's root follows the selected root.
+ - A library card shows the generated shape closest to its curated idea: bass register (2 per octave, capped at 2
+ octaves), note count (0.5 per note), the curated tab moved rigidly to the new root (0 to 1) and neck position
+ (0.08 per fret, capped at 10 frets); engine cost decides between equally faithful shapes.
+ - Chord Explorer links use the selected root and a type from the first engine family whose pitch classes match
+ (bass and note count ignored), else the largest full formula present without a contradicting third. Power chords,
+ dyads, the tritone, fourths, quartal stacks and clusters get no type. The shape travels as gv only in E Standard.
+ - Library data: the "Am Add9" and "Drop Gm Add9" rows (and their related copies) had no minor third, so they
+ sounded sus2; their notes now include the b3. "Drop Fm Mediant" had a fourth where its minor third belongs.
+
+## Heuristics that are not research-backed
+
+- All cost weights in `weights.ts` and `TRANSITION_WEIGHTS` (their relative order follows ISMIR 2023: finger
+ positioning highest, right-hand skips next, change speed lowest).
+- Pair-limit fractions, the 70 percent fingertip position, 10.5 mm string spacing, the 12.5 mm side-by-side finger
+ spacing, the thumb window, the 0.6 diversity penalty, the low-mud threshold (E3, intervals under a perfect fourth),
+ the anchor discount cap, and every rhythm style rate (accent, push, variation, dead-note and slot-coverage rules).
+- No peer-reviewed millimetre comfort limit was found, which is why reach comes from calibration.
+
+## Tests
+
+`npx vitest run engine` covers every module, including a property test over 12 roots x 20 base families x
+{E Standard, Drop D} (every voicing matches its recipe, has a no-barre fingering inside reach and pair limits, and
+sounds exactly its tab), drone coverage, determinism, a brute-force completeness check for the search, and a
+generation time budget.
diff --git a/engine/fingering.test.ts b/engine/fingering.test.ts
new file mode 100644
index 0000000..27cb808
--- /dev/null
+++ b/engine/fingering.test.ts
@@ -0,0 +1,760 @@
+import { describe, it, expect } from 'vitest';
+import {
+ findBestFingering,
+ enumerateFingerings,
+ physicalCostBreakdown,
+ classifyShape,
+ FINGER_NAMES,
+ MIN_FINGER_SPACING_MM,
+ THUMB_FRET_WINDOW
+} from './fingering';
+import { DEFAULT_HAND_PROFILE, allowedSpanMm, pairLimitFraction } from './handProfile';
+import { fingertipMm, fretWireMm, spanMm } from './geometry';
+import { fromRiffForgeTab } from './shape';
+import { createRng } from './random';
+import { PHYSICAL_TERMS, PLAYABILITY_WEIGHTS } from './weights';
+import { generateVoicings } from './generateVoicings';
+import { E_STANDARD, DROP_D } from './tuning';
+import type { Fingering, FingeringMetrics, FingeringResult, FingerNumber, HandProfile, Shape } from './types';
+
+const P = DEFAULT_HAND_PROFILE;
+const L = P.scaleLengthMm;
+const BARRE: HandProfile = { ...P, noBarre: false };
+const PARTIAL: HandProfile = { ...P, allowTwoStringPartialBarre: true };
+const THUMB: HandProfile = { ...P, allowThumb: true };
+
+const tab = (text: string): Shape => {
+ const shape = fromRiffForgeTab(text);
+ if (!shape) throw new Error(`bad tab ${text}`);
+ return shape;
+};
+
+const valid = (result: FingeringResult): Fingering => {
+ if (result.ok === false) throw new Error(`expected a fingering, got ${result.reason}: ${result.detail}`);
+ return result.fingering;
+};
+
+const reasonOf = (result: FingeringResult): string => (result.ok === false ? result.reason : 'OK');
+
+const best = (text: string, profile: HandProfile = P): Fingering => valid(findBestFingering(tab(text), profile));
+
+/**
+ * Reference for the side-by-side rule: fretting fingers (thumb excluded) in hand order 1..4 each press inside
+ * their own fret space, at least MIN_FINGER_SPACING_MM apart along the neck.
+ */
+const fitsSideBySide = (fretsInHandOrder: readonly number[], scaleLengthMm: number = L): boolean => {
+ let pos = -Infinity;
+ for (const fret of fretsInHandOrder) {
+ pos = Math.max(fretWireMm(fret - 1, scaleLengthMm), pos + MIN_FINGER_SPACING_MM);
+ if (pos > fretWireMm(fret, scaleLengthMm) + 1e-9) return false;
+ }
+ return true;
+};
+
+/** Frets of the distinct fretting fingers 1..4 in hand order, from a fingering. */
+const handOrderFrets = (shape: Shape, fingers: readonly (FingerNumber | null)[]): number[] => {
+ const fretOf = new Map();
+ fingers.forEach((finger, s) => {
+ if (finger !== null && finger > 0) fretOf.set(finger, shape[s]!);
+ });
+ return [...fretOf.keys()].sort((a, b) => a - b).map((finger) => fretOf.get(finger)!);
+};
+
+const fretWidth = (fret: number): number => fretWireMm(fret, L) - fretWireMm(fret - 1, L);
+
+describe('validation order', () => {
+ it('rejects malformed shapes as INVALID_SHAPE', () => {
+ expect(reasonOf(findBestFingering([], P))).toBe('INVALID_SHAPE');
+ expect(reasonOf(findBestFingering([null, null, null, null, null, null], P))).toBe('INVALID_SHAPE');
+ expect(reasonOf(findBestFingering([0, 1.5, 2, null, null, null], P))).toBe('INVALID_SHAPE');
+ expect(reasonOf(findBestFingering([0, -1, 2, null, null, null], P))).toBe('INVALID_SHAPE');
+ });
+
+ it('rejects frets above profile.maxFret as FRET_RANGE', () => {
+ expect(reasonOf(findBestFingering(tab('x 16 x x x x'), P))).toBe('FRET_RANGE');
+ expect(reasonOf(findBestFingering(tab('x 16 x x x x'), { ...P, maxFret: 20 }))).toBe('OK');
+ expect(reasonOf(findBestFingering(tab('x 15 x x x x'), P))).toBe('OK');
+ });
+
+ it('rejects open strings when the profile disallows them', () => {
+ const closed = { ...P, allowOpenStrings: false };
+ expect(reasonOf(findBestFingering(tab('0 2 2 1 0 0'), closed))).toBe('OPEN_STRINGS_DISALLOWED');
+ expect(reasonOf(findBestFingering(tab('x 2 2 1 x x'), closed))).toBe('OK');
+ });
+
+ it('checks INVALID_SHAPE, then FRET_RANGE, then OPEN_STRINGS_DISALLOWED', () => {
+ const closed = { ...P, allowOpenStrings: false };
+ expect(reasonOf(findBestFingering(tab('0 16 x x x x'), closed))).toBe('FRET_RANGE');
+ expect(reasonOf(findBestFingering([0, 16, -2, null, null, null], closed))).toBe('INVALID_SHAPE');
+ });
+
+ it('gives a human detail sentence on every rejection', () => {
+ for (const shape of [tab('x 16 x x x x'), tab('x 1 x x 6 x'), tab('3 5 5 4 3 x'), [] as Shape]) {
+ const result = findBestFingering(shape, P);
+ expect(result.ok).toBe(false);
+ if (result.ok === false) expect(result.detail.length).toBeGreaterThan(5);
+ }
+ });
+});
+
+describe('all-open shapes', () => {
+ it('are valid with no fingers, no barres and zero metrics', () => {
+ const f = best('0 0 0 0 0 0');
+ expect(f.fingers).toEqual([null, null, null, null, null, null]);
+ expect(f.barres).toEqual([]);
+ expect(f.metrics).toEqual({
+ frettedCount: 0,
+ fingerCount: 0,
+ spanMm: 0,
+ allowedSpanMm: 0,
+ reachRatio: 0,
+ pairs: [],
+ maxPairRatio: 0,
+ maxPairStretch: 0,
+ usesPinky: false,
+ usesThumb: false,
+ contortions: 0,
+ interiorMutes: 0,
+ lowestFret: null,
+ highestFret: null
+ });
+ expect(f.costBreakdown.total).toBe(0);
+ });
+
+ it('still counts interior mutes from the shape', () => {
+ expect(best('0 x 0 x x x').metrics.interiorMutes).toBe(1);
+ });
+});
+
+describe('known chord shapes (no-barre default profile)', () => {
+ it('open E major 0 2 2 1 0 0: index on G#, middle and ring on the 2nd fret', () => {
+ const f = best('0 2 2 1 0 0');
+ expect(f.fingers).toEqual([null, 2, 3, 1, null, null]);
+ expect(f.barres).toEqual([]);
+ expect(f.metrics.fingerCount).toBe(3);
+ expect(f.metrics.frettedCount).toBe(3);
+ expect(f.metrics.usesPinky).toBe(false);
+ expect(f.metrics.contortions).toBe(0);
+ expect(f.metrics.lowestFret).toBe(1);
+ expect(f.metrics.highestFret).toBe(2);
+ });
+
+ it('C major x 3 2 0 1 0: classic 3-2-1 fingering', () => {
+ expect(best('x 3 2 0 1 0').fingers).toEqual([null, 3, 2, null, 1, null]);
+ });
+
+ it('Em(add9) 0 2 4 0 0 0 is valid without a barre', () => {
+ const f = best('0 2 4 0 0 0');
+ expect(f.barres).toEqual([]);
+ expect(f.fingers).toEqual([null, 1, 3, null, null, null]);
+ });
+
+ it('a single note goes to the index (lexicographic tie-break among equal costs)', () => {
+ expect(best('x x x 5 x x').fingers).toEqual([null, null, null, 1, null, null]);
+ const all = enumerateFingerings(tab('x x x 5 x x'), P).map((f) => f.fingers[3]);
+ expect(all).toEqual([1, 2, 3, 4]);
+ });
+
+ it('rejects more than four fretted notes with NEEDS_BARRE', () => {
+ const result = findBestFingering(tab('3 5 5 4 3 x'), P);
+ expect(reasonOf(result)).toBe('NEEDS_BARRE');
+ });
+
+ it('rejects the F barre 1 3 3 2 1 1 with NEEDS_BARRE by default', () => {
+ expect(reasonOf(findBestFingering(tab('1 3 3 2 1 1'), P))).toBe('NEEDS_BARRE');
+ });
+});
+
+describe('reach', () => {
+ it('rejects a wide shape x 1 x x 6 x with REACH and names the numbers', () => {
+ const result = findBestFingering(tab('x 1 x x 6 x'), P);
+ expect(reasonOf(result)).toBe('REACH');
+ if (result.ok === false) expect(result.detail).toMatch(/span \d+ mm > allowed \d+ mm/);
+ });
+
+ it('accepts index fret 1 + pinky fret 4 (the calibrated span) and rejects fret 1 + fret 5', () => {
+ const f = best('x 1 x x 4 x');
+ expect(f.fingers).toEqual([null, 1, null, null, 4, null]);
+ expect(f.metrics.reachRatio).toBeCloseTo(1, 9);
+ expect(reasonOf(findBestFingering(tab('x 1 x x 5 x'), P))).toBe('REACH');
+ });
+
+ it('scales with the stretch tolerance', () => {
+ expect(reasonOf(findBestFingering(tab('x 5 x x 9 x'), P))).toBe('REACH');
+ expect(reasonOf(findBestFingering(tab('x 5 x x 9 x'), { ...P, stretchTolerance: 1.1 }))).toBe('OK');
+ });
+
+ it('ignores open strings for span and reach', () => {
+ const f = best('0 x x x 3 0');
+ expect(f.metrics.spanMm).toBe(0);
+ expect(f.metrics.reachRatio).toBe(0);
+ expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 3), 9);
+ });
+
+ it('fills span and allowed span from the fingertip model', () => {
+ const f = best('0 2 2 1 0 0');
+ expect(f.metrics.spanMm).toBeCloseTo(spanMm(1, 2, L), 9);
+ expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 1), 9);
+ expect(f.metrics.reachRatio).toBeCloseTo(spanMm(1, 2, L) / allowedSpanMm(P, 1), 9);
+ });
+});
+
+describe('pair limits and stretch', () => {
+ it('rejects x 1 1 3 3 x with PAIR_STRETCH because the middle-ring pair binds', () => {
+ const result = findBestFingering(tab('x 1 1 3 3 x'), P);
+ expect(reasonOf(result)).toBe('PAIR_STRETCH');
+ if (result.ok === false) expect(result.detail).toContain('middle-ring');
+ expect(enumerateFingerings(tab('x 1 1 3 3 x'), P)).toEqual([]);
+ });
+
+ it('fills every pair with distance, limit, ratio, natural spacing and stretch', () => {
+ const f = best('x 3 2 0 1 0');
+ const allowed = allowedSpanMm(P, 1);
+ expect(f.metrics.pairs.map((p) => [p.from, p.to])).toEqual([
+ [1, 2],
+ [2, 3]
+ ]);
+ const [im, mr] = f.metrics.pairs;
+ expect(im.distanceMm).toBeCloseTo(fingertipMm(2, L) - fingertipMm(1, L), 9);
+ expect(im.limitMm).toBeCloseTo(pairLimitFraction(P, 1, 2) * allowed, 9);
+ expect(im.ratio).toBeCloseTo(im.distanceMm / im.limitMm, 9);
+ expect(im.naturalMm).toBeCloseTo(fingertipMm(2, L) - fingertipMm(1, L), 9);
+ expect(im.stretch).toBe(0);
+ expect(mr.limitMm).toBeCloseTo(pairLimitFraction(P, 2, 3) * allowed, 9);
+ expect(f.metrics.maxPairRatio).toBeCloseTo(Math.max(im.ratio, mr.ratio), 12);
+ expect(f.metrics.maxPairStretch).toBe(0);
+ });
+
+ it('computes stretch beyond the relaxed one-fret-per-finger spacing', () => {
+ // Forced 1-2-3-4 with the middle finger two frets above the index at fret 11.
+ const f = best('x 11 13 14 15 x', P);
+ expect(f.fingers).toEqual([null, 1, 2, 3, 4, null]);
+ const im = f.metrics.pairs[0];
+ const natural = fingertipMm(12, L) - fingertipMm(11, L);
+ const distance = fingertipMm(13, L) - fingertipMm(11, L);
+ expect(im.naturalMm).toBeCloseTo(natural, 9);
+ expect(im.stretch).toBeCloseTo(Math.min(1, (distance - natural) / (im.limitMm - natural)), 9);
+ expect(im.stretch).toBeGreaterThan(0);
+ expect(f.metrics.maxPairStretch).toBeCloseTo(Math.max(...f.metrics.pairs.map((p) => p.stretch)), 12);
+ });
+
+ it('marks a cramped pair (closer than the relaxed spacing) as zero stretch', () => {
+ // Index fret 2 and pinky fret 4: natural spacing for 1 -> 4 is three frets, distance only two.
+ const f = valid(findBestFingering(tab('x 2 x x 4 x'), P));
+ for (const pair of f.metrics.pairs) {
+ if (pair.distanceMm <= pair.naturalMm) expect(pair.stretch).toBe(0);
+ }
+ });
+
+ it('skips fingers by summing adjacent limits (index + pinky at the calibrated span)', () => {
+ const f = best('x 1 x x 4 x');
+ expect(f.metrics.pairs).toHaveLength(1);
+ expect(f.metrics.pairs[0].limitMm).toBeCloseTo(pairLimitFraction(P, 1, 4) * allowedSpanMm(P, 1), 9);
+ });
+});
+
+describe('contortion', () => {
+ it('counts a lower-numbered finger on a higher string at the same fret', () => {
+ const all = enumerateFingerings(tab('0 2 2 1 0 0'), P);
+ const crossed = all.find((f) => f.fingers.join() === [null, 3, 2, 1, null, null].join());
+ expect(crossed).toBeDefined();
+ expect(crossed!.metrics.contortions).toBe(1);
+ const term = crossed!.costBreakdown.terms.find((t) => t.key === 'contortion')!;
+ expect(term.normalized).toBeCloseTo(0.5, 12);
+ expect(term.cost).toBeCloseTo(0.5 * PLAYABILITY_WEIGHTS.contortion.weight, 12);
+ });
+
+ it('allows a forced contortion in barre mode (index barre on high strings, middle on string 0)', () => {
+ const f = best('3 4 4 0 3 3', BARRE);
+ expect(f.fingers).toEqual([2, 3, 4, null, 1, 1]);
+ expect(f.barres).toEqual([{ finger: 1, fret: 3, fromString: 4, toString: 5 }]);
+ expect(f.metrics.contortions).toBe(1);
+ });
+});
+
+describe('fingers side by side along the neck', () => {
+ it('rejects four separate fingers stacked in one fret as NEEDS_BARRE (a barre in disguise)', () => {
+ for (const text of ['x 1 1 1 1 x', '1 1 1 1 x x', '8 8 8 8 x x', 'x 13 13 13 13 x']) {
+ const result = findBestFingering(tab(text), P);
+ expect(reasonOf(result)).toBe('NEEDS_BARRE');
+ if (result.ok === false) expect(result.detail).toMatch(/side by side/);
+ }
+ });
+
+ it('fits three fingers in one fret only where the fret is wide enough', () => {
+ expect(best('x 0 2 2 2 0').fingers).toEqual([null, null, 1, 2, 3, null]);
+ expect(reasonOf(findBestFingering(tab('x 7 7 7 x x'), P))).toBe('OK');
+ for (const text of ['x 10 10 10 x x', '13 13 13 x x x', 'x 15 15 15 x x']) {
+ expect(reasonOf(findBestFingering(tab(text), P))).toBe('NEEDS_BARRE');
+ }
+ for (let fret = 1; fret <= 15; fret++) {
+ const fits = fretWidth(fret) >= 2 * MIN_FINGER_SPACING_MM;
+ expect(reasonOf(findBestFingering(tab(`x ${fret} ${fret} ${fret} x x`), P))).toBe(fits ? 'OK' : 'NEEDS_BARRE');
+ }
+ });
+
+ it('never fits four fingers into one fret on a 25.5 inch scale', () => {
+ for (let fret = 1; fret <= 24; fret++) expect(fretWidth(fret)).toBeLessThan(3 * MIN_FINGER_SPACING_MM);
+ });
+
+ it('counts neighbouring frets: four fingers do not squeeze into two narrow frets', () => {
+ expect(reasonOf(findBestFingering(tab('x 14 14 15 15 x'), P))).toBe('NEEDS_BARRE');
+ expect(best('x 2 2 3 3 x').fingers).toEqual([null, 1, 2, 3, 4, null]);
+ expect(best('x 12 13 14 15 x').fingers).toEqual([null, 1, 2, 3, 4, null]);
+ });
+
+ it('lets a barre take over where separate fingers do not fit', () => {
+ expect(best('x 13 13 13 13 x', BARRE).barres).toEqual([{ finger: 1, fret: 13, fromString: 1, toString: 4 }]);
+ // Drop D one-finger power chord high up the neck.
+ expect(reasonOf(findBestFingering(tab('10 10 10 x x x'), P))).toBe('NEEDS_BARRE');
+ expect(best('10 10 10 x x x', BARRE).barres).toEqual([{ finger: 1, fret: 10, fromString: 0, toString: 2 }]);
+ });
+
+ it('does not let the generator stack more fingers in a fret than fit', () => {
+ const add9 = generateVoicings({ root: 8, family: 'add9', tuning: E_STANDARD, profile: P, limit: 12 });
+ const shapes = add9.voicings.map((v) => v.shape.map((f) => (f === null ? 'x' : String(f))).join(' '));
+ expect(shapes).not.toContain('x 1 1 1 1 x');
+ expect(shapes).not.toContain('x 13 13 13 13 x');
+ for (const tuning of [E_STANDARD, DROP_D]) {
+ for (const family of ['add9', 'quartal', 'major', 'sus2', 'power5']) {
+ for (let root = 0; root < 12; root++) {
+ for (const v of generateVoicings({ root, family, tuning, profile: P, limit: 12 }).voicings) {
+ expect(fitsSideBySide(handOrderFrets(v.shape, v.fingering.fingers))).toBe(true);
+ }
+ }
+ }
+ }
+ });
+});
+
+describe('barre mode (noBarre = false)', () => {
+ it('accepts the F barre 1 3 3 2 1 1 with an index barre over strings 0..5', () => {
+ const f = best('1 3 3 2 1 1', BARRE);
+ expect(f.barres).toEqual([{ finger: 1, fret: 1, fromString: 0, toString: 5 }]);
+ expect(f.fingers).toEqual([1, 3, 4, 2, 1, 1]);
+ expect(f.metrics.fingerCount).toBe(4);
+ const barreTerm = f.costBreakdown.terms.find((t) => t.key === 'barre')!;
+ expect(barreTerm.raw).toBe(1);
+ expect(barreTerm.cost).toBeCloseTo(PLAYABILITY_WEIGHTS.barre.weight, 12);
+ });
+
+ it('never lets a barre cover an open or muted string', () => {
+ for (const text of ['3 4 4 0 3 3', '3 4 4 x 3 3']) {
+ for (const f of enumerateFingerings(tab(text), BARRE)) {
+ for (const b of f.barres) {
+ for (let s = b.fromString; s <= b.toString; s++) {
+ const fret = tab(text)[s];
+ expect(fret).not.toBeNull();
+ expect(fret).toBeGreaterThanOrEqual(b.fret);
+ }
+ }
+ }
+ }
+ });
+
+ it('keeps fingers off strings the barre already sounds', () => {
+ for (const f of enumerateFingerings(tab('1 3 3 2 1 1'), BARRE)) {
+ for (const b of f.barres) {
+ for (let s = b.fromString; s <= b.toString; s++) {
+ if (tab('1 3 3 2 1 1')[s] === b.fret) expect(f.fingers[s]).toBe(b.finger);
+ }
+ }
+ }
+ });
+
+ it('still prefers a barre-free fingering when one exists', () => {
+ expect(best('0 2 2 1 0 0', BARRE)).toEqual(best('0 2 2 1 0 0', P));
+ });
+
+ it('rejects NEEDS_BARRE when neither the index barre nor a mini-barre leaves enough fingers', () => {
+ // No two adjacent strings share a fret above the index barre, so nothing can flatten.
+ expect(reasonOf(findBestFingering(tab('1 1 3 4 3 4'), BARRE))).toBe('NEEDS_BARRE');
+ });
+
+ it('plays the A-shape barre at every position (index barre plus ring mini-barre or 2-3-4)', () => {
+ const ringMini = [null, 1, 3, 3, 3, 1];
+ for (let n = 1; n <= 12; n++) {
+ const f = best(`x ${n} ${n + 2} ${n + 2} ${n + 2} ${n}`, BARRE);
+ expect(f.barres[0]).toEqual({ finger: 1, fret: n, fromString: 1, toString: 5 });
+ expect([ringMini, [null, 1, 2, 3, 4, 1]]).toContainEqual(f.fingers);
+ // Where three separate fingers do not fit in fret n + 2, the ring must flatten.
+ if (!fitsSideBySide([n + 2, n + 2, n + 2])) {
+ expect(f.fingers).toEqual(ringMini);
+ expect(f.barres[1]).toEqual({ finger: 3, fret: n + 2, fromString: 2, toString: 4 });
+ }
+ }
+ // Near the nut the two-fret index-middle gap of 2-3-4 is past the default pair limit.
+ expect(best('x 1 3 3 3 1', BARRE).fingers).toEqual(ringMini);
+ expect(reasonOf(findBestFingering(tab('x 5 7 7 7 5'), BARRE))).toBe('OK');
+ });
+
+ it('flattens a non-index finger only when separate fingers cannot play the shape', () => {
+ // F barre: ring and pinky fit, so no mini-barre.
+ expect(best('1 3 3 2 1 1', BARRE).fingers).toEqual([1, 3, 4, 2, 1, 1]);
+ // Too many notes for separate fingers above a two-string index barre: the ring flattens.
+ const f = best('1 1 3 3 3 3', BARRE);
+ expect(f.barres[0]).toEqual({ finger: 1, fret: 1, fromString: 0, toString: 1 });
+ expect(f.barres.slice(1).every((b) => b.finger !== 1)).toBe(true);
+ // Every enumerated mini-barre covers two or three adjacent strings that share its fret.
+ for (const shape of [tab('1 1 3 3 3 3'), tab('x 1 3 3 3 3'), tab('x 3 5 5 5 3')]) {
+ const all = enumerateFingerings(shape, BARRE);
+ expect(all.length).toBeGreaterThan(0);
+ for (const g of all) {
+ for (const b of g.barres.filter((x) => x.finger !== 1)) {
+ expect(b.toString - b.fromString).toBeGreaterThanOrEqual(1);
+ expect(b.toString - b.fromString).toBeLessThanOrEqual(2);
+ for (let s = b.fromString; s <= b.toString; s++) expect(shape[s]).toBe(b.fret);
+ }
+ }
+ }
+ });
+
+ it('never flattens a finger in no-barre mode', () => {
+ expect(reasonOf(findBestFingering(tab('x 3 5 5 5 3'), P))).toBe('NEEDS_BARRE');
+ expect(reasonOf(findBestFingering(tab('x 13 13 13 x x'), P))).toBe('NEEDS_BARRE');
+ });
+
+ it('combines an index barre with two-string partial barres when allowed', () => {
+ const f = best('1 1 3 3 3 3', { ...BARRE, allowTwoStringPartialBarre: true });
+ expect(f.fingers).toEqual([1, 1, 3, 3, 4, 4]);
+ expect(f.barres).toEqual([
+ { finger: 1, fret: 1, fromString: 0, toString: 1 },
+ { finger: 3, fret: 3, fromString: 2, toString: 3 },
+ { finger: 4, fret: 3, fromString: 4, toString: 5 }
+ ]);
+ expect(f.costBreakdown.terms.find((t) => t.key === 'barre')!.normalized).toBe(1);
+ });
+
+ it('still enforces reach in barre mode', () => {
+ expect(reasonOf(findBestFingering(tab('1 6 6 x 1 1'), BARRE))).toBe('REACH');
+ });
+});
+
+describe('two-string partial barre mode', () => {
+ it('lets one finger cover two adjacent strings at the same fret', () => {
+ const shape = tab('x 3 5 5 4 3');
+ expect(reasonOf(findBestFingering(shape, P))).toBe('NEEDS_BARRE');
+ const f = valid(findBestFingering(shape, PARTIAL));
+ expect(f.fingers).toEqual([null, 1, 4, 4, 3, 2]);
+ expect(f.barres).toEqual([{ finger: 4, fret: 5, fromString: 2, toString: 3 }]);
+ expect(f.metrics.fingerCount).toBe(4);
+ });
+
+ it('does not allow non-adjacent strings to share a finger', () => {
+ expect(reasonOf(findBestFingering(tab('3 5 3 5 4 x'), PARTIAL))).toBe('NEEDS_BARRE');
+ });
+
+ it('does not use a partial barre when separate fingers are cheaper', () => {
+ expect(best('x 2 2 x x x', PARTIAL).barres).toEqual([]);
+ });
+});
+
+describe('thumb mode', () => {
+ it('lets the thumb fret string 0 so a fifth note fits (2 x 4 4 3 5)', () => {
+ const shape = tab('2 x 4 4 3 5');
+ expect(reasonOf(findBestFingering(shape, P))).toBe('NEEDS_BARRE');
+ const f = valid(findBestFingering(shape, THUMB));
+ expect(f.fingers).toEqual([0, null, 2, 3, 1, 4]);
+ expect(f.metrics.usesThumb).toBe(true);
+ expect(f.metrics.fingerCount).toBe(5);
+ // Thumb excluded from reach: span runs from fret 3 to fret 5.
+ expect(f.metrics.spanMm).toBeCloseTo(spanMm(3, 5, L), 9);
+ expect(f.metrics.allowedSpanMm).toBeCloseTo(allowedSpanMm(P, 3), 9);
+ expect(f.metrics.pairs.every((p) => p.from >= 1)).toBe(true);
+ expect(f.costBreakdown.terms.find((t) => t.key === 'thumb')!.cost).toBeCloseTo(PLAYABILITY_WEIGHTS.thumb.weight, 12);
+ });
+
+ it(`keeps the thumb within ${THUMB_FRET_WINDOW} fret of the lowest finger`, () => {
+ const result = findBestFingering(tab('1 x 4 4 3 5'), THUMB);
+ expect(reasonOf(result)).toBe('NEEDS_BARRE');
+ if (result.ok === false) expect(result.detail).toContain('thumb');
+ });
+
+ it('uses the thumb when it relieves a long reach (2 x x 3 4 5)', () => {
+ const f = best('2 x x 3 4 5', THUMB);
+ expect(f.fingers).toEqual([0, null, null, 1, 2, 3]);
+ expect(best('2 x x 3 4 5', P).fingers).toEqual([1, null, null, 2, 3, 4]);
+ });
+
+ it('never puts the thumb on any string but string 0', () => {
+ for (const f of enumerateFingerings(tab('x 2 x 3 4 5'), THUMB)) {
+ expect(f.fingers.includes(0)).toBe(false);
+ }
+ });
+});
+
+describe('physical cost breakdown', () => {
+ it('always lists the nine physical terms in PHYSICAL_TERMS order with weights.ts weights', () => {
+ for (const text of ['0 0 0 0 0 0', '0 2 2 1 0 0', 'x 3 2 0 1 0']) {
+ const { costBreakdown } = best(text);
+ expect(costBreakdown.terms.map((t) => t.key)).toEqual([...PHYSICAL_TERMS]);
+ for (const term of costBreakdown.terms) {
+ expect(term.bucket).toBe('physical');
+ expect(term.weight).toBe(PLAYABILITY_WEIGHTS[term.key].weight);
+ expect(term.cost).toBeCloseTo(term.normalized * term.weight, 12);
+ expect(term.normalized).toBeGreaterThanOrEqual(0);
+ expect(term.normalized).toBeLessThanOrEqual(1);
+ }
+ const sum = costBreakdown.terms.reduce((acc, t) => acc + t.cost, 0);
+ expect(costBreakdown.total).toBeCloseTo(sum, 12);
+ expect(costBreakdown.physical).toBe(costBreakdown.total);
+ expect(costBreakdown.rightHand).toBe(0);
+ expect(costBreakdown.musical).toBe(0);
+ }
+ });
+
+ it('normalizes open E major as documented', () => {
+ const f = best('0 2 2 1 0 0');
+ const n = Object.fromEntries(f.costBreakdown.terms.map((t) => [t.key, t.normalized]));
+ expect(n.reach).toBeCloseTo(f.metrics.reachRatio ** 2, 12);
+ expect(n.pairStretch).toBe(0);
+ expect(n.fingerCount).toBeCloseTo(0.75, 12);
+ expect(n.pinky).toBe(0);
+ expect(n.contortion).toBe(0);
+ expect(n.interiorMutes).toBe(0);
+ expect(n.position).toBeCloseTo(1 / 12, 12);
+ expect(n.thumb).toBe(0);
+ expect(n.barre).toBe(0);
+ });
+
+ it('maps raw metrics through the documented formulas and clamps to 0..1', () => {
+ const metrics: FingeringMetrics = {
+ frettedCount: 5,
+ fingerCount: 5,
+ spanMm: 50,
+ allowedSpanMm: 100,
+ reachRatio: 0.5,
+ pairs: [],
+ maxPairRatio: 0.9,
+ maxPairStretch: 0.4,
+ usesPinky: true,
+ usesThumb: true,
+ contortions: 3,
+ interiorMutes: 1,
+ lowestFret: 18,
+ highestFret: 20
+ };
+ const barres = [
+ { finger: 1 as const, fret: 18, fromString: 0, toString: 1 },
+ { finger: 3 as const, fret: 20, fromString: 2, toString: 3 }
+ ];
+ const fingers: (FingerNumber | null)[] = [0, 1, 3, 3, 2, null];
+ const b = physicalCostBreakdown([18, 18, 20, 20, 19, null], fingers, barres, metrics);
+ const byKey = Object.fromEntries(b.terms.map((t) => [t.key, t]));
+ expect(byKey.reach.normalized).toBeCloseTo(0.25, 12);
+ expect(byKey.pairStretch.normalized).toBeCloseTo(0.16, 12);
+ expect(byKey.fingerCount.raw).toBe(5);
+ expect(byKey.fingerCount.normalized).toBe(1);
+ expect(byKey.pinky.normalized).toBe(1);
+ expect(byKey.contortion.raw).toBe(3);
+ expect(byKey.contortion.normalized).toBe(1);
+ expect(byKey.interiorMutes.normalized).toBeCloseTo(0.5, 12);
+ expect(byKey.position.raw).toBe(18);
+ expect(byKey.position.normalized).toBe(1);
+ expect(byKey.thumb.normalized).toBe(1);
+ expect(byKey.barre.raw).toBe(2);
+ expect(byKey.barre.normalized).toBe(1);
+ });
+
+ it('uses position 0 when there is no fretted note', () => {
+ const metrics = best('0 0 0 0 0 0').metrics;
+ const b = physicalCostBreakdown([0, 0, 0, 0, 0, 0], new Array(6).fill(null), [], metrics);
+ expect(b.terms.find((t) => t.key === 'position')!.raw).toBe(0);
+ });
+
+ it('is the cost the best fingering minimizes', () => {
+ for (const text of ['0 2 2 1 0 0', 'x 3 2 0 1 0', 'x 5 7 7 6 x', '1 3 3 2 1 1']) {
+ for (const profile of [P, BARRE, PARTIAL, THUMB]) {
+ const all = enumerateFingerings(tab(text), profile);
+ if (all.length === 0) continue;
+ const result = valid(findBestFingering(tab(text), profile));
+ expect(result).toEqual(all[0]);
+ for (let i = 1; i < all.length; i++) {
+ expect(all[i].costBreakdown.total).toBeGreaterThanOrEqual(all[i - 1].costBreakdown.total);
+ }
+ for (const f of all) {
+ expect(f.costBreakdown).toEqual(physicalCostBreakdown(tab(text), f.fingers, f.barres, f.metrics));
+ }
+ }
+ }
+ });
+});
+
+describe('classifyShape', () => {
+ it('classifies the F barre as needing a barre with an index barre fingering', () => {
+ const c = classifyShape(tab('1 3 3 2 1 1'), P);
+ expect(c.needsBarre).toBe(true);
+ expect(reasonOf(c.noBarre)).toBe('NEEDS_BARRE');
+ expect(valid(c.withBarre).barres).toEqual([{ finger: 1, fret: 1, fromString: 0, toString: 5 }]);
+ });
+
+ it('classifies open shapes as barre-free in both modes', () => {
+ const c = classifyShape(tab('0 2 2 1 0 0'), BARRE);
+ expect(c.needsBarre).toBe(false);
+ expect(valid(c.noBarre).fingers).toEqual([null, 2, 3, 1, null, null]);
+ expect(valid(c.withBarre).barres).toEqual([]);
+ });
+
+ it('flags a shape that only a barre makes playable for this hand', () => {
+ const c = classifyShape(tab('x 1 1 3 3 x'), P);
+ expect(reasonOf(c.noBarre)).toBe('PAIR_STRETCH');
+ expect(valid(c.withBarre).barres).toEqual([{ finger: 1, fret: 1, fromString: 1, toString: 2 }]);
+ expect(c.needsBarre).toBe(true);
+ });
+
+ it('does not blame a barre when the shape is simply out of reach', () => {
+ const c = classifyShape(tab('x 1 x x 6 x'), P);
+ expect(reasonOf(c.noBarre)).toBe('REACH');
+ expect(reasonOf(c.withBarre)).toBe('REACH');
+ expect(c.needsBarre).toBe(false);
+ });
+
+ it('classifies the A-shape barre x 3 5 5 5 3 as playable with a barre', () => {
+ const c = classifyShape(tab('x 3 5 5 5 3'), P);
+ expect(c.needsBarre).toBe(true);
+ expect(reasonOf(c.noBarre)).toBe('NEEDS_BARRE');
+ expect(valid(c.withBarre).barres[0]).toEqual({ finger: 1, fret: 3, fromString: 1, toString: 5 });
+ });
+
+ it('applies reach limits in barre mode too', () => {
+ const c = classifyShape(tab('1 6 6 x 1 1'), P);
+ expect(c.needsBarre).toBe(true);
+ expect(reasonOf(c.withBarre)).toBe('REACH');
+ });
+});
+
+describe('exhaustive search matches a brute-force reference (no-barre default profile)', () => {
+ // Independent reimplementation of the rules over all 4^k finger functions.
+ const reference = (shape: Shape, profile: HandProfile): { valid: string[]; reason: string } => {
+ const notes = shape.flatMap((f, s) => (f !== null && f > 0 ? [{ s, f }] : []));
+ if (notes.length > 4) return { valid: [], reason: 'NEEDS_BARRE' };
+ const out: string[] = [];
+ let crossingFree = false;
+ let fits = false;
+ let reachOk = false;
+ const total = 4 ** notes.length;
+ for (let code = 0; code < total; code++) {
+ const fingers = notes.map((_, i) => 1 + (Math.floor(code / 4 ** i) % 4));
+ if (new Set(fingers).size !== fingers.length) continue;
+ const fretOf = new Map(fingers.map((finger, i) => [finger, notes[i].f]));
+ const order = [...fretOf.keys()].sort((a, b) => a - b);
+ if (order.some((finger, i) => i > 0 && fretOf.get(finger)! < fretOf.get(order[i - 1])!)) continue;
+ crossingFree = true;
+ if (!fitsSideBySide(order.map((finger) => fretOf.get(finger)!), profile.scaleLengthMm)) continue;
+ fits = true;
+ if (notes.length > 0) {
+ const lo = Math.min(...notes.map((n) => n.f));
+ const hi = Math.max(...notes.map((n) => n.f));
+ const allowed = allowedSpanMm(profile, lo);
+ if (spanMm(lo, hi, profile.scaleLengthMm) > allowed + 1e-9) continue;
+ reachOk = true;
+ const pairOk = order.every((finger, i) => {
+ if (i === 0) return true;
+ const a = order[i - 1];
+ const distance = fingertipMm(fretOf.get(finger)!, profile.scaleLengthMm) - fingertipMm(fretOf.get(a)!, profile.scaleLengthMm);
+ const limit = pairLimitFraction(profile, a as FingerNumber, finger as FingerNumber) * allowed;
+ return distance / limit <= 1 + 1e-9;
+ });
+ if (!pairOk) continue;
+ } else {
+ reachOk = true;
+ }
+ const perString = shape.map(() => 'x');
+ notes.forEach((n, i) => (perString[n.s] = String(fingers[i])));
+ out.push(perString.join(''));
+ }
+ const reason =
+ out.length > 0 ? 'OK' : !crossingFree ? 'FINGER_CROSSING' : !fits ? 'NEEDS_BARRE' : !reachOk ? 'REACH' : 'PAIR_STRETCH';
+ return { valid: out.sort(), reason };
+ };
+
+ const key = (f: Fingering): string => f.fingers.map((x) => (x === null ? 'x' : String(x))).join('');
+
+ it('finds exactly the legal assignments and the same rejection reason on 400 random shapes', () => {
+ const rng = createRng('fingering-reference');
+ let validCount = 0;
+ for (let n = 0; n < 400; n++) {
+ const window = rng.int(1, 10);
+ const shape: (number | null)[] = Array.from({ length: 6 }, () => {
+ const r = rng.next();
+ if (r < 0.35) return null;
+ if (r < 0.5) return 0;
+ return rng.int(window, window + 5);
+ });
+ if (!shape.some((f) => f !== null)) shape[0] = 0;
+ const ref = reference(shape, P);
+ const all = enumerateFingerings(shape, P);
+ expect(all.map(key).sort()).toEqual(ref.valid);
+ expect(reasonOf(findBestFingering(shape, P))).toBe(ref.reason);
+ if (ref.reason === 'OK') validCount++;
+ }
+ // Make sure the sample exercises both outcomes.
+ expect(validCount).toBeGreaterThan(50);
+ expect(validCount).toBeLessThan(380);
+ });
+
+ it('never returns a barre, a repeated finger, a crossing or a broken limit in no-barre mode', () => {
+ const rng = createRng('fingering-invariants');
+ for (let n = 0; n < 300; n++) {
+ const w = rng.int(1, 11);
+ const shape = Array.from({ length: 6 }, () => (rng.chance(0.4) ? null : rng.int(w, w + 4)));
+ if (shape.every((f) => f === null)) continue;
+ const result = findBestFingering(shape, P);
+ if (!result.ok) continue;
+ const { fingers, barres, metrics } = result.fingering;
+ expect(barres).toEqual([]);
+ const used = fingers.filter((f) => f !== null);
+ expect(new Set(used).size).toBe(used.length);
+ expect(used.every((f) => f! >= 1 && f! <= 4)).toBe(true);
+ const byFinger = fingers
+ .map((f, s) => ({ f, fret: shape[s] }))
+ .filter((x) => x.f !== null)
+ .sort((a, b) => a.f! - b.f!);
+ for (let i = 1; i < byFinger.length; i++) expect(byFinger[i].fret!).toBeGreaterThanOrEqual(byFinger[i - 1].fret!);
+ expect(metrics.reachRatio).toBeLessThanOrEqual(1 + 1e-9);
+ for (const pair of metrics.pairs) expect(pair.ratio).toBeLessThanOrEqual(1 + 1e-9);
+ expect(fitsSideBySide(byFinger.map((x) => x.fret!))).toBe(true);
+ }
+ });
+});
+
+describe('determinism and naming', () => {
+ it('returns deep-equal results for repeated calls', () => {
+ for (const text of ['0 2 2 1 0 0', '1 3 3 2 1 1', 'x 1 1 3 3 x', 'x 3 5 5 4 3']) {
+ for (const profile of [P, BARRE, PARTIAL, THUMB]) {
+ expect(findBestFingering(tab(text), profile)).toEqual(findBestFingering(tab(text), profile));
+ expect(enumerateFingerings(tab(text), profile)).toEqual(enumerateFingerings(tab(text), profile));
+ }
+ }
+ });
+
+ it('names fingers 0..4', () => {
+ expect(FINGER_NAMES).toEqual(['thumb', 'index', 'middle', 'ring', 'pinky']);
+ });
+});
+
+describe('performance', () => {
+ it('runs 10,000 findBestFingering calls on typical 3-4 note shapes in under 500 ms', () => {
+ const shapes = [
+ '0 2 2 1 0 0',
+ 'x 3 2 0 1 0',
+ '0 2 4 0 0 0',
+ 'x 5 7 7 x x',
+ '3 5 5 x x x',
+ 'x x 7 9 10 x',
+ 'x 12 14 14 x x',
+ '5 x 7 7 5 x',
+ 'x 1 1 3 3 x',
+ 'x 7 9 9 8 x',
+ '3 x 4 5 x x',
+ 'x x 10 12 13 12'
+ ].map(tab);
+ for (let i = 0; i < 2000; i++) findBestFingering(shapes[i % shapes.length], P);
+ const start = performance.now();
+ let valid = 0;
+ for (let i = 0; i < 10000; i++) if (findBestFingering(shapes[i % shapes.length], P).ok) valid++;
+ const elapsed = performance.now() - start;
+ expect(valid).toBeGreaterThan(0);
+ expect(elapsed).toBeLessThan(500);
+ });
+});
diff --git a/engine/fingering.ts b/engine/fingering.ts
new file mode 100644
index 0000000..5c7bee8
--- /dev/null
+++ b/engine/fingering.ts
@@ -0,0 +1,836 @@
+// Finger assignment for one chord shape. The search is exhaustive and deterministic: every legal
+// assignment of fingers to fretted notes is visited, hard limits come from the hand profile, and the
+// cheapest assignment by physical cost wins (ties: lexicographically smallest fingers array).
+// In barre mode a second pass lets a non-index finger flatten over 2-3 adjacent strings (a mini-barre,
+// e.g. the ring finger in the A-shape barre chord), but only when no fingering exists without one.
+import type {
+ Barre,
+ CostBreakdown,
+ CostTerm,
+ CostTermKey,
+ Fingering,
+ FingeringMetrics,
+ FingeringRejectReason,
+ FingeringResult,
+ FingerNumber,
+ HandProfile,
+ PairStretch,
+ Shape
+} from './types';
+import { fingertipMm, fretWireMm } from './geometry';
+import { allowedSpanMm, pairLimitFraction } from './handProfile';
+import { interiorMutes } from './shape';
+import { PHYSICAL_TERMS, PLAYABILITY_WEIGHTS } from './weights';
+
+export const FINGER_NAMES = ['thumb', 'index', 'middle', 'ring', 'pinky'] as const;
+
+/**
+ * Heuristic, not research-backed: a thumb wrapped over the neck frets string 0 at most this many
+ * frets behind or ahead of the lowest fretting finger.
+ */
+export const THUMB_FRET_WINDOW = 1;
+
+/**
+ * Heuristic, not research-backed: fretting fingertips sit side by side along the neck in hand order
+ * (index nearest the nut), each inside its own fret space, and two consecutive fingers need at least this
+ * much neck length between their contact points (adult fingertips are roughly 14-18 mm wide; staggering
+ * across strings saves a little). On a 25.5" scale three fingers share one fret only up to fret 7 and
+ * four never do: such stacks are barres in disguise and are rejected as NEEDS_BARRE.
+ */
+export const MIN_FINGER_SPACING_MM = 12.5;
+
+/** A flattened non-index finger (barre mode only) covers at most this many adjacent strings. */
+export const MAX_MINI_BARRE_STRINGS = 3;
+
+const EPS = 1e-9;
+const FINGERS = 4;
+
+// Leaf outcomes, in rejection-priority order.
+const OK = 0;
+const THUMB_OUT_OF_WINDOW = 1;
+const CROSSING = 2;
+const CROWDED = 3;
+const TOO_WIDE = 4;
+const PAIR_OVER = 5;
+
+// ---------------------------------------------------------------------------
+// Physical cost terms
+// ---------------------------------------------------------------------------
+
+type TermInputs = Pick<
+ FingeringMetrics,
+ 'reachRatio' | 'maxPairStretch' | 'fingerCount' | 'usesPinky' | 'contortions' | 'interiorMutes' | 'lowestFret' | 'usesThumb'
+>;
+
+const rawTerm = (key: CostTermKey, t: TermInputs, barreCount: number): number => {
+ switch (key) {
+ case 'reach':
+ return t.reachRatio;
+ case 'pairStretch':
+ return t.maxPairStretch;
+ case 'fingerCount':
+ return t.fingerCount;
+ case 'pinky':
+ return t.usesPinky ? 1 : 0;
+ case 'contortion':
+ return t.contortions;
+ case 'interiorMutes':
+ return t.interiorMutes;
+ case 'position':
+ return t.lowestFret ?? 0;
+ case 'thumb':
+ return t.usesThumb ? 1 : 0;
+ case 'barre':
+ return barreCount;
+ default:
+ return 0;
+ }
+};
+
+const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0);
+
+const normalizeTerm = (key: CostTermKey, raw: number): number => {
+ switch (key) {
+ case 'reach':
+ case 'pairStretch':
+ return clamp01(raw * raw);
+ case 'fingerCount':
+ return clamp01(raw / 4);
+ case 'contortion':
+ case 'interiorMutes':
+ return clamp01(raw / 2);
+ case 'position':
+ return clamp01(raw / 12);
+ default:
+ return clamp01(raw);
+ }
+};
+
+/** Same arithmetic and order as physicalCostBreakdown().total, without allocating terms. */
+const physicalTotal = (t: TermInputs, barreCount: number): number => {
+ let total = 0;
+ for (let i = 0; i < PHYSICAL_TERMS.length; i++) {
+ const key = PHYSICAL_TERMS[i];
+ total += normalizeTerm(key, rawTerm(key, t, barreCount)) * PLAYABILITY_WEIGHTS[key].weight;
+ }
+ return total;
+};
+
+export const physicalCostBreakdown = (
+ _shape: Shape,
+ _fingers: (FingerNumber | null)[],
+ barres: Barre[],
+ metrics: FingeringMetrics
+): CostBreakdown => {
+ const terms: CostTerm[] = [];
+ let total = 0;
+ for (const key of PHYSICAL_TERMS) {
+ const raw = rawTerm(key, metrics, barres.length);
+ const normalized = normalizeTerm(key, raw);
+ const { bucket, weight } = PLAYABILITY_WEIGHTS[key];
+ const cost = normalized * weight;
+ total += cost;
+ terms.push({ key, bucket, raw, normalized, weight, cost });
+ }
+ return { total, physical: total, rightHand: 0, musical: 0, terms };
+};
+
+// ---------------------------------------------------------------------------
+// Search context
+// ---------------------------------------------------------------------------
+
+interface SearchContext {
+ shape: Shape;
+ profile: HandProfile;
+ /** Fretted notes in ascending string order. */
+ strings: number[];
+ frets: number[];
+ tip: Float64Array;
+ /** Fret wire positions in mm from the nut. */
+ wire: Float64Array;
+ /** pairLimitFraction for fingers a < b, at index a * 5 + b. */
+ pairFraction: number[];
+ lowestFret: number | null;
+ highestFret: number | null;
+ interiorMutes: number;
+ thumbOnString0: boolean;
+ /** Lowest fretted fret when an index barre is allowed, otherwise -1. */
+ indexBarreFret: number;
+ partialBarres: boolean;
+ /** Second pass in barre mode: fingers 2..4 may flatten over adjacent strings at their fret. */
+ miniBarres: boolean;
+ /** Fingers that could fret a note: 4, or 5 with the thumb on string 0. */
+ available: number;
+}
+
+/** Per finger 0..4; `notes[f] === 0` means unused. */
+interface SearchState {
+ assign: number[];
+ fret: number[];
+ notes: number[];
+ low: number[];
+ high: number[];
+}
+
+interface Evaluation extends TermInputs {
+ status: number;
+ minFret: number;
+ maxFret: number;
+ spanMm: number;
+ allowedSpanMm: number;
+ maxPairRatio: number;
+ worstFrom: number;
+ worstTo: number;
+ worstDistance: number;
+ worstLimit: number;
+ barreCount: number;
+ /** CROWDED detail: the tightest run of fingers, its frets and the neck length it needs and has. */
+ crowdCount: number;
+ crowdFromFret: number;
+ crowdToFret: number;
+ crowdNeedMm: number;
+ crowdHaveMm: number;
+}
+
+let tableScale = -1;
+let tipCache = new Float64Array(0);
+let wireCache = new Float64Array(0);
+
+/** Fingertip and fret wire positions per fret, cached for the last scale length used. */
+const neckTables = (scaleLengthMm: number, highestFret: number): { tip: Float64Array; wire: Float64Array } => {
+ const size = highestFret + FINGERS + 1;
+ if (scaleLengthMm !== tableScale || tipCache.length < size) {
+ const n = Math.max(size, 40);
+ const tip = new Float64Array(n);
+ const wire = new Float64Array(n);
+ for (let f = 0; f < n; f++) {
+ tip[f] = fingertipMm(f, scaleLengthMm);
+ wire[f] = fretWireMm(f, scaleLengthMm);
+ }
+ tipCache = tip;
+ wireCache = wire;
+ tableScale = scaleLengthMm;
+ }
+ return { tip: tipCache, wire: wireCache };
+};
+
+const reject = (reason: FingeringRejectReason, detail: string): FingeringResult => ({ ok: false, reason, detail });
+
+type Prepared = { ok: true; ctx: SearchContext } | { ok: false; result: FingeringResult };
+
+const prepare = (shape: Shape, profile: HandProfile): Prepared => {
+ if (!Array.isArray(shape as unknown) || shape.length < 1) {
+ return { ok: false, result: reject('INVALID_SHAPE', 'shape has no strings') };
+ }
+ let sounding = 0;
+ for (let s = 0; s < shape.length; s++) {
+ const fret = shape[s];
+ if (fret === null) continue;
+ if (typeof fret !== 'number' || !Number.isInteger(fret) || fret < 0) {
+ return { ok: false, result: reject('INVALID_SHAPE', `string ${s} has an invalid fret value`) };
+ }
+ sounding++;
+ }
+ if (sounding === 0) return { ok: false, result: reject('INVALID_SHAPE', 'no string sounds') };
+
+ const strings: number[] = [];
+ const frets: number[] = [];
+ let lowest = Infinity;
+ let highest = -Infinity;
+ for (let s = 0; s < shape.length; s++) {
+ const fret = shape[s];
+ if (fret === null || fret === 0) continue;
+ if (fret > profile.maxFret) {
+ return { ok: false, result: reject('FRET_RANGE', `fret ${fret} is above the max fret ${profile.maxFret}`) };
+ }
+ strings.push(s);
+ frets.push(fret);
+ if (fret < lowest) lowest = fret;
+ if (fret > highest) highest = fret;
+ }
+ if (!profile.allowOpenStrings && shape.some((fret) => fret === 0)) {
+ return { ok: false, result: reject('OPEN_STRINGS_DISALLOWED', 'open strings are turned off in the hand profile') };
+ }
+
+ const pairFraction = new Array(25).fill(0);
+ for (let a = 1; a < FINGERS; a++) {
+ for (let b = a + 1; b <= FINGERS; b++) pairFraction[a * 5 + b] = pairLimitFraction(profile, a as FingerNumber, b as FingerNumber);
+ }
+ const hasFretted = strings.length > 0;
+ const string0 = shape[0];
+ const thumbOnString0 = profile.allowThumb && string0 !== null && string0 > 0;
+ const { tip, wire } = neckTables(profile.scaleLengthMm, hasFretted ? highest : 0);
+ return {
+ ok: true,
+ ctx: {
+ shape,
+ profile,
+ strings,
+ frets,
+ tip,
+ wire,
+ pairFraction,
+ lowestFret: hasFretted ? lowest : null,
+ highestFret: hasFretted ? highest : null,
+ interiorMutes: interiorMutes(shape),
+ thumbOnString0,
+ indexBarreFret: !profile.noBarre && hasFretted ? lowest : -1,
+ partialBarres: profile.allowTwoStringPartialBarre,
+ miniBarres: false,
+ available: FINGERS + (thumbOnString0 ? 1 : 0)
+ }
+ };
+};
+
+// ---------------------------------------------------------------------------
+// Evaluation of one complete assignment
+// ---------------------------------------------------------------------------
+
+const safeRatio = (value: number, limit: number): number => (limit > 0 ? value / limit : value > 0 ? Infinity : 0);
+
+const pairDistanceMm = (ctx: SearchContext, fretA: number, fretB: number): number => ctx.tip[fretB] - ctx.tip[fretA];
+
+const pairLimitMm = (ctx: SearchContext, a: number, b: number, allowed: number): number => ctx.pairFraction[a * 5 + b] * allowed;
+
+/** Relaxed spacing: one fret per finger-number step from finger a's fret. */
+const naturalSpacingMm = (ctx: SearchContext, a: number, b: number, fretA: number): number =>
+ ctx.tip[fretA + (b - a)] - ctx.tip[fretA];
+
+const stretchBeyondNatural = (distance: number, natural: number, limit: number): number => {
+ if (distance <= natural) return 0;
+ return limit > natural ? Math.min(1, (distance - natural) / (limit - natural)) : 1;
+};
+
+const createEvaluation = (ctx: SearchContext): Evaluation => ({
+ status: OK,
+ minFret: 0,
+ maxFret: 0,
+ spanMm: 0,
+ allowedSpanMm: 0,
+ reachRatio: 0,
+ maxPairRatio: 0,
+ maxPairStretch: 0,
+ worstFrom: 0,
+ worstTo: 0,
+ worstDistance: 0,
+ worstLimit: 0,
+ fingerCount: 0,
+ usesPinky: false,
+ usesThumb: false,
+ contortions: 0,
+ barreCount: 0,
+ crowdCount: 0,
+ crowdFromFret: 0,
+ crowdToFret: 0,
+ crowdNeedMm: 0,
+ crowdHaveMm: 0,
+ interiorMutes: ctx.interiorMutes,
+ lowestFret: ctx.lowestFret
+});
+
+/**
+ * Packs the fretting fingers (hand order, thumb excluded, frets non-decreasing) as far toward the nut as
+ * MIN_FINGER_SPACING_MM allows. Returns false and fills the CROWDED detail when one lands past its fret wire.
+ */
+const fitsSideBySide = (ctx: SearchContext, fret: readonly number[], notes: readonly number[], ev: Evaluation): boolean => {
+ let pos = -Infinity;
+ let runStart = 0;
+ let runCount = 0;
+ for (let f = 1; f <= FINGERS; f++) {
+ if (notes[f] === 0) continue;
+ const back = ctx.wire[fret[f] - 1];
+ const packed = pos + MIN_FINGER_SPACING_MM;
+ if (packed <= back) {
+ pos = back;
+ runStart = fret[f];
+ runCount = 1;
+ } else {
+ pos = packed;
+ runCount++;
+ }
+ if (pos > ctx.wire[fret[f]] + EPS) {
+ ev.crowdCount = runCount;
+ ev.crowdFromFret = runStart;
+ ev.crowdToFret = fret[f];
+ ev.crowdNeedMm = (runCount - 1) * MIN_FINGER_SPACING_MM;
+ ev.crowdHaveMm = ctx.wire[fret[f]] - ctx.wire[runStart - 1];
+ return false;
+ }
+ }
+ return true;
+};
+
+/** Checks the hard rules in rejection-priority order and fills the cost inputs of a legal assignment. */
+const evaluate = (ctx: SearchContext, st: SearchState, ev: Evaluation): void => {
+ const { fret, notes } = st;
+ let minFret = Infinity;
+ let maxFret = -Infinity;
+ let previous = -Infinity;
+ let crossing = false;
+ for (let f = 1; f <= FINGERS; f++) {
+ if (notes[f] === 0) continue;
+ const at = fret[f];
+ if (at < previous) crossing = true;
+ previous = at;
+ if (at < minFret) minFret = at;
+ if (at > maxFret) maxFret = at;
+ }
+ const usesThumb = notes[0] > 0;
+ if (usesThumb && minFret !== Infinity && Math.abs(fret[0] - minFret) > THUMB_FRET_WINDOW) {
+ ev.status = THUMB_OUT_OF_WINDOW;
+ return;
+ }
+ if (crossing) {
+ ev.status = CROSSING;
+ return;
+ }
+ if (!fitsSideBySide(ctx, fret, notes, ev)) {
+ ev.status = CROWDED;
+ return;
+ }
+
+ ev.usesThumb = usesThumb;
+ if (minFret === Infinity) {
+ ev.minFret = 0;
+ ev.maxFret = 0;
+ ev.spanMm = 0;
+ ev.allowedSpanMm = usesThumb ? allowedSpanMm(ctx.profile, fret[0]) : 0;
+ ev.reachRatio = 0;
+ } else {
+ ev.minFret = minFret;
+ ev.maxFret = maxFret;
+ ev.spanMm = ctx.tip[maxFret] - ctx.tip[minFret];
+ ev.allowedSpanMm = allowedSpanMm(ctx.profile, minFret);
+ ev.reachRatio = safeRatio(ev.spanMm, ev.allowedSpanMm);
+ if (ev.spanMm > ev.allowedSpanMm + EPS) {
+ ev.status = TOO_WIDE;
+ return;
+ }
+ }
+
+ let maxRatio = 0;
+ let maxStretch = 0;
+ ev.worstFrom = 0;
+ ev.worstTo = 0;
+ ev.worstDistance = 0;
+ ev.worstLimit = 0;
+ let a = 0;
+ for (let b = 1; b <= FINGERS; b++) {
+ if (notes[b] === 0) continue;
+ if (a !== 0) {
+ const distance = pairDistanceMm(ctx, fret[a], fret[b]);
+ const limit = pairLimitMm(ctx, a, b, ev.allowedSpanMm);
+ const ratio = safeRatio(distance, limit);
+ const stretch = stretchBeyondNatural(distance, naturalSpacingMm(ctx, a, b, fret[a]), limit);
+ if (ratio > maxRatio) {
+ maxRatio = ratio;
+ ev.worstFrom = a;
+ ev.worstTo = b;
+ ev.worstDistance = distance;
+ ev.worstLimit = limit;
+ }
+ if (stretch > maxStretch) maxStretch = stretch;
+ }
+ a = b;
+ }
+ ev.maxPairRatio = maxRatio;
+ ev.maxPairStretch = maxStretch;
+ if (maxRatio > 1 + EPS) {
+ ev.status = PAIR_OVER;
+ return;
+ }
+
+ let fingerCount = 0;
+ let barreCount = 0;
+ for (let f = 0; f <= FINGERS; f++) {
+ if (notes[f] > 0) fingerCount++;
+ if (notes[f] > 1) barreCount++;
+ }
+ let contortions = 0;
+ for (let x = 1; x < FINGERS; x++) {
+ if (notes[x] === 0) continue;
+ for (let y = x + 1; y <= FINGERS; y++) {
+ if (notes[y] > 0 && fret[x] === fret[y] && st.low[x] > st.low[y]) contortions++;
+ }
+ }
+ ev.fingerCount = fingerCount;
+ ev.barreCount = barreCount;
+ ev.contortions = contortions;
+ ev.usesPinky = notes[FINGERS] > 0;
+ ev.status = OK;
+};
+
+const buildFingering = (ctx: SearchContext, st: SearchState, ev: Evaluation): Fingering => {
+ const { shape } = ctx;
+ const fingers = new Array(shape.length).fill(null);
+ for (let i = 0; i < ctx.strings.length; i++) fingers[ctx.strings[i]] = st.assign[i] as FingerNumber;
+
+ const barres: Barre[] = [];
+ for (let f = 0; f <= FINGERS; f++) {
+ if (st.notes[f] > 1) barres.push({ finger: f as FingerNumber, fret: st.fret[f], fromString: st.low[f], toString: st.high[f] });
+ }
+
+ const pairs: PairStretch[] = [];
+ let a = 0;
+ for (let b = 1; b <= FINGERS; b++) {
+ if (st.notes[b] === 0) continue;
+ if (a !== 0) {
+ const distanceMm = pairDistanceMm(ctx, st.fret[a], st.fret[b]);
+ const limitMm = pairLimitMm(ctx, a, b, ev.allowedSpanMm);
+ const naturalMm = naturalSpacingMm(ctx, a, b, st.fret[a]);
+ pairs.push({
+ from: a as FingerNumber,
+ to: b as FingerNumber,
+ distanceMm,
+ limitMm,
+ ratio: safeRatio(distanceMm, limitMm),
+ naturalMm,
+ stretch: stretchBeyondNatural(distanceMm, naturalMm, limitMm)
+ });
+ }
+ a = b;
+ }
+
+ const metrics: FingeringMetrics = {
+ frettedCount: ctx.strings.length,
+ fingerCount: ev.fingerCount,
+ spanMm: ev.spanMm,
+ allowedSpanMm: ev.allowedSpanMm,
+ reachRatio: ev.reachRatio,
+ pairs,
+ maxPairRatio: ev.maxPairRatio,
+ maxPairStretch: ev.maxPairStretch,
+ usesPinky: ev.usesPinky,
+ usesThumb: ev.usesThumb,
+ contortions: ev.contortions,
+ interiorMutes: ctx.interiorMutes,
+ lowestFret: ctx.lowestFret,
+ highestFret: ctx.highestFret
+ };
+ return { fingers, barres, metrics, costBreakdown: physicalCostBreakdown(shape, fingers, barres, metrics) };
+};
+
+// ---------------------------------------------------------------------------
+// Exhaustive search
+// ---------------------------------------------------------------------------
+
+interface Diagnostics {
+ /** Some assignment put the thumb outside its fret window. */
+ thumbOutOfWindow: boolean;
+ structural: boolean;
+ crossingFree: boolean;
+ /** Some crossing-free assignment fits its fingers side by side. */
+ fits: boolean;
+ /** Mildest overcrowding over all crossing-free assignments (NEEDS_BARRE detail). */
+ crowdExcessMm: number;
+ crowdCount: number;
+ crowdFromFret: number;
+ crowdToFret: number;
+ crowdNeedMm: number;
+ crowdHaveMm: number;
+ reachOk: boolean;
+ /** Narrowest failing span (REACH detail). */
+ reachRatio: number;
+ reachSpan: number;
+ reachAllowed: number;
+ reachMinFret: number;
+ reachMaxFret: number;
+ /** Mildest failing pair over all reach-legal assignments (PAIR_STRETCH detail). */
+ pairRatio: number;
+ pairFrom: number;
+ pairTo: number;
+ pairDistance: number;
+ pairLimit: number;
+}
+
+const createState = (k: number): SearchState => ({
+ assign: new Array(k).fill(0),
+ fret: [0, 0, 0, 0, 0],
+ notes: [0, 0, 0, 0, 0],
+ low: [0, 0, 0, 0, 0],
+ high: [0, 0, 0, 0, 0]
+});
+
+/**
+ * May finger `f` (already placed) also press string `s` at `fret`? Strings are visited in ascending
+ * order, so st.high[f] is the highest string the finger covers so far.
+ */
+const canExtend = (ctx: SearchContext, st: SearchState, f: number, s: number, fret: number): boolean => {
+ if (f === 0 || st.fret[f] !== fret) return false;
+ if (ctx.partialBarres && st.notes[f] === 1 && st.high[f] === s - 1) return true;
+ if (ctx.miniBarres && f > 1 && st.notes[f] < MAX_MINI_BARRE_STRINGS && st.high[f] === s - 1) return true;
+ if (f !== 1 || fret !== ctx.indexBarreFret) return false;
+ // Index barre: every string it spans must be fretted, and strings at the barre fret are the barre's.
+ for (let t = st.high[1] + 1; t < s; t++) {
+ const between = ctx.shape[t];
+ if (between === null || between === 0 || between === fret) return false;
+ }
+ return true;
+};
+
+const explore = (ctx: SearchContext, onValid: (st: SearchState, ev: Evaluation) => void): Diagnostics => {
+ const k = ctx.strings.length;
+ const st = createState(k);
+ const ev = createEvaluation(ctx);
+ const diag: Diagnostics = {
+ thumbOutOfWindow: false,
+ structural: false,
+ crossingFree: false,
+ fits: false,
+ crowdExcessMm: Infinity,
+ crowdCount: 0,
+ crowdFromFret: 0,
+ crowdToFret: 0,
+ crowdNeedMm: 0,
+ crowdHaveMm: 0,
+ reachOk: false,
+ reachRatio: Infinity,
+ reachSpan: 0,
+ reachAllowed: 0,
+ reachMinFret: 0,
+ reachMaxFret: 0,
+ pairRatio: Infinity,
+ pairFrom: 0,
+ pairTo: 0,
+ pairDistance: 0,
+ pairLimit: 0
+ };
+
+ const visit = (): void => {
+ evaluate(ctx, st, ev);
+ if (ev.status === THUMB_OUT_OF_WINDOW) {
+ diag.thumbOutOfWindow = true;
+ return;
+ }
+ diag.structural = true;
+ if (ev.status === CROSSING) return;
+ diag.crossingFree = true;
+ if (ev.status === CROWDED) {
+ const excess = ev.crowdNeedMm - ev.crowdHaveMm;
+ if (excess < diag.crowdExcessMm) {
+ diag.crowdExcessMm = excess;
+ diag.crowdCount = ev.crowdCount;
+ diag.crowdFromFret = ev.crowdFromFret;
+ diag.crowdToFret = ev.crowdToFret;
+ diag.crowdNeedMm = ev.crowdNeedMm;
+ diag.crowdHaveMm = ev.crowdHaveMm;
+ }
+ return;
+ }
+ diag.fits = true;
+ if (ev.status === TOO_WIDE) {
+ if (ev.reachRatio < diag.reachRatio) {
+ diag.reachRatio = ev.reachRatio;
+ diag.reachSpan = ev.spanMm;
+ diag.reachAllowed = ev.allowedSpanMm;
+ diag.reachMinFret = ev.minFret;
+ diag.reachMaxFret = ev.maxFret;
+ }
+ return;
+ }
+ diag.reachOk = true;
+ if (ev.status === PAIR_OVER) {
+ if (ev.maxPairRatio < diag.pairRatio) {
+ diag.pairRatio = ev.maxPairRatio;
+ diag.pairFrom = ev.worstFrom;
+ diag.pairTo = ev.worstTo;
+ diag.pairDistance = ev.worstDistance;
+ diag.pairLimit = ev.worstLimit;
+ }
+ return;
+ }
+ onValid(st, ev);
+ };
+
+ const step = (i: number): void => {
+ if (i === k) {
+ visit();
+ return;
+ }
+ const s = ctx.strings[i];
+ const fret = ctx.frets[i];
+ for (let f = s === 0 && ctx.thumbOnString0 ? 0 : 1; f <= FINGERS; f++) {
+ if (st.notes[f] === 0) {
+ st.fret[f] = fret;
+ st.notes[f] = 1;
+ st.low[f] = s;
+ st.high[f] = s;
+ st.assign[i] = f;
+ step(i + 1);
+ st.notes[f] = 0;
+ } else if (canExtend(ctx, st, f, s, fret)) {
+ const previousHigh = st.high[f];
+ st.notes[f]++;
+ st.high[f] = s;
+ st.assign[i] = f;
+ step(i + 1);
+ st.notes[f]--;
+ st.high[f] = previousHigh;
+ }
+ }
+ };
+
+ step(0);
+ return diag;
+};
+
+/** Without any multi-string finger, more notes than fingers can be rejected before searching. */
+const tooManyNotes = (ctx: SearchContext): FingeringResult | null => {
+ const k = ctx.strings.length;
+ if (ctx.profile.noBarre && !ctx.partialBarres && k > ctx.available) {
+ return reject('NEEDS_BARRE', `${k} fretted notes but only ${ctx.available} fingers without a barre`);
+ }
+ return null;
+};
+
+/** Formats two millimetre values with just enough decimals to tell them apart. */
+const formatMmPair = (value: number, limit: number): [string, string] => {
+ for (let digits = 0; digits < 2; digits++) {
+ const a = value.toFixed(digits);
+ const b = limit.toFixed(digits);
+ if (a !== b) return [a, b];
+ }
+ return [value.toFixed(2), limit.toFixed(2)];
+};
+
+const rejection = (ctx: SearchContext, diag: Diagnostics): FingeringResult => {
+ const k = ctx.strings.length;
+ if (!diag.structural) {
+ if (diag.thumbOutOfWindow) {
+ return reject(
+ 'NEEDS_BARRE',
+ `${k} fretted notes need the thumb, but its fret ${ctx.shape[0]} is more than ${THUMB_FRET_WINDOW} fret from the lowest finger`
+ );
+ }
+ return reject('NEEDS_BARRE', `${k} fretted notes do not fit ${ctx.available} fingers with the allowed barres`);
+ }
+ if (!diag.crossingFree) return reject('FINGER_CROSSING', 'every finger order needs crossed fingers');
+ if (!diag.fits) {
+ const [need, have] = formatMmPair(diag.crowdNeedMm, diag.crowdHaveMm);
+ const frets = diag.crowdFromFret === diag.crowdToFret ? `fret ${diag.crowdToFret}` : `frets ${diag.crowdFromFret}-${diag.crowdToFret}`;
+ return reject(
+ 'NEEDS_BARRE',
+ `${diag.crowdCount} fingers do not fit side by side in ${frets}: need ${need} mm, have ${have} mm`
+ );
+ }
+ if (!diag.reachOk) {
+ const [span, allowed] = formatMmPair(diag.reachSpan, diag.reachAllowed);
+ return reject('REACH', `span ${span} mm > allowed ${allowed} mm (frets ${diag.reachMinFret}-${diag.reachMaxFret})`);
+ }
+ const [distance, limit] = formatMmPair(diag.pairDistance, diag.pairLimit);
+ const pair = `${FINGER_NAMES[diag.pairFrom]}-${FINGER_NAMES[diag.pairTo]}`;
+ return reject('PAIR_STRETCH', `${pair} needs ${distance} mm > limit ${limit} mm`);
+};
+
+const lexLess = (a: readonly number[], b: readonly number[]): boolean => {
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] < b[i];
+ return false;
+};
+
+const compareFingers = (a: readonly (FingerNumber | null)[], b: readonly (FingerNumber | null)[]): number => {
+ for (let i = 0; i < a.length; i++) {
+ const x = a[i] ?? -1;
+ const y = b[i] ?? -1;
+ if (x !== y) return x - y;
+ }
+ return a.length - b.length;
+};
+
+/** Rebuilds the search state for a recorded assignment (notes in ascending string order). */
+const replay = (ctx: SearchContext, assign: readonly number[]): SearchState => {
+ const st = createState(assign.length);
+ for (let i = 0; i < assign.length; i++) {
+ const f = assign[i];
+ const s = ctx.strings[i];
+ if (st.notes[f] === 0) {
+ st.fret[f] = ctx.frets[i];
+ st.low[f] = s;
+ }
+ st.high[f] = s;
+ st.notes[f]++;
+ st.assign[i] = f;
+ }
+ return st;
+};
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+/**
+ * Runs the search; in barre mode, when nothing is valid, runs it again with mini-barres allowed (a superset
+ * of the first pass, so its diagnostics cover every assignment).
+ */
+const exploreWithFallback = (ctx: SearchContext, onValid: (st: SearchState, ev: Evaluation) => void): { diag: Diagnostics; found: boolean } => {
+ let found = false;
+ const record = (st: SearchState, ev: Evaluation): void => {
+ found = true;
+ onValid(st, ev);
+ };
+ let diag = explore(ctx, record);
+ if (!found && !ctx.profile.noBarre && ctx.strings.length > 1) {
+ ctx.miniBarres = true;
+ diag = explore(ctx, record);
+ }
+ return { diag, found };
+};
+
+export const findBestFingering = (shape: Shape, profile: HandProfile): FingeringResult => {
+ const prepared = prepare(shape, profile);
+ if (prepared.ok === false) return prepared.result;
+ const { ctx } = prepared;
+ const early = tooManyNotes(ctx);
+ if (early) return early;
+
+ let found = false;
+ let bestCost = Infinity;
+ const bestAssign = new Array(ctx.strings.length).fill(0);
+ const { diag } = exploreWithFallback(ctx, (st, ev) => {
+ const cost = physicalTotal(ev, ev.barreCount);
+ if (!found || cost < bestCost || (cost === bestCost && lexLess(st.assign, bestAssign))) {
+ found = true;
+ bestCost = cost;
+ for (let i = 0; i < bestAssign.length; i++) bestAssign[i] = st.assign[i];
+ }
+ });
+ if (!found) return rejection(ctx, diag);
+
+ const st = replay(ctx, bestAssign);
+ const ev = createEvaluation(ctx);
+ evaluate(ctx, st, ev);
+ return { ok: true, fingering: buildFingering(ctx, st, ev) };
+};
+
+/**
+ * All valid assignments, best first (physical cost asc, then lexicographic fingers array). Mini-barre
+ * assignments appear only when no assignment without one is valid.
+ */
+export const enumerateFingerings = (shape: Shape, profile: HandProfile): Fingering[] => {
+ const prepared = prepare(shape, profile);
+ if (prepared.ok === false) return [];
+ const { ctx } = prepared;
+ if (tooManyNotes(ctx)) return [];
+ const out: Fingering[] = [];
+ exploreWithFallback(ctx, (st, ev) => {
+ out.push(buildFingering(ctx, st, ev));
+ });
+ return out.sort((x, y) => x.costBreakdown.total - y.costBreakdown.total || compareFingers(x.fingers, y.fingers));
+};
+
+/**
+ * Classification for legacy/curated shapes. `noBarre` uses a copy of the profile with noBarre on,
+ * `withBarre` a copy with noBarre off (both keep the profile's partial-barre, thumb and reach settings).
+ * needsBarre is true only when a barre is what is missing: too many notes for one finger per string,
+ * more fingers in a fret than fit side by side, or no barre-free fingering while a barre fingering
+ * exists (mini-barres included). Shapes that fail for other reasons
+ * (out of reach in both modes, fret range, open strings) are not blamed on the barre.
+ */
+export const classifyShape = (
+ shape: Shape,
+ profile: HandProfile
+): { needsBarre: boolean; noBarre: FingeringResult; withBarre: FingeringResult } => {
+ const noBarre = findBestFingering(shape, { ...profile, noBarre: true });
+ const withBarre = findBestFingering(shape, { ...profile, noBarre: false });
+ const needsBarre = noBarre.ok === false && (noBarre.reason === 'NEEDS_BARRE' || withBarre.ok);
+ return { needsBarre, noBarre, withBarre };
+};
diff --git a/engine/generateVoicings.property.test.ts b/engine/generateVoicings.property.test.ts
new file mode 100644
index 0000000..630c484
--- /dev/null
+++ b/engine/generateVoicings.property.test.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect, afterAll } from 'vitest';
+import { generateVoicings } from './generateVoicings';
+import { findBestFingering } from './fingering';
+import { DEFAULT_HAND_PROFILE } from './handProfile';
+import { intervalFrom, pitchClass, pitchClassName } from './pitch';
+import { fromRiffForgeTab, shapeToMidi, toRiffForgeTab } from './shape';
+import { DROP_D, E_STANDARD } from './tuning';
+import { DRONE_FAMILIES, VOICING_FAMILIES, allowedIntervals, matchFamily } from './voicingSpec';
+import type { GenerateResult, Tuning, VoicingFamily } from './types';
+
+const P = DEFAULT_HAND_PROFILE;
+const TUNINGS: readonly Tuning[] = [E_STANDARD, DROP_D];
+const ROOTS = Array.from({ length: 12 }, (_, i) => i);
+const LIMIT = 12;
+const EPS = 1e-9;
+const COVERAGE_TARGET = 0.95;
+const FILE_BUDGET_MS = 10_000;
+const MEDIAN_BUDGET_MS = 30;
+
+const fileStart = performance.now();
+const summary: Record = {};
+
+const label = (tuning: Tuning, family: VoicingFamily, root: number): string =>
+ `${tuning.id} ${family.id} ${pitchClassName(root)}`;
+
+/** (a) exact pitch classes, (b) barre-free fingering with distinct fingers, (c) reach and pair limits, (d) audio == tab. */
+const checkVoicings = (result: GenerateResult, family: VoicingFamily, root: number, tuning: Tuning): void => {
+ for (const v of result.voicings) {
+ const where = `${label(tuning, family, root)} ${toRiffForgeTab(v.shape)}`;
+ const match = matchFamily(family, root, v.shape, tuning);
+ expect(match, where).toEqual({ ok: true });
+
+ const fingering = findBestFingering(v.shape, P);
+ expect(fingering.ok, where).toBe(true);
+ if (fingering.ok === false) continue;
+ expect(fingering.fingering.barres, where).toEqual([]);
+ const used = fingering.fingering.fingers.filter((f) => f !== null);
+ expect(new Set(used).size, where).toBe(used.length);
+ expect(v.fingering).toEqual(fingering.fingering);
+
+ const { metrics } = fingering.fingering;
+ expect(metrics.reachRatio, where).toBeLessThanOrEqual(1 + EPS);
+ expect(metrics.spanMm, where).toBeLessThanOrEqual(metrics.allowedSpanMm + EPS);
+ for (const pair of metrics.pairs) expect(pair.ratio, where).toBeLessThanOrEqual(1 + EPS);
+
+ const viaTab = fromRiffForgeTab(toRiffForgeTab(v.shape));
+ expect(viaTab, where).not.toBeNull();
+ if (viaTab) expect(v.midi, where).toEqual(shapeToMidi(viaTab, tuning));
+ }
+};
+
+/** An open string can carry the drone: root or 5th, allowed by the recipe, and not a forbidden bass on string 0. */
+const droneable = (family: VoicingFamily, root: number, tuning: Tuning): boolean => {
+ const allowed = new Set(allowedIntervals(family));
+ return tuning.openMidi.some((midi, s) => {
+ const interval = intervalFrom(root, pitchClass(midi));
+ if ((interval !== 0 && interval !== 7) || !allowed.has(interval)) return false;
+ return s !== 0 || family.bass === 'any' || family.bass.includes(interval);
+ });
+};
+
+describe('generateVoicings property: base families x 12 roots x {E standard, Drop D}', () => {
+ it('returns only exact, barre-free, reachable voicings whose audio equals the tab, for >= 95 percent of combos', () => {
+ const empties: string[] = [];
+ let combos = 0;
+ let covered = 0;
+ let voicings = 0;
+ for (const tuning of TUNINGS) {
+ for (const family of VOICING_FAMILIES) {
+ for (const root of ROOTS) {
+ const result = generateVoicings({ root, family, tuning, profile: P, limit: LIMIT });
+ combos++;
+ expect(result.voicings.length).toBeLessThanOrEqual(LIMIT);
+ checkVoicings(result, family, root, tuning);
+ voicings += result.voicings.length;
+ if (result.voicings.length > 0) {
+ covered++;
+ expect(result.empty).toBeUndefined();
+ } else {
+ expect(result.empty?.constraint, label(tuning, family, root)).toBeDefined();
+ expect(result.empty?.message.length).toBeGreaterThan(0);
+ empties.push(`${label(tuning, family, root)}: ${result.empty?.constraint} (${result.empty?.message})`);
+ }
+ }
+ }
+ }
+ const coverage = covered / combos;
+ summary.base = { combos, covered, coverage: Number(coverage.toFixed(4)), voicings, empties };
+ expect(combos).toBe(12 * VOICING_FAMILIES.length * 2);
+ expect(coverage).toBeGreaterThanOrEqual(COVERAGE_TARGET);
+ }, 20_000);
+});
+
+describe('generateVoicings property: drone families', () => {
+ it('returns drones exactly where an open string can ring the root or 5th, else names the constraint', () => {
+ let combos = 0;
+ let covered = 0;
+ let expectedCovered = 0;
+ const mismatches: string[] = [];
+ const constraints: Record = {};
+ for (const tuning of TUNINGS) {
+ for (const family of DRONE_FAMILIES) {
+ for (const root of ROOTS) {
+ const result = generateVoicings({ root, family, tuning, profile: P, limit: LIMIT });
+ combos++;
+ checkVoicings(result, family, root, tuning);
+ for (const v of result.voicings) {
+ expect(v.tags).toContain('drone');
+ expect(v.shape).toContain(0);
+ }
+ const expected = droneable(family, root, tuning);
+ if (expected) expectedCovered++;
+ if (result.voicings.length > 0) covered++;
+ else {
+ const constraint = result.empty?.constraint ?? 'MISSING';
+ constraints[constraint] = (constraints[constraint] ?? 0) + 1;
+ if (!expected) expect(constraint, label(tuning, family, root)).toBe('NO_DRONE_STRING');
+ }
+ if (expected !== result.voicings.length > 0) mismatches.push(`${label(tuning, family, root)}: ${result.empty?.constraint}`);
+ }
+ }
+ }
+ summary.drones = { combos, covered, expectedCovered, coverage: Number((covered / combos).toFixed(4)), emptyConstraints: constraints, mismatches };
+ expect(mismatches).toEqual([]);
+ }, 20_000);
+});
+
+describe('generateVoicings performance', () => {
+ it('generates one root x family x tuning in under 30 ms (median)', () => {
+ for (let i = 0; i < 40; i++) {
+ generateVoicings({ root: i % 12, family: VOICING_FAMILIES[i % VOICING_FAMILIES.length], tuning: E_STANDARD, profile: P });
+ }
+ const times: number[] = [];
+ for (const tuning of TUNINGS) {
+ for (const family of VOICING_FAMILIES) {
+ for (const root of ROOTS) {
+ const t0 = performance.now();
+ generateVoicings({ root, family, tuning, profile: P, limit: LIMIT });
+ times.push(performance.now() - t0);
+ }
+ }
+ }
+ times.sort((a, b) => a - b);
+ const median = times[Math.floor(times.length / 2)];
+ summary.timingMs = {
+ median: Number(median.toFixed(3)),
+ p90: Number(times[Math.floor(times.length * 0.9)].toFixed(3)),
+ max: Number(times[times.length - 1].toFixed(3))
+ };
+ expect(median).toBeLessThan(MEDIAN_BUDGET_MS);
+ }, 20_000);
+
+ it('finishes the whole property file in under 10 s', () => {
+ const elapsed = performance.now() - fileStart;
+ summary.fileMs = Math.round(elapsed);
+ expect(elapsed).toBeLessThan(FILE_BUDGET_MS);
+ });
+});
+
+afterAll(() => {
+ console.info(`generateVoicings property summary ${JSON.stringify(summary, null, 2)}`);
+});
diff --git a/engine/generateVoicings.test.ts b/engine/generateVoicings.test.ts
new file mode 100644
index 0000000..ba7b34a
--- /dev/null
+++ b/engine/generateVoicings.test.ts
@@ -0,0 +1,726 @@
+import { describe, it, expect } from 'vitest';
+import { SEED_JITTER, findClosestVoicing, generateVoicings, resolveFamilyId, structurallyUnfingerable } from './generateVoicings';
+import { findBestFingering } from './fingering';
+import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, allowedSpanMm, profileFromCalibration } from './handProfile';
+import { degreeString } from './naming';
+import { shapeConstraintViolation } from './playability';
+import { createRng, mulberry32, seedFromString } from './random';
+import { fromRiffForgeTab, shapeKey, shapeToMidi, toRiffForgeTab } from './shape';
+import { DROP_D, E_STANDARD } from './tuning';
+import { getFamily, matchFamily, relaxFamily } from './voicingSpec';
+import type { GeneratedVoicing, GenerateParams, HandProfile, VoicingFamily } from './types';
+
+const P = DEFAULT_HAND_PROFILE;
+
+const gen = (over: Partial = {}) =>
+ generateVoicings({ root: 'E', family: 'power5', tuning: E_STANDARD, profile: P, ...over });
+
+/** Every playable shape, in plain sort order. */
+const all = (over: Partial = {}) => gen({ limit: 100000, diversify: false, ...over });
+
+const keys = (voicings: readonly GeneratedVoicing[]): string[] => voicings.map((v) => toRiffForgeTab(v.shape));
+
+const fam = (id: string): VoicingFamily => {
+ const family = getFamily(id);
+ if (!family) throw new Error(`missing family ${id}`);
+ return family;
+};
+
+const frettedCount = (v: GeneratedVoicing): number => v.shape.filter((f) => f !== null && f > 0).length;
+
+const stringMask = (v: GeneratedVoicing): number => v.shape.reduce((m, f, s) => (f === null ? m : m | (1 << s)), 0);
+
+const nearDuplicatePairs = (voicings: readonly GeneratedVoicing[]): number => {
+ let pairs = 0;
+ for (let i = 0; i < voicings.length; i++) {
+ for (let j = i + 1; j < voicings.length; j++) {
+ const a = voicings[i].fingering.metrics.lowestFret ?? 0;
+ const b = voicings[j].fingering.metrics.lowestFret ?? 0;
+ if (Math.abs(a - b) <= 2 && stringMask(voicings[i]) === stringMask(voicings[j])) pairs++;
+ }
+ }
+ return pairs;
+};
+
+describe('generateVoicings: M1 cases', () => {
+ it('m_add9 at G never returns the barre 3 5 7 3 3 3 or more than 4 fretted notes', () => {
+ const result = all({ root: 'G', family: 'm_add9' });
+ expect(result.voicings.length).toBeGreaterThan(0);
+ expect(keys(result.voicings)).not.toContain('3 5 7 3 3 3');
+ for (const v of result.voicings) {
+ expect(frettedCount(v)).toBeLessThanOrEqual(4);
+ expect(v.fingering.barres).toEqual([]);
+ }
+ expect(keys(gen({ root: 'G', family: 'm_add9' }).voicings)).not.toContain('3 5 7 3 3 3');
+ });
+
+ it('Drop D power5 at D starts with 0 0 0 x x x, sounding D2 A2 D3', () => {
+ const result = gen({ root: 'D', tuning: DROP_D });
+ const first = result.voicings[0];
+ expect(toRiffForgeTab(first.shape)).toBe('0 0 0 x x x');
+ expect(first.midi).toEqual([38, 45, 50]);
+ expect(first.degrees).toBe('1 5 1');
+ expect(first.symbol).toBe('D5');
+ expect(first.tags).toEqual(['pedalCompatible', 'usesOpenStrings', 'rootInBass', 'lowStrings']);
+ expect(first.cost).toBe(0);
+ });
+
+ it('includes the all-open window', () => {
+ expect(keys(all({ root: 'E', family: 'fourth' }).voicings)).toContain('0 0 x x x x');
+ });
+
+ it('Em(add9) 0 2 4 0 0 0 is a valid no-barre m_add9 voicing at E', () => {
+ const fingering = findBestFingering([0, 2, 4, 0, 0, 0], P);
+ expect(fingering.ok).toBe(true);
+ const em = all({ root: 'E', family: 'm_add9' }).voicings.find((v) => toRiffForgeTab(v.shape) === '0 2 4 0 0 0');
+ expect(em).toBeDefined();
+ expect(em!.fingering.barres).toEqual([]);
+ expect(em!.degrees).toBe('1 5 2 b3 5 1');
+ });
+
+ it('open E major 0 2 2 1 0 0 is a valid major voicing with three fingers', () => {
+ const e = all({ root: 'E', family: 'major' }).voicings.find((v) => toRiffForgeTab(v.shape) === '0 2 2 1 0 0');
+ expect(e).toBeDefined();
+ expect(e!.fingering.fingers).toEqual([null, 2, 3, 1, null, null]);
+ expect(e!.fingering.metrics.fingerCount).toBe(3);
+ });
+});
+
+describe('generateVoicings: output', () => {
+ it('fills every field from the engine functions', () => {
+ for (const [root, family, tuning] of [
+ ['E', 'm_add9', E_STANDARD],
+ ['A', 'phrygian', E_STANDARD],
+ ['D', 'sus2', DROP_D],
+ ['C', 'drone_power5', E_STANDARD]
+ ] as const) {
+ const result = gen({ root, family, tuning });
+ expect(result.voicings.length).toBeGreaterThan(0);
+ for (const v of result.voicings) {
+ const rootPc = { E: 4, A: 9, D: 2, C: 0 }[root];
+ expect(v.familyId).toBe(family);
+ expect(v.midi).toEqual(shapeToMidi(v.shape, tuning));
+ expect(v.degrees).toBe(degreeString(v.shape, tuning, rootPc));
+ expect(v.degreesByString.length).toBe(6);
+ expect(v.cost).toBe(v.breakdown.total);
+ expect(v.breakdown.terms.length).toBe(14);
+ expect(v.novelty).toBeGreaterThanOrEqual(0);
+ expect(v.novelty).toBeLessThanOrEqual(1);
+ expect(v.name.length).toBeGreaterThan(0);
+ if (v.symbol !== null) expect(v.name).toBe(v.symbol);
+ expect(matchFamily(fam(family), rootPc, v.shape, tuning).ok).toBe(true);
+ expect(findBestFingering(v.shape, P)).toEqual({ ok: true, fingering: v.fingering });
+ }
+ }
+ });
+
+ it('tags pedal-friendly, open, root-bass and string-group shapes', () => {
+ const tagsOf = (text: string, over: Partial = {}) => {
+ const v = all(over).voicings.find((x) => toRiffForgeTab(x.shape) === text);
+ if (!v) throw new Error(`missing ${text}`);
+ return v.tags;
+ };
+ expect(tagsOf('x 7 9 9 x x', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'middleStrings']);
+ expect(tagsOf('0 2 2 x x x', { root: 'E' })).toEqual(['pedalCompatible', 'usesOpenStrings', 'rootInBass', 'lowStrings']);
+ expect(tagsOf('x x x 9 12 x', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'middleStrings', 'topStrings']);
+ expect(tagsOf('x x x x 5 7', { root: 'E' })).toEqual(['pedalCompatible', 'rootInBass', 'topStrings']);
+ // string 0 fretted: not pedal-compatible
+ expect(tagsOf('5 7 7 x x x', { root: 'A' })).toEqual(['rootInBass', 'lowStrings']);
+ expect(tagsOf('x x 2 4 5 0', { root: 'E', family: 'drone_power5' })).toEqual(['pedalCompatible', 'drone', 'usesOpenStrings', 'rootInBass']);
+ // open low string on the 5th still counts as a pedal string
+ expect(tagsOf('0 x 2 2 2 x', { root: 'A', family: 'major' })).toEqual(['pedalCompatible', 'usesOpenStrings']);
+ });
+
+ it('is deterministic, including the order', () => {
+ const params: GenerateParams = { root: 'A', family: 'sus2', tuning: E_STANDARD, profile: P };
+ expect(generateVoicings(params)).toEqual(generateVoicings(params));
+ expect(generateVoicings({ ...params, seed: 'riff' })).toEqual(generateVoicings({ ...params, seed: 'riff' }));
+ expect(generateVoicings({ ...params, sort: 'unusual' })).toEqual(generateVoicings({ ...params, sort: 'unusual' }));
+ });
+
+ it('accepts family objects and ids, root names and pitch classes', () => {
+ const byId = gen({ root: 'F#', family: 'm7' });
+ expect(gen({ root: 6, family: fam('m7') })).toEqual(byId);
+ expect(gen({ root: 'Gb', family: 'm7' })).toEqual(byId);
+ expect(() => gen({ family: 'no_such_family' })).toThrow(/no_such_family/);
+ expect(() => gen({ root: 'H' })).toThrow();
+ });
+
+ it('honours limit', () => {
+ expect(gen({ root: 'A', family: 'major', limit: 3 }).voicings.length).toBe(3);
+ expect(gen({ root: 'A', family: 'major' }).voicings.length).toBe(12);
+ expect(gen({ root: 'A', family: 'major', limit: 0 }).voicings).toEqual([]);
+ expect(gen({ root: 'A', family: 'major', limit: -3 }).voicings).toEqual([]);
+ expect(gen({ root: 'A', family: 'major', limit: Number.NaN }).voicings.length).toBe(12);
+ });
+
+ it('treats limit Infinity as "every playable voicing"', () => {
+ const everything = gen({ root: 'E', family: 'add9', limit: Infinity, diversify: false });
+ expect(everything.stats.playable).toBeGreaterThan(12);
+ expect(everything.voicings.length).toBe(everything.stats.playable);
+ expect(everything).toEqual(gen({ root: 'E', family: 'add9', limit: 1e9, diversify: false }));
+ const spread = gen({ root: 'E', family: 'add9', limit: Infinity });
+ expect(new Set(keys(spread.voicings))).toEqual(new Set(keys(everything.voicings)));
+ });
+
+ it('gives extended-range tunings meaningful novelty', () => {
+ const seven = { id: 'b-standard-7', name: '7-string B standard', openMidi: [35, 40, 45, 50, 55, 59, 64] };
+ const { voicings } = gen({ root: 'E', family: 'major', tuning: seven, limit: 1000, diversify: false });
+ const openE = voicings.find((v) => toRiffForgeTab(v.shape) === 'x 0 2 2 1 0 0');
+ expect(openE?.novelty).toBe(0);
+ expect(voicings.filter((v) => v.novelty < 1).length).toBeGreaterThan(voicings.length / 2);
+ });
+
+ it('reports consistent stats', () => {
+ const result = gen({ root: 'C', family: 'm_add9' });
+ const { stats } = result;
+ expect(stats.windows).toBe(P.maxFret + 1);
+ expect(stats.combinations).toBeGreaterThanOrEqual(stats.pitchValid);
+ expect(stats.pitchValid).toBeGreaterThanOrEqual(stats.playable);
+ expect(stats.playable).toBeGreaterThanOrEqual(result.voicings.length);
+ const rejected = Object.values(stats.rejections).reduce((s, n) => s + (n ?? 0), 0);
+ expect(rejected).toBeGreaterThanOrEqual(stats.combinations - stats.playable);
+ expect(result.empty).toBeUndefined();
+ });
+});
+
+describe('generateVoicings: hand profile', () => {
+ it('a smaller reach removes wide shapes; a larger one adds shapes', () => {
+ const small = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 });
+ const large = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, allowStretches: true });
+ for (const family of ['m_add9', 'sus2', 'power5']) {
+ const base = all({ family, root: 'A' });
+ const less = all({ family, root: 'A', profile: small });
+ const more = all({ family, root: 'A', profile: large });
+ const baseKeys = new Set(keys(base.voicings));
+ const moreKeys = new Set(keys(more.voicings));
+ for (const k of keys(less.voicings)) expect(baseKeys.has(k)).toBe(true);
+ for (const k of baseKeys) expect(moreKeys.has(k)).toBe(true);
+ expect(less.voicings.length).toBeLessThan(base.voicings.length);
+ expect(more.voicings.length).toBeGreaterThan(base.voicings.length);
+ const removed = base.voicings.filter((v) => !keys(less.voicings).includes(toRiffForgeTab(v.shape)));
+ expect(removed.some((v) => v.fingering.metrics.spanMm > allowedSpanMm(small, v.fingering.metrics.lowestFret ?? 0))).toBe(true);
+ }
+ });
+
+ it('allowOpenStrings=false yields no open strings', () => {
+ const closed: HandProfile = { ...P, allowOpenStrings: false };
+ for (const family of ['power5', 'm_add9', 'phrygian']) {
+ const result = all({ family, profile: closed });
+ expect(result.voicings.length).toBeGreaterThan(0);
+ for (const v of result.voicings) expect(v.shape).not.toContain(0);
+ }
+ });
+
+ it('respects maxFret and the interior-mute limit', () => {
+ const tight: HandProfile = { ...P, maxFret: 7, maxInteriorMutes: 0 };
+ const result = all({ family: 'm7', root: 'A', profile: tight });
+ expect(result.voicings.length).toBeGreaterThan(0);
+ expect(result.stats.windows).toBe(8);
+ for (const v of result.voicings) {
+ expect(Math.max(...v.shape.map((f) => f ?? 0))).toBeLessThanOrEqual(7);
+ expect(v.fingering.metrics.interiorMutes).toBe(0);
+ }
+ });
+});
+
+describe('generateVoicings: ranking', () => {
+ it("sorts 'easiest' by cost", () => {
+ const { voicings } = all({ root: 'C', family: 'add9' });
+ for (let i = 1; i < voicings.length; i++) expect(voicings[i].cost).toBeGreaterThanOrEqual(voicings[i - 1].cost);
+ });
+
+ it("sorts 'unusual' by novelty, then cost", () => {
+ const { voicings } = all({ root: 'C', family: 'add9', sort: 'unusual' });
+ expect(voicings.length).toBeGreaterThan(5);
+ for (let i = 1; i < voicings.length; i++) {
+ const [a, b] = [voicings[i - 1], voicings[i]];
+ expect(b.novelty).toBeLessThanOrEqual(a.novelty);
+ if (b.novelty === a.novelty) expect(b.cost).toBeGreaterThanOrEqual(a.cost);
+ }
+ expect(voicings[0].novelty).toBeGreaterThan(voicings[voicings.length - 1].novelty);
+ });
+
+ it('a seed only reorders near-equal results, deterministically', () => {
+ const plain = all({ root: 'D', family: 'sus4' });
+ const seeded = all({ root: 'D', family: 'sus4', seed: 'take-2' });
+ expect(new Set(keys(seeded.voicings))).toEqual(new Set(keys(plain.voicings)));
+ for (let i = 1; i < seeded.voicings.length; i++) {
+ expect(seeded.voicings[i].cost).toBeGreaterThanOrEqual(seeded.voicings[i - 1].cost - 0.01);
+ }
+ expect(all({ root: 'D', family: 'sus4', seed: 'take-2' })).toEqual(seeded);
+ });
+
+ it('diversify spreads results across positions and string sets', () => {
+ let improved = 0;
+ for (const [root, family] of [
+ ['E', 'power5'],
+ ['A', 'minor'],
+ ['G', 'm_add9'],
+ ['C', 'major'],
+ ['D', 'sus2'],
+ ['F', 'm7'],
+ ['B', 'quartal'],
+ ['E', 'phrygian']
+ ] as const) {
+ const spread = gen({ root, family });
+ const plain = gen({ root, family, diversify: false });
+ expect(spread.voicings.length).toBe(plain.voicings.length);
+ expect(nearDuplicatePairs(spread.voicings)).toBeLessThanOrEqual(nearDuplicatePairs(plain.voicings));
+ if (nearDuplicatePairs(spread.voicings) < nearDuplicatePairs(plain.voicings)) improved++;
+ const full = new Set(keys(all({ root, family }).voicings));
+ for (const k of keys(spread.voicings)) expect(full.has(k)).toBe(true);
+ expect(spread.voicings[0]).toEqual(plain.voicings[0]);
+ }
+ expect(improved).toBeGreaterThan(0);
+ });
+
+ it('diversify follows the contract: +0.6 per selected voicing on the same strings within 2 frets', () => {
+ for (const [root, family, sort] of [
+ ['E', 'power5', 'easiest'],
+ ['A', 'minor', 'easiest'],
+ ['G', 'm_add9', 'easiest'],
+ ['C', 'add9', 'unusual']
+ ] as const) {
+ const sorted = all({ root, family, sort }).voicings;
+ const scoreOf = (v: GeneratedVoicing) => (sort === 'unusual' ? -v.novelty : v.cost);
+ const picked: GeneratedVoicing[] = [];
+ const left = [...sorted];
+ while (picked.length < 12 && left.length > 0) {
+ let best = 0;
+ let bestScore = Infinity;
+ left.forEach((c, i) => {
+ const near = picked.filter(
+ (p) =>
+ stringMask(p) === stringMask(c) &&
+ Math.abs((p.fingering.metrics.lowestFret ?? 0) - (c.fingering.metrics.lowestFret ?? 0)) <= 2
+ ).length;
+ const score = scoreOf(c) + 0.6 * near;
+ if (score < bestScore) {
+ best = i;
+ bestScore = score;
+ }
+ });
+ picked.push(left.splice(best, 1)[0]);
+ }
+ expect(keys(gen({ root, family, sort }).voicings), `${root} ${family}`).toEqual(keys(picked));
+ }
+ });
+
+ it("the 'chug' context favours fewer, lower strings", () => {
+ const weight = (vs: readonly GeneratedVoicing[]) =>
+ vs.reduce((sum, v) => sum + v.midi.length + v.shape.reduce((a, f, i) => (f === null ? a : a + i), 0) / v.midi.length, 0) /
+ vs.length;
+ let lighter = 0;
+ for (const [root, family] of [['E', 'power5'], ['A', 'major'], ['E', 'minor'], ['D', 'sus4'], ['G', 'fourth']] as const) {
+ const strum = gen({ root, family, diversify: false, limit: 8 }).voicings;
+ const chug = gen({ root, family, diversify: false, limit: 8, context: 'chug' }).voicings;
+ expect(weight(chug)).toBeLessThanOrEqual(weight(strum));
+ if (weight(chug) < weight(strum)) lighter++;
+ for (const v of chug) expect(v.breakdown.terms.find((t) => t.key === 'chugStrings')?.cost).toBeGreaterThan(0);
+ for (const v of strum) expect(v.breakdown.terms.find((t) => t.key === 'chugStrings')?.cost).toBe(0);
+ }
+ expect(lighter).toBeGreaterThan(0);
+ });
+});
+
+describe('generateVoicings: empty results name the binding constraint', () => {
+ it('no drone string: drone 4th on C in E standard', () => {
+ const result = gen({ root: 'C', family: 'drone_fourth' });
+ expect(result.voicings).toEqual([]);
+ expect(result.empty?.constraint).toBe('NO_DRONE_STRING');
+ expect(result.empty?.message).toMatch(/drone/);
+ });
+
+ it('open strings off: a drone needs an open string', () => {
+ const result = gen({ root: 'E', family: 'drone_power5', profile: { ...P, allowOpenStrings: false } });
+ expect(result.voicings).toEqual([]);
+ expect(result.empty?.constraint).toBe('OPEN_STRINGS');
+ expect(result.empty?.message).toMatch(/open strings/);
+ });
+
+ it('needs a barre: five distinct fretted notes', () => {
+ const fiveNotes: VoicingFamily = {
+ id: 'six_nine',
+ label: '6/9 five-note',
+ description: 'test recipe',
+ group: 'legacy',
+ required: [0, 2, 4, 7, 9],
+ optional: [],
+ forbidden: [],
+ bass: [0],
+ minNotes: 5,
+ maxNotes: 5,
+ tags: []
+ };
+ const result = gen({ root: 'C', family: fiveNotes, profile: { ...P, allowOpenStrings: false } });
+ expect(result.voicings).toEqual([]);
+ expect(result.empty?.constraint).toBe('NEEDS_BARRE');
+ expect(result.empty?.message).toMatch(/needs a barre/);
+ });
+
+ it('reach: a two-note power chord for a tiny hand without open strings', () => {
+ const tiny: HandProfile = { ...P, reachAtLowMm: 30, reachAtHighMm: 30, allowOpenStrings: false };
+ const twoNote: VoicingFamily = { ...fam('power5'), id: 'power5_two', minNotes: 2, maxNotes: 2 };
+ const result = gen({ root: 'A', family: twoNote, profile: tiny });
+ expect(result.voicings).toEqual([]);
+ expect(result.empty?.constraint).toBe('REACH');
+ expect(result.empty?.message).toMatch(/reach/);
+ });
+
+ it('pitch classes: a contradictory recipe', () => {
+ const broken: VoicingFamily = { ...fam('power5'), id: 'broken', forbidden: [7] };
+ const result = gen({ family: broken });
+ expect(result.voicings).toEqual([]);
+ expect(result.empty?.constraint).toBe('PITCH_CLASSES');
+ });
+
+ it('note count: more required notes than the recipe allows', () => {
+ const broken: VoicingFamily = { ...fam('phrygian'), id: 'broken', maxNotes: 3 };
+ expect(gen({ family: broken }).empty?.constraint).toBe('NOTE_COUNT');
+ });
+
+ it('max fret 3 + phrygian: every empty root names a constraint in one line', () => {
+ const low: HandProfile = { ...P, maxFret: 3 };
+ let empties = 0;
+ for (let root = 0; root < 12; root++) {
+ const result = gen({ root, family: 'phrygian', profile: low });
+ if (result.voicings.length > 0) continue;
+ empties++;
+ expect(result.empty).toBeDefined();
+ expect(result.empty?.message).not.toContain('\n');
+ expect(result.empty?.message.length).toBeGreaterThan(10);
+ }
+ expect(empties).toBeGreaterThan(0);
+ });
+});
+
+describe('findClosestVoicing', () => {
+ it('returns the exact result when it has voicings', () => {
+ const params: GenerateParams = { root: 'E', family: 'm_add9', tuning: E_STANDARD, profile: P };
+ const closest = findClosestVoicing(params);
+ expect(closest.relaxed).toBeNull();
+ expect(closest.result).toEqual(generateVoicings(params));
+ });
+
+ it('walks the relaxation ladder when the exact recipe is empty', () => {
+ // phrygian needs 4..6 notes with the root in the bass; maxFret 3 without open strings leaves some roots empty
+ const tight: HandProfile = { ...P, maxFret: 3, allowOpenStrings: false };
+ let relaxedCases = 0;
+ for (let root = 0; root < 12; root++) {
+ const params: GenerateParams = { root, family: 'phrygian', tuning: E_STANDARD, profile: tight };
+ const exact = generateVoicings(params);
+ const closest = findClosestVoicing(params);
+ if (exact.voicings.length > 0) {
+ expect(closest.relaxed).toBeNull();
+ continue;
+ }
+ if (closest.relaxed === null) {
+ expect(closest.result).toEqual(exact);
+ continue;
+ }
+ relaxedCases++;
+ const ladder = relaxFamily(fam('phrygian'));
+ const step = ladder.find((s) => s.relaxed.join() === closest.relaxed?.join());
+ expect(step).toBeDefined();
+ expect(closest.result.voicings.length).toBeGreaterThan(0);
+ for (const v of closest.result.voicings) {
+ expect(v.familyId).toBe(step?.family.id);
+ expect(v.familyId).toContain('~');
+ }
+ // earlier ladder steps were empty
+ for (const earlier of ladder.slice(0, ladder.indexOf(step!))) {
+ expect(generateVoicings({ ...params, family: earlier.family }).voicings).toEqual([]);
+ }
+ }
+ expect(relaxedCases).toBeGreaterThan(0);
+ });
+
+ it('returns the exact empty result when nothing on the ladder works', () => {
+ const params: GenerateParams = { root: 'C', family: 'drone_fourth', tuning: E_STANDARD, profile: P };
+ const closest = findClosestVoicing(params);
+ expect(closest.relaxed).toBeNull();
+ expect(closest.result.empty?.constraint).toBe('NO_DRONE_STRING');
+ expect(closest.exactEmpty).toEqual(closest.result.empty);
+ });
+
+ it('keeps the exact diagnosis next to a relaxed suggestion', () => {
+ const params: GenerateParams = { root: 'D#', family: 'power5_b2', tuning: E_STANDARD, profile: { ...P, maxFret: 5, allowOpenStrings: false } };
+ const exact = generateVoicings(params);
+ expect(exact.voicings).toEqual([]);
+ const closest = findClosestVoicing(params);
+ expect(closest.relaxed).toEqual(['any bass note']);
+ expect(closest.result.voicings.length).toBeGreaterThan(0);
+ expect(closest.result.empty).toBeUndefined();
+ expect(closest.exactEmpty).toEqual(exact.empty);
+ expect(closest.exactEmpty?.constraint).toBe('MAX_FRET');
+ expect(findClosestVoicing({ ...params, root: 'E', profile: P }).exactEmpty).toBeUndefined();
+ });
+
+ it('returns relaxed family ids that resolve and regenerate', () => {
+ const params: GenerateParams = { root: 'D#', family: 'power5_b2', tuning: E_STANDARD, profile: { ...P, maxFret: 5, allowOpenStrings: false } };
+ const closest = findClosestVoicing(params);
+ expect(closest.relaxed).toEqual(['any bass note']);
+ const id = closest.result.voicings[0].familyId;
+ expect(id).toBe('power5_b2~anyBass');
+ const resolved = resolveFamilyId(id);
+ expect(resolved).toEqual(relaxFamily(fam('power5_b2'))[0].family);
+ expect(resolved?.label).toBe(fam('power5_b2').label);
+ expect(generateVoicings({ ...params, family: id })).toEqual(closest.result);
+ expect(resolveFamilyId('power5_b2')).toBe(fam('power5_b2'));
+ expect(resolveFamilyId('power5_b2~noSuchStep')).toBeUndefined();
+ expect(resolveFamilyId('no_such~anyBass')).toBeUndefined();
+ expect(() => generateVoicings({ ...params, family: 'power5_b2~noSuchStep' })).toThrow(/power5_b2~noSuchStep/);
+ });
+});
+
+/** Every shape over frets 0..maxFret with allowed pitch classes, filtered by the engine's own validators. */
+const bruteForce = (family: VoicingFamily, rootPc: number, tuning: typeof E_STANDARD, profile: HandProfile): Set => {
+ const allowed = new Set([...family.required, ...family.optional]);
+ const options = tuning.openMidi.map((open) => {
+ const list: (number | null)[] = [null];
+ for (let f = 0; f <= profile.maxFret; f++) if (allowed.has((((open + f - rootPc) % 12) + 12) % 12)) list.push(f);
+ return list;
+ });
+ const found = new Set();
+ const shape: (number | null)[] = new Array(tuning.openMidi.length).fill(null);
+ const walk = (s: number): void => {
+ if (s === shape.length) {
+ if (matchFamily(family, rootPc, shape, tuning).ok !== true) return;
+ if (shapeConstraintViolation(shape, profile) !== null) return;
+ if (findBestFingering(shape, profile).ok !== true) return;
+ found.add(toRiffForgeTab(shape));
+ return;
+ }
+ for (const f of options[s]) {
+ shape[s] = f;
+ walk(s + 1);
+ }
+ shape[s] = null;
+ };
+ walk(0);
+ return found;
+};
+
+describe('generateVoicings: completeness against brute force', () => {
+ const profiles: [string, HandProfile][] = [
+ ['default', P],
+ ['thumb', { ...P, allowThumb: true }],
+ ['partial barre', { ...P, allowTwoStringPartialBarre: true }],
+ ['barre mode', { ...P, noBarre: false }],
+ ['small reach, no mutes', { ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }), maxInteriorMutes: 0 }],
+ ['stretch, 2 mutes, no open', { ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, allowStretches: true, allowOpenStrings: false }), maxInteriorMutes: 2 }],
+ ['max fret 9', { ...P, maxFret: 9 }]
+ ];
+ const combos: [typeof E_STANDARD, string, number][] = [
+ [E_STANDARD, 'power5', 4],
+ [E_STANDARD, 'm_add9', 7],
+ [DROP_D, 'phrygian', 2],
+ [E_STANDARD, 'quartal', 9],
+ [E_STANDARD, 'cluster_m2', 0],
+ [DROP_D, 'drone_sus2', 2],
+ [E_STANDARD, 'major', 6],
+ [DROP_D, 'power5_b2', 11]
+ ];
+
+ for (const [name, profile] of profiles) {
+ it(`finds exactly the valid shapes (${name})`, () => {
+ for (const [tuning, familyId, root] of combos) {
+ const family = fam(familyId);
+ const expected = bruteForce(family, root, tuning, profile);
+ const actual = keys(all({ root, family, tuning, profile }).voicings);
+ expect(new Set(actual), `${tuning.id} ${familyId} ${root}`).toEqual(expected);
+ expect(actual.length).toBe(expected.size);
+ }
+ }, 30_000);
+ }
+});
+
+describe('structurallyUnfingerable (pre-check before the exhaustive fingering search)', () => {
+ const wide: HandProfile = {
+ ...P,
+ noBarre: false,
+ allowTwoStringPartialBarre: true,
+ allowThumb: true,
+ stretchTolerance: 1.5,
+ reachAtLowMm: 160,
+ reachAtHighMm: 160,
+ maxFret: 24,
+ maxInteriorMutes: 4
+ };
+ const profiles: [string, HandProfile][] = [
+ ['default', P],
+ ['thumb', { ...P, allowThumb: true }],
+ ['partial barre', { ...P, allowTwoStringPartialBarre: true }],
+ ['partial barre + thumb', { ...P, allowTwoStringPartialBarre: true, allowThumb: true }],
+ ['barre mode', { ...P, noBarre: false }],
+ ['barre mode + thumb', { ...P, noBarre: false, allowThumb: true }],
+ ['wide, every option', wide]
+ ];
+
+ it('only flags shapes the fingering search rejects as NEEDS_BARRE, and flags most structural misses', () => {
+ const rng = createRng('unfingerable');
+ for (const [name, profile] of profiles) {
+ let flagged = 0;
+ let structuralMisses = 0;
+ for (let i = 0; i < 4000; i++) {
+ const base = rng.int(1, 11);
+ const shape = Array.from({ length: 6 }, () => (rng.chance(0.25) ? null : rng.chance(0.15) ? 0 : base + rng.int(0, 4)));
+ if (shape.every((f) => f === null)) continue;
+ const result = findBestFingering(shape, profile);
+ if (structurallyUnfingerable(shape, profile)) {
+ flagged++;
+ expect(result, `${name} ${toRiffForgeTab(shape)}`).toMatchObject({ ok: false, reason: 'NEEDS_BARRE' });
+ } else if (result.ok === false && /do not fit \d+ fingers|but only \d+ fingers|need the thumb/.test(result.detail)) {
+ structuralMisses++;
+ }
+ }
+ expect(flagged, name).toBeGreaterThan(0);
+ expect(structuralMisses, name).toBeLessThan(flagged);
+ }
+ });
+
+ it('flags the classic barre shapes in no-barre mode only', () => {
+ for (const text of ['1 3 3 2 1 1', 'x 1 3 3 3 1', '3 5 7 3 3 3']) {
+ const shape = fromRiffForgeTab(text)!;
+ expect(structurallyUnfingerable(shape, P), text).toBe(true);
+ expect(structurallyUnfingerable(shape, { ...P, noBarre: false }), text).toBe(false);
+ }
+ expect(structurallyUnfingerable(fromRiffForgeTab('0 2 2 1 0 0')!, P)).toBe(false);
+ expect(structurallyUnfingerable(fromRiffForgeTab('3 x 0 0 0 3')!, P)).toBe(false);
+ });
+
+ it('keeps generation exact for a barre, partial-barre, thumb and wide-stretch profile', () => {
+ const profile: HandProfile = { ...wide, maxFret: 8 };
+ for (const [tuning, familyId, root] of [
+ [E_STANDARD, 'm_add9', 7],
+ [DROP_D, 'add9', 7],
+ [E_STANDARD, 'major', 0],
+ [DROP_D, 'phrygian', 2]
+ ] as const) {
+ const family = fam(familyId);
+ const expected = bruteForce(family, root, tuning, profile);
+ expect(new Set(keys(all({ root, family, tuning, profile }).voicings)), `${tuning.id} ${familyId}`).toEqual(expected);
+ }
+ }, 60_000);
+});
+
+/** The contract's selection, replayed on every playable voicing in plain sort order. */
+const contractPicks = (sorted: readonly GeneratedVoicing[], over: Partial, limit: number): string[] => {
+ const jitter = (v: GeneratedVoicing): number =>
+ over.seed === undefined ? 0 : mulberry32(seedFromString(over.seed + shapeKey(v.shape)))() * SEED_JITTER;
+ const primary = (v: GeneratedVoicing): number => (over.sort === 'unusual' ? -v.novelty : v.cost) + jitter(v);
+ if (over.diversify === false) return keys(sorted.slice(0, limit));
+ const picked: GeneratedVoicing[] = [];
+ const left = [...sorted];
+ while (picked.length < limit && left.length > 0) {
+ let best = 0;
+ let bestScore = Infinity;
+ left.forEach((c, i) => {
+ const near = picked.filter(
+ (p) =>
+ stringMask(p) === stringMask(c) &&
+ Math.abs((p.fingering.metrics.lowestFret ?? 0) - (c.fingering.metrics.lowestFret ?? 0)) <= 2
+ ).length;
+ const score = primary(c) + 0.6 * near;
+ if (score < bestScore) {
+ best = i;
+ bestScore = score;
+ }
+ });
+ picked.push(left.splice(best, 1)[0]);
+ }
+ return keys(picked);
+};
+
+describe('generateVoicings: heavy hand profiles (lazy fingering search)', () => {
+ const heavyCalibration = profileFromCalibration({
+ ...DEFAULT_CALIBRATION,
+ lowPinkyFret: 7,
+ highPinkyFret: 13,
+ allowStretches: true,
+ noBarre: false,
+ maxFret: 24
+ });
+ const extreme: HandProfile = {
+ ...P,
+ noBarre: false,
+ allowTwoStringPartialBarre: true,
+ allowThumb: true,
+ stretchTolerance: 1.5,
+ reachAtLowMm: 160,
+ reachAtHighMm: 160,
+ maxFret: 24,
+ maxInteriorMutes: 4
+ };
+
+ it('returns exactly the voicings an exhaustive search selects', () => {
+ let lazyRuns = 0;
+ const cases = [
+ ['heavy calibration', heavyCalibration, E_STANDARD, 'add9', 7, [{}, { seed: 'take-3' }, { sort: 'unusual' as const }]],
+ ['heavy calibration', heavyCalibration, DROP_D, 'major', 4, [{}, { sort: 'unusual' as const }]],
+ ['extreme, max fret 12', { ...extreme, maxFret: 12 }, DROP_D, 'm_add9', 2, [{}, { seed: 'take-3' }, { sort: 'unusual' as const }]],
+ ['extreme, max fret 12', { ...extreme, maxFret: 12 }, E_STANDARD, 'sus2', 9, [{}]]
+ ] as const;
+ for (const [profileName, profile, tuning, family, root, overs] of cases) {
+ for (const over of overs) {
+ const base = { root, family, tuning, profile, ...over };
+ const every = all(base);
+ for (const variant of [{}, { diversify: false }, { limit: 1 }, { limit: 30 }]) {
+ const params = { ...base, ...variant };
+ const result = gen(params);
+ const where = `${profileName} ${tuning.id} ${family} ${root} ${JSON.stringify(over)} ${JSON.stringify(variant)}`;
+ expect(keys(result.voicings), where).toEqual(contractPicks(every.voicings, params, params.limit ?? 12));
+ expect(result.stats.combinations, where).toBe(every.stats.combinations);
+ expect(result.stats.pitchValid, where).toBe(every.stats.pitchValid);
+ if (result.stats.playable < every.stats.playable) lazyRuns++;
+ }
+ const none = gen({ ...base, limit: 0 });
+ expect(none.voicings).toEqual([]);
+ expect(none.empty).toBeUndefined();
+ }
+ }
+ expect(lazyRuns).toBeGreaterThan(20);
+ }, 120_000);
+
+ it('stays deterministic, including the order and the stats', () => {
+ const params: GenerateParams = { root: 'G', family: 'add9', tuning: DROP_D, profile: extreme, seed: 'riff' };
+ expect(generateVoicings(params)).toEqual(generateVoicings(params));
+ expect(generateVoicings({ ...params, sort: 'unusual' })).toEqual(generateVoicings({ ...params, sort: 'unusual' }));
+ });
+
+ it('meets the time budget on the heaviest profiles', () => {
+ const timeAll = (profile: HandProfile, families: readonly string[], tunings = [E_STANDARD, DROP_D]): number[] => {
+ for (let i = 0; i < 8; i++) generateVoicings({ root: i, family: families[i % families.length], tuning: E_STANDARD, profile });
+ const times: number[] = [];
+ for (const tuning of tunings) {
+ for (const family of families) {
+ for (let root = 0; root < 12; root++) {
+ const t0 = performance.now();
+ generateVoicings({ root, family, tuning, profile });
+ times.push(performance.now() - t0);
+ }
+ }
+ }
+ return times.sort((a, b) => a - b);
+ };
+ const pct = (times: number[], p: number): number => times[Math.min(times.length - 1, Math.floor(times.length * p))];
+ // Every calibration answer at its widest (barres on, stretches on, max fret 24): median about 6 ms, max about 18 ms
+ const calibrated = timeAll(heavyCalibration, ['add9', 'm_add9', 'sus2', 'sus4', 'major', 'phrygian', 'power5_b2']);
+ expect(pct(calibrated, 0.5)).toBeLessThan(15);
+ expect(pct(calibrated, 0.95)).toBeLessThan(30);
+ // Every option on and the widest hand, heaviest recipes: median about 35 ms, max about 90 ms here (400-700 ms
+ // before the lazy search); generous bounds so a loaded machine does not fail the run
+ const widest = timeAll(extreme, ['add9', 'm_add9'], [DROP_D]);
+ expect(pct(widest, 0.5)).toBeLessThan(120);
+ expect(pct(widest, 1)).toBeLessThan(400);
+ }, 60_000);
+});
+
+describe('generateVoicings: keys', () => {
+ it('never returns duplicate shapes', () => {
+ const { voicings } = all({ root: 'B', family: 'minor' });
+ const list = voicings.map((v) => shapeKey(v.shape));
+ expect(new Set(list).size).toBe(list.length);
+ });
+});
diff --git a/engine/generateVoicings.ts b/engine/generateVoicings.ts
new file mode 100644
index 0000000..a58a8f5
--- /dev/null
+++ b/engine/generateVoicings.ts
@@ -0,0 +1,892 @@
+// Voicing generation: a pruned, exhaustive search over fret windows anchored at the lowest fretted fret, exact
+// recipe validation, the fingering search as the hard gate, then cost ranking and diversification across positions
+// and string sets. Large searches (wide, barre or thumb profiles) run the fingering search lazily in lower-bound
+// order, with the same results. Deterministic for equal inputs, including the order of the results.
+import type {
+ BindingConstraint,
+ Fingering,
+ FingeringRejectReason,
+ FingeringResult,
+ GenerateParams,
+ GenerateResult,
+ GenerateStats,
+ GeneratedVoicing,
+ HandProfile,
+ Shape,
+ Tuning,
+ VoicingFamily,
+ VoicingTag
+} from './types';
+import { MAX_MINI_BARRE_STRINGS, THUMB_FRET_WINDOW, findBestFingering } from './fingering';
+import { fingertipMm, highestReachableFret } from './geometry';
+import { allowedSpanMm } from './handProfile';
+import { nameVoicing } from './naming';
+import { intervalFrom, pitchClass, pitchClassName, toPitchClass } from './pitch';
+import {
+ countInteriorMutes,
+ nonPhysicalCost,
+ noveltyAgainst,
+ prepareReferences,
+ scoreTotal,
+ scoreVoicing,
+ shapeConstraintViolation,
+ type ScoreContext
+} from './playability';
+import { mulberry32, seedFromString } from './random';
+import { interiorMutes, shapeKey, shapeToMidi } from './shape';
+import { allowedIntervals, getFamily, matchFamily, relaxFamily } from './voicingSpec';
+import { PLAYABILITY_WEIGHTS } from './weights';
+
+export const DEFAULT_VOICING_LIMIT = 12;
+/** Added to a candidate's sort score per already selected voicing at a nearby position on the same strings. */
+export const DIVERSITY_PENALTY = 0.6;
+/** "Nearby": lowest fretted frets (0 when none) at most this far apart. */
+export const DIVERSITY_FRET_DISTANCE = 2;
+/** Seeded ordering jitter range: only reorders results whose sort keys are this close. */
+export const SEED_JITTER = 0.01;
+
+const FINGERS = 4;
+const EPS = 1e-9;
+
+// Empty-result diagnosis searches a wider space than the hand allows, so the constraints that the main search
+// enforces by construction (reach windows, max fret, open strings) can be named. Heuristic bounds.
+const DIAG_REACH_FACTOR = 1.5;
+const DIAG_MIN_WINDOW_FRETS = 4;
+const DIAG_EXTRA_FRETS = 12;
+const DIAG_FRET_CAP = 24;
+
+/** Tie order when two constraints explain the same number of near misses. */
+const BINDING_ORDER: readonly BindingConstraint[] = [
+ 'NEEDS_BARRE',
+ 'REACH',
+ 'PAIR_STRETCH',
+ 'FINGER_CROSSING',
+ 'INTERIOR_MUTES',
+ 'MAX_FRET',
+ 'OPEN_STRINGS',
+ 'PITCH_CLASSES',
+ 'NOTE_COUNT',
+ 'BASS',
+ 'NO_DRONE_STRING'
+];
+
+const FINGERING_TO_BINDING: Readonly> = {
+ NEEDS_BARRE: 'NEEDS_BARRE',
+ REACH: 'REACH',
+ PAIR_STRETCH: 'PAIR_STRETCH',
+ FINGER_CROSSING: 'FINGER_CROSSING',
+ FRET_RANGE: 'MAX_FRET',
+ OPEN_STRINGS_DISALLOWED: 'OPEN_STRINGS',
+ INVALID_SHAPE: 'NOTE_COUNT'
+};
+
+type Counts = Partial>;
+
+const bump = (counts: Counts, key: BindingConstraint, by = 1): void => {
+ counts[key] = (counts[key] ?? 0) + by;
+};
+
+const maskOf = (intervals: readonly number[]): number => {
+ let mask = 0;
+ for (const i of intervals) mask |= 1 << pitchClass(i);
+ return mask;
+};
+
+const popcount = (mask: number): number => {
+ let count = 0;
+ for (let m = mask; m; m &= m - 1) count++;
+ return count;
+};
+
+/**
+ * A registered family id, or a relaxed id `~~...` as findClosestVoicing puts in
+ * GeneratedVoicing.familyId (rebuilt from relaxFamily, so stored ids regenerate the same recipe).
+ */
+export const resolveFamilyId = (id: string): VoicingFamily | undefined => {
+ const direct = getFamily(id);
+ if (direct) return direct;
+ const cut = id.indexOf('~');
+ if (cut < 0) return undefined;
+ const base = getFamily(id.slice(0, cut));
+ return base ? relaxFamily(base).find((step) => step.family.id === id)?.family : undefined;
+};
+
+const resolveFamily = (family: VoicingFamily | string): VoicingFamily => {
+ if (typeof family !== 'string') return family;
+ const found = resolveFamilyId(family);
+ if (!found) throw new Error(`Unknown voicing family "${family}"`);
+ return found;
+};
+
+// ---------------------------------------------------------------------------
+// Finger supply pre-check
+// ---------------------------------------------------------------------------
+
+/**
+ * Lower bound on the fingers 1..4 that the fretted notes from string `from` up need. Each finger holds one fret and
+ * covers at most `cap` adjacent strings there; the index barre at `barreFret` covers that fret's notes inside one run
+ * of consecutive fretted strings.
+ */
+const fingersNeeded = (shape: Shape, from: number, cap: number, barreFret: number): number => {
+ const n = shape.length;
+ let total = 0;
+ let blockSave = 0;
+ let bestSave = 0;
+ let s = from;
+ while (s < n) {
+ const f = shape[s];
+ if (f === null || f === 0) {
+ blockSave = 0;
+ s++;
+ continue;
+ }
+ let e = s;
+ while (e + 1 < n && shape[e + 1] === f) e++;
+ const groups = Math.ceil((e - s + 1) / cap);
+ total += groups;
+ if (f === barreFret) {
+ blockSave += groups;
+ if (blockSave > bestSave) bestSave = blockSave;
+ }
+ s = e + 1;
+ }
+ return bestSave > 0 ? total - bestSave + 1 : total;
+};
+
+/** Most adjacent strings one finger covers at one fret, outside the index barre (mini-barres in barre mode). */
+const supplyCap = (profile: HandProfile): number =>
+ !profile.noBarre ? MAX_MINI_BARRE_STRINGS : profile.allowTwoStringPartialBarre ? 2 : 1;
+
+/**
+ * Cheap necessary condition for a fingering, mirroring fingering.ts's structural rules: one fret per finger, partial
+ * barres (2 adjacent strings) and mini-barres (3, barre mode) at one fret, the index barre at the lowest fret, and the
+ * thumb on string 0 within THUMB_FRET_WINDOW of the lowest finger. For a shape inside the profile's fret and
+ * open-string limits, true means findBestFingering rejects it with NEEDS_BARRE, so the exhaustive search is skipped.
+ */
+export const structurallyUnfingerable = (shape: Shape, profile: HandProfile): boolean => {
+ let lowest = Infinity;
+ let lowestAbove0 = Infinity;
+ for (let s = 0; s < shape.length; s++) {
+ const f = shape[s];
+ if (typeof f !== 'number' || !(f > 0)) continue;
+ if (f < lowest) lowest = f;
+ if (s > 0 && f < lowestAbove0) lowestAbove0 = f;
+ }
+ if (lowest === Infinity) return false;
+ const cap = supplyCap(profile);
+ const barreFret = profile.noBarre ? -1 : lowest;
+ if (fingersNeeded(shape, 0, cap, barreFret) <= FINGERS) return false;
+ const thumbFret = shape[0];
+ if (!profile.allowThumb || typeof thumbFret !== 'number' || !(thumbFret > 0)) return true;
+ // Thumb on string 0: the fingers hold the other notes (there are some, a lone note needs one finger)
+ if (Math.abs(thumbFret - lowestAbove0) > THUMB_FRET_WINDOW) return true;
+ return fingersNeeded(shape, 1, cap, barreFret) > FINGERS;
+};
+
+// ---------------------------------------------------------------------------
+// Shape enumeration
+// ---------------------------------------------------------------------------
+
+/** Fretted notes must include `anchor` (the lowest fretted fret) and stay within [anchor, hi]. Anchor 0 = open only. */
+interface FretWindow {
+ anchor: number;
+ hi: number;
+}
+
+interface SearchSpace {
+ windows: readonly FretWindow[];
+ allowOpen: boolean;
+ /** Prune branches with more fretted notes than this. */
+ maxFretted: number;
+ /** Prune branches with more muted strings inside the sounding range than this. */
+ maxInteriorMutes: number;
+ /** Finger-supply rules for partial-barre, barre or thumb profiles (see structurallyUnfingerable), else null. */
+ supply: FingerSupply | null;
+}
+
+interface FingerSupply {
+ /** supplyCap(profile) */
+ cap: number;
+ barre: boolean;
+ thumb: boolean;
+}
+
+const fingerSupply = (profile: HandProfile): FingerSupply | null =>
+ profile.noBarre && !profile.allowTwoStringPartialBarre && !profile.allowThumb
+ ? null
+ : { cap: supplyCap(profile), barre: !profile.noBarre, thumb: profile.allowThumb };
+
+/**
+ * structurallyUnfingerable on a partial shape (higher strings still muted), without the thumb-window rule. The
+ * finger lower bound never drops as strings are added (the window anchor is the barre fret), so every completion
+ * of a flagged prefix is unfingerable too.
+ */
+const prefixOutOfFingers = (prefix: Shape, supply: FingerSupply, anchor: number): boolean => {
+ const barreFret = supply.barre ? anchor : -1;
+ if (fingersNeeded(prefix, 0, supply.cap, barreFret) <= FINGERS) return false;
+ const thumbFret = prefix[0];
+ return !(supply.thumb && thumbFret !== null && thumbFret > 0 && fingersNeeded(prefix, 1, supply.cap, barreFret) <= FINGERS);
+};
+
+/**
+ * Depth-first search over strings, low to high. Per string: muted, open, or a fret in the window whose pitch class
+ * the recipe allows. Branches that cannot complete the required pitch classes, the note count, the window anchor
+ * or the drone string are cut silently; branches cut by a profile limit are reported through `onPrune`.
+ */
+const enumerateShapes = (
+ family: VoicingFamily,
+ rootPc: number,
+ tuning: Tuning,
+ space: SearchSpace,
+ onPrune: (constraint: 'NEEDS_BARRE' | 'INTERIOR_MUTES') => void,
+ onLeaf: (shape: (number | null)[]) => void
+): void => {
+ const n = tuning.openMidi.length;
+ const allowedMask = maskOf(allowedIntervals(family));
+ const requiredMask = maskOf(family.required);
+ const droneMask = family.drone ? maskOf([0, 7]) & allowedMask : 0;
+ const openInterval = tuning.openMidi.map((midi) => intervalFrom(rootPc, pitchClass(midi)));
+ const frets = new Array(n).fill(null);
+
+ for (const { anchor, hi } of space.windows) {
+ const options: number[][] = [];
+ const bits: number[][] = [];
+ for (let s = 0; s < n; s++) {
+ const o = [-1];
+ const b = [0];
+ const openBit = 1 << openInterval[s];
+ if (space.allowOpen && allowedMask & openBit) {
+ o.push(0);
+ b.push(openBit);
+ }
+ if (anchor > 0) {
+ for (let f = anchor; f <= hi; f++) {
+ const bit = 1 << ((openInterval[s] + f) % 12);
+ if (allowedMask & bit) {
+ o.push(f);
+ b.push(bit);
+ }
+ }
+ }
+ options.push(o);
+ bits.push(b);
+ }
+ const suffixMask = new Array(n + 1).fill(0);
+ const suffixAnchor = new Array(n + 1).fill(false);
+ const suffixDrone = new Array(n + 1).fill(false);
+ for (let s = n - 1; s >= 0; s--) {
+ let mask = suffixMask[s + 1];
+ let hasAnchor = suffixAnchor[s + 1];
+ let hasDrone = suffixDrone[s + 1];
+ for (let i = 1; i < options[s].length; i++) {
+ mask |= bits[s][i];
+ if (options[s][i] === anchor) hasAnchor = true;
+ if (options[s][i] === 0 && bits[s][i] & droneMask) hasDrone = true;
+ }
+ suffixMask[s] = mask;
+ suffixAnchor[s] = hasAnchor;
+ suffixDrone[s] = hasDrone;
+ }
+
+ const visit = (
+ s: number,
+ present: number,
+ sounding: number,
+ fretted: number,
+ interior: number,
+ pending: number,
+ hasAnchor: boolean,
+ hasDrone: boolean
+ ): void => {
+ const remaining = n - s;
+ if (sounding > family.maxNotes || sounding + remaining < family.minNotes) return;
+ const missing = requiredMask & ~present;
+ if (missing & ~suffixMask[s] || popcount(missing) > remaining) return;
+ if (anchor > 0 && !hasAnchor && !suffixAnchor[s]) return;
+ if (droneMask && !hasDrone && !suffixDrone[s]) return;
+ if (fretted > space.maxFretted || (fretted > FINGERS && space.supply !== null && prefixOutOfFingers(frets, space.supply, anchor))) {
+ onPrune('NEEDS_BARRE');
+ return;
+ }
+ if (interior > space.maxInteriorMutes) {
+ onPrune('INTERIOR_MUTES');
+ return;
+ }
+ if (s === n) {
+ onLeaf(frets.slice());
+ return;
+ }
+ const o = options[s];
+ const b = bits[s];
+ for (let i = 0; i < o.length; i++) {
+ const f = o[i];
+ if (f < 0) {
+ frets[s] = null;
+ visit(s + 1, present, sounding, fretted, interior, sounding > 0 ? pending + 1 : 0, hasAnchor, hasDrone);
+ } else {
+ frets[s] = f;
+ visit(
+ s + 1,
+ present | b[i],
+ sounding + 1,
+ fretted + (f > 0 ? 1 : 0),
+ interior + pending,
+ 0,
+ hasAnchor || f === anchor,
+ hasDrone || (f === 0 && (b[i] & droneMask) !== 0)
+ );
+ }
+ }
+ frets[s] = null;
+ };
+ visit(0, 0, 0, 0, 0, 0, false, false);
+ }
+};
+
+const reachHi = (profile: HandProfile, fret: number, factor: number, limit: number): number =>
+ highestReachableFret(fret, allowedSpanMm(profile, fret) * factor, profile.scaleLengthMm, limit);
+
+/**
+ * One open-only pass, then one window per anchor fret 1..maxFret reaching as far as the hand allows from it.
+ * With the thumb allowed, the fingers may start up to THUMB_FRET_WINDOW frets above a thumb on the anchor.
+ */
+const handWindows = (profile: HandProfile): FretWindow[] => {
+ const windows: FretWindow[] = [{ anchor: 0, hi: 0 }];
+ for (let w = 1; w <= profile.maxFret; w++) {
+ let hi = reachHi(profile, w, 1, profile.maxFret);
+ if (profile.allowThumb) {
+ for (let k = 1; k <= THUMB_FRET_WINDOW; k++) hi = Math.max(hi, reachHi(profile, Math.min(w + k, profile.maxFret), 1, profile.maxFret));
+ }
+ windows.push({ anchor: w, hi });
+ }
+ return windows;
+};
+
+const maxFrettedNotes = (profile: HandProfile): number => {
+ if (!profile.noBarre) return Infinity;
+ const thumb = profile.allowThumb ? 1 : 0;
+ return (profile.allowTwoStringPartialBarre ? 2 * FINGERS : FINGERS) + thumb;
+};
+
+// ---------------------------------------------------------------------------
+// Empty-result diagnosis
+// ---------------------------------------------------------------------------
+
+/**
+ * Some open string can ring the root or 5th: allowed by the recipe, and when it is not an allowed bass note, a
+ * lower string can still sound an allowed bass note below it.
+ */
+const droneStringExists = (family: VoicingFamily, rootPc: number, tuning: Tuning): boolean => {
+ const allowed = maskOf(allowedIntervals(family));
+ const bassOk = (interval: number): boolean => family.bass === 'any' || family.bass.includes(interval);
+ const { openMidi } = tuning;
+ return openMidi.some((midi, s) => {
+ const interval = intervalFrom(rootPc, pitchClass(midi));
+ if ((interval !== 0 && interval !== 7) || !(allowed & (1 << interval))) return false;
+ if (bassOk(interval)) return true;
+ for (let t = 0; t < s; t++) {
+ for (let f = 0; openMidi[t] + f < midi && f <= DIAG_FRET_CAP; f++) {
+ const below = intervalFrom(rootPc, pitchClass(openMidi[t] + f));
+ if (allowed & (1 << below) && bassOk(below)) return true;
+ }
+ }
+ return false;
+ });
+};
+
+const CONSTRAINT_TEXT: Readonly string>> = {
+ PITCH_CLASSES: () => 'pitch classes (no shape sounds exactly the notes this recipe asks for)',
+ NOTE_COUNT: () => 'note count (the recipe cannot fit its note limits on these strings)',
+ BASS: () => 'bass note (no shape puts an allowed note in the bass)',
+ NO_DRONE_STRING: () => 'no drone string (no open string can ring the root or 5th here)',
+ NEEDS_BARRE: () => 'needs a barre (the closest shapes need more fingers than fit without one)',
+ REACH: () => 'reach (the closest shapes need a wider stretch than your hand profile allows)',
+ PAIR_STRETCH: () => 'finger stretch (the closest shapes pull two neighbouring fingers past their limit)',
+ FINGER_CROSSING: () => 'finger crossing (the closest shapes need crossed fingers)',
+ INTERIOR_MUTES: (p) => `interior mutes (the closest shapes mute more than ${p.maxInteriorMutes} inner string${p.maxInteriorMutes === 1 ? '' : 's'})`,
+ MAX_FRET: (p) => `max fret (the closest shapes sit above fret ${p.maxFret})`,
+ OPEN_STRINGS: () => 'open strings are off (the closest shapes need open strings)'
+};
+
+const emptyResult = (
+ constraint: BindingConstraint,
+ family: VoicingFamily,
+ rootPc: number,
+ tuning: Tuning,
+ profile: HandProfile,
+ onlyThis = 0
+): NonNullable => {
+ const tail = onlyThis > 0 ? `; ${onlyThis} close shape${onlyThis === 1 ? ' fails' : 's fail'} only on this` : '';
+ return {
+ constraint,
+ message: `No ${family.label} voicing on ${pitchClassName(rootPc)} in ${tuning.name}: ${CONSTRAINT_TEXT[constraint](profile)}${tail}.`
+ };
+};
+
+/** The profile rules a pitch-valid shape breaks; fingering is checked under a copy without fret/open limits. */
+const profileViolations = (shape: Shape, profile: HandProfile, relaxed: HandProfile): BindingConstraint[] => {
+ const out: BindingConstraint[] = [];
+ let lo = Infinity;
+ let hi = -Infinity;
+ let open = false;
+ for (const f of shape) {
+ if (f === null) continue;
+ if (f === 0) open = true;
+ else {
+ if (f < lo) lo = f;
+ if (f > hi) hi = f;
+ }
+ }
+ if (hi > profile.maxFret) out.push('MAX_FRET');
+ if (open && !profile.allowOpenStrings) out.push('OPEN_STRINGS');
+ if (interiorMutes(shape) > profile.maxInteriorMutes) out.push('INTERIOR_MUTES');
+ // Span over all fretted notes: the fingering search reports NEEDS_BARRE before reach, so reach is checked here.
+ // With the thumb allowed its note leaves the span, so the fingering result decides instead.
+ if (!profile.allowThumb && lo !== Infinity) {
+ const span = fingertipMm(hi, profile.scaleLengthMm) - fingertipMm(lo, profile.scaleLengthMm);
+ if (span > allowedSpanMm(profile, lo) + EPS) out.push('REACH');
+ }
+ const fingering: FingeringResult = structurallyUnfingerable(shape, relaxed)
+ ? { ok: false, reason: 'NEEDS_BARRE', detail: '' }
+ : findBestFingering(shape, relaxed);
+ if (fingering.ok === false) {
+ const constraint = FINGERING_TO_BINDING[fingering.reason];
+ if (!out.includes(constraint)) out.push(constraint);
+ }
+ return out;
+};
+
+const mostCounted = (counts: Counts): BindingConstraint | null => {
+ let best: BindingConstraint | null = null;
+ for (const key of BINDING_ORDER) {
+ const n = counts[key] ?? 0;
+ if (n > 0 && (best === null || n > (counts[best] ?? 0))) best = key;
+ }
+ return best;
+};
+
+/**
+ * Names the constraint behind an empty result. Searches pitch-valid shapes in a wider space (larger windows, frets
+ * above maxFret, open strings, no mute or note limits) and picks the constraint that alone rejects the most of them;
+ * when every near miss breaks several rules, the one breaking the most; with no pitch-valid shape at all, the
+ * recipe's own failure.
+ */
+const diagnoseEmpty = (
+ family: VoicingFamily,
+ rootPc: number,
+ tuning: Tuning,
+ profile: HandProfile
+): NonNullable => {
+ const strings = tuning.openMidi.length;
+ const say = (c: BindingConstraint, onlyThis = 0) => emptyResult(c, family, rootPc, tuning, profile, onlyThis);
+ if (family.minNotes > strings || family.minNotes > family.maxNotes || family.required.length > family.maxNotes) {
+ return say('NOTE_COUNT');
+ }
+ if (family.drone && !droneStringExists(family, rootPc, tuning)) return say('NO_DRONE_STRING');
+
+ const diagMax = Math.max(profile.maxFret, Math.min(DIAG_FRET_CAP, profile.maxFret + DIAG_EXTRA_FRETS));
+ const windows: FretWindow[] = [{ anchor: 0, hi: 0 }];
+ for (let w = 1; w <= diagMax; w++) {
+ const hi = Math.max(reachHi(profile, w, DIAG_REACH_FACTOR, diagMax), Math.min(diagMax, w + DIAG_MIN_WINDOW_FRETS));
+ windows.push({ anchor: w, hi });
+ }
+ const relaxed: HandProfile = { ...profile, maxFret: diagMax, allowOpenStrings: true };
+ const single: Counts = {};
+ const any: Counts = {};
+ const pitch: Counts = {};
+ enumerateShapes(
+ family,
+ rootPc,
+ tuning,
+ { windows, allowOpen: true, maxFretted: Infinity, maxInteriorMutes: Infinity, supply: null },
+ () => undefined,
+ (shape) => {
+ const match = matchFamily(family, rootPc, shape, tuning);
+ if (match.ok === false) {
+ bump(pitch, match.reason);
+ return;
+ }
+ const violations = profileViolations(shape, profile, relaxed);
+ if (violations.length === 1) bump(single, violations[0]);
+ for (const v of violations) bump(any, v);
+ }
+ );
+ const alone = mostCounted(single);
+ if (alone) return say(alone, single[alone]);
+ return say(mostCounted(any) ?? mostCounted(pitch) ?? 'PITCH_CLASSES');
+};
+
+// ---------------------------------------------------------------------------
+// Ranking and output
+// ---------------------------------------------------------------------------
+
+interface Candidate {
+ shape: Shape;
+ key: string;
+ fingering: Fingering;
+ /** scoreVoicing(...).total; the breakdown is built only for the chosen voicings. */
+ total: number;
+ /** NaN until computed (only needed up front for the 'unusual' sort). */
+ novelty: number;
+ primary: number;
+ secondary: number;
+ lowest: number;
+ stringMask: number;
+}
+
+const compareCandidates = (a: Candidate, b: Candidate): number =>
+ a.primary - b.primary || a.secondary - b.secondary || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
+
+interface Selection {
+ picked: Candidate[];
+ /** Highest score any pick won its round with: a candidate scoring above it can never be picked. */
+ worst: number;
+}
+
+/**
+ * Greedy pick in sort order; a candidate's score grows by DIVERSITY_PENALTY for every selected voicing on the same
+ * sounding strings within DIVERSITY_FRET_DISTANCE frets. Ties keep the sort order.
+ */
+const diversified = (sorted: readonly Candidate[], limit: number): Selection => {
+ const picked: Candidate[] = [];
+ const taken = new Uint8Array(sorted.length);
+ let worst = -Infinity;
+ while (picked.length < limit) {
+ let best = -1;
+ let bestScore = Infinity;
+ for (let i = 0; i < sorted.length; i++) {
+ if (taken[i]) continue;
+ const c = sorted[i];
+ if (best !== -1 && c.primary >= bestScore) break;
+ let near = 0;
+ for (const p of picked) {
+ if (p.stringMask === c.stringMask && Math.abs(p.lowest - c.lowest) <= DIVERSITY_FRET_DISTANCE) near++;
+ }
+ const score = c.primary + DIVERSITY_PENALTY * near;
+ if (score < bestScore) {
+ best = i;
+ bestScore = score;
+ }
+ }
+ if (best === -1) break;
+ taken[best] = 1;
+ picked.push(sorted[best]);
+ if (bestScore > worst) worst = bestScore;
+ }
+ return { picked, worst };
+};
+
+const select = (sorted: readonly Candidate[], limit: number, diversify: boolean): Selection => {
+ if (diversify) return diversified(sorted, limit);
+ const picked = sorted.slice(0, limit);
+ return { picked, worst: picked.length > 0 ? picked[picked.length - 1].primary : -Infinity };
+};
+
+// ---------------------------------------------------------------------------
+// Lazy fingering search
+// ---------------------------------------------------------------------------
+
+/**
+ * Above this many fingering-feasible shapes (wide, barre or thumb profiles) the fingering search runs lazily: shapes
+ * are searched in order of a lower bound on their sort key, and the search stops once no unsearched shape can enter
+ * the results. The voicings are identical to an exhaustive search; stats.playable and the fingering rejections then
+ * count only the searched shapes. Smaller searches stay exhaustive.
+ */
+export const LAZY_SEARCH_MIN_SHAPES = 2048;
+
+/** Shapes searched before the first re-selection in the lazy search; doubles each round. */
+const LAZY_FIRST_BATCH = 64;
+
+/** Margin on lower-bound comparisons; costs are short sums of terms of order 1. */
+const BOUND_EPS = 1e-9;
+
+/** A shape that passed every check except the fingering search. */
+interface Leaf {
+ shape: Shape;
+ /** shapeKey, computed when needed (seeded jitter or ranking). */
+ key: string | null;
+ jitter: number;
+ /** Lower bound on the candidate's primary sort key (exact for 'unusual'); lazy search only. */
+ bound: number;
+ /** Precomputed for the lazy 'unusual' search, NaN otherwise. */
+ novelty: number;
+}
+
+interface NeckTable {
+ tip: Float64Array;
+ allowed: Float64Array;
+}
+
+const neckTable = (profile: HandProfile): NeckTable => {
+ const size = profile.maxFret + 2;
+ const tip = new Float64Array(size);
+ const allowed = new Float64Array(size);
+ for (let f = 0; f < size; f++) {
+ tip[f] = fingertipMm(f, profile.scaleLengthMm);
+ allowed[f] = allowedSpanMm(profile, f);
+ }
+ return { tip, allowed };
+};
+
+const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0);
+
+/**
+ * Lower bound on the reach, finger-count, pinky, thumb and barre terms when fingers 1..4 press exactly the fretted
+ * notes from string `from` up (string 0 goes to the thumb when `from` is 1). Mirrors fingering.ts's term
+ * normalization; reach is exact because the fingers sit on exactly those frets.
+ */
+const fingerTermsBound = (shape: Shape, from: number, cap: number, barreFret: number, neck: NeckTable): number => {
+ const thumb = from === 1;
+ let lo = Infinity;
+ let hi = -Infinity;
+ let notes = 0;
+ for (let s = from; s < shape.length; s++) {
+ const f = shape[s];
+ if (f === null || f === 0) continue;
+ notes++;
+ if (f < lo) lo = f;
+ if (f > hi) hi = f;
+ }
+ const fingers = notes === 0 ? 0 : fingersNeeded(shape, from, cap, barreFret);
+ let cost = 0;
+ if (notes > 0) {
+ const ratio = (neck.tip[hi] - neck.tip[lo]) / neck.allowed[lo];
+ cost += clamp01(ratio * ratio) * PLAYABILITY_WEIGHTS.reach.weight;
+ }
+ cost += clamp01((fingers + (thumb ? 1 : 0)) / FINGERS) * PLAYABILITY_WEIGHTS.fingerCount.weight;
+ if (fingers >= FINGERS) cost += PLAYABILITY_WEIGHTS.pinky.weight;
+ if (thumb) cost += PLAYABILITY_WEIGHTS.thumb.weight;
+ // More notes than fingers: some finger covers two or more strings, which the fingering reports as a barre
+ if (notes > FINGERS) cost += PLAYABILITY_WEIGHTS.barre.weight;
+ return cost;
+};
+
+/** Lower bound on scoreVoicing(...).total over every fingering of a shape that passed structurallyUnfingerable. */
+const costLowerBound = (shape: Shape, profile: HandProfile, ctx: ScoreContext, neck: NeckTable): number => {
+ let lowest = Infinity;
+ for (const f of shape) if (f !== null && f > 0 && f < lowest) lowest = f;
+ let physical = clamp01(countInteriorMutes(shape) / 2) * PLAYABILITY_WEIGHTS.interiorMutes.weight;
+ if (lowest !== Infinity) {
+ const cap = supplyCap(profile);
+ const barreFret = profile.noBarre ? -1 : lowest;
+ let best = fingersNeeded(shape, 0, cap, barreFret) <= FINGERS ? fingerTermsBound(shape, 0, cap, barreFret, neck) : Infinity;
+ const thumbFret = shape[0];
+ if (profile.allowThumb && thumbFret !== null && thumbFret > 0) {
+ best = Math.min(best, fingerTermsBound(shape, 1, cap, barreFret, neck));
+ }
+ if (best !== Infinity) physical += best;
+ physical += clamp01(lowest / 12) * PLAYABILITY_WEIGHTS.position.weight;
+ }
+ return physical + nonPhysicalCost(shape, ctx);
+};
+
+const voicingTags = (shape: Shape, tuning: Tuning, rootPc: number, family: VoicingFamily): VoicingTag[] => {
+ const n = shape.length;
+ const tags: VoicingTag[] = [];
+ const intervalOn = (s: number): number => intervalFrom(rootPc, pitchClass(tuning.openMidi[s] + (shape[s] ?? 0)));
+ const low = shape[0];
+ if (low === null || (low === 0 && (intervalOn(0) === 0 || intervalOn(0) === 7))) tags.push('pedalCompatible');
+ if (family.drone) tags.push('drone');
+ if (shape.some((f) => f === 0)) tags.push('usesOpenStrings');
+ let lo = -1;
+ let hi = -1;
+ let bass = Infinity;
+ let bassString = -1;
+ for (let s = 0; s < n; s++) {
+ const f = shape[s];
+ if (f === null) continue;
+ if (lo < 0) lo = s;
+ hi = s;
+ const midi = tuning.openMidi[s] + f;
+ if (midi < bass) {
+ bass = midi;
+ bassString = s;
+ }
+ }
+ if (bassString >= 0 && intervalOn(bassString) === 0) tags.push('rootInBass');
+ if (lo >= 0) {
+ if (hi <= 2) tags.push('lowStrings');
+ if (lo >= 1 && hi <= n - 2) tags.push('middleStrings');
+ if (lo >= n - 3) tags.push('topStrings');
+ }
+ return tags;
+};
+
+const run = (params: GenerateParams, diagnose: boolean): GenerateResult => {
+ const family = resolveFamily(params.family);
+ const rootPc = toPitchClass(params.root);
+ const { tuning, profile } = params;
+ const sort = params.sort ?? 'easiest';
+ const limitRaw = params.limit ?? DEFAULT_VOICING_LIMIT;
+ // Infinity asks for every playable voicing; only NaN falls back to the default
+ const limit = Number.isNaN(limitRaw) ? DEFAULT_VOICING_LIMIT : Math.max(0, Math.floor(limitRaw));
+ const ctx: ScoreContext = {
+ tuning,
+ profile,
+ rootPc,
+ family,
+ distortion: params.distortion ?? true,
+ context: params.context ?? 'strum',
+ musical: params.musical ?? true
+ };
+ const references = prepareReferences(params.referenceShapes ?? []);
+ const { seed } = params;
+
+ const windows = handWindows(profile);
+ const rejections: Counts = {};
+ const stats: GenerateStats = { windows: windows.length, combinations: 0, pitchValid: 0, playable: 0, rejections };
+ // Each shape is enumerated once: windows are keyed by the lowest fretted fret, so no deduplication is needed.
+ const leaves: Leaf[] = [];
+
+ enumerateShapes(
+ family,
+ rootPc,
+ tuning,
+ {
+ windows,
+ allowOpen: profile.allowOpenStrings,
+ maxFretted: maxFrettedNotes(profile),
+ maxInteriorMutes: profile.maxInteriorMutes,
+ supply: fingerSupply(profile)
+ },
+ (constraint) => bump(rejections, constraint),
+ (shape) => {
+ stats.combinations++;
+ const match = matchFamily(family, rootPc, shape, tuning);
+ if (match.ok === false) {
+ bump(rejections, match.reason);
+ return;
+ }
+ stats.pitchValid++;
+ const violation = shapeConstraintViolation(shape, profile);
+ if (violation !== null) {
+ bump(rejections, violation);
+ return;
+ }
+ if (structurallyUnfingerable(shape, profile)) {
+ bump(rejections, 'NEEDS_BARRE');
+ return;
+ }
+ if (seed === undefined) {
+ leaves.push({ shape, key: null, jitter: 0, bound: 0, novelty: NaN });
+ } else {
+ const key = shapeKey(shape);
+ leaves.push({ shape, key, jitter: mulberry32(seedFromString(seed + key))() * SEED_JITTER, bound: 0, novelty: NaN });
+ }
+ }
+ );
+
+ const candidates: Candidate[] = [];
+ const evaluate = (leaf: Leaf): void => {
+ const { shape } = leaf;
+ const result = findBestFingering(shape, profile);
+ if (result.ok === false) {
+ bump(rejections, FINGERING_TO_BINDING[result.reason]);
+ return;
+ }
+ stats.playable++;
+ const total = scoreTotal(shape, result.fingering, ctx);
+ const novelty = sort !== 'unusual' ? NaN : Number.isNaN(leaf.novelty) ? noveltyAgainst(shape, references) : leaf.novelty;
+ let stringMask = 0;
+ for (let s = 0; s < shape.length; s++) if (shape[s] !== null) stringMask |= 1 << s;
+ candidates.push({
+ shape,
+ key: leaf.key ?? shapeKey(shape),
+ fingering: result.fingering,
+ total,
+ novelty,
+ primary: (sort === 'unusual' ? -novelty : total) + leaf.jitter,
+ secondary: sort === 'unusual' ? total : 0,
+ lowest: result.fingering.metrics.lowestFret ?? 0,
+ stringMask
+ });
+ };
+
+ const diversify = params.diversify !== false;
+ let chosen: Candidate[] = [];
+ if (leaves.length <= LAZY_SEARCH_MIN_SHAPES || limit >= leaves.length) {
+ for (const leaf of leaves) evaluate(leaf);
+ candidates.sort(compareCandidates);
+ chosen = select(candidates, limit, diversify).picked;
+ } else {
+ // Exact: once every unsearched shape's bound exceeds the worst winning score, none of them can be picked
+ // (a pick needs a score <= that, and a score is at least the primary key).
+ const neck = sort === 'unusual' ? null : neckTable(profile);
+ for (const leaf of leaves) {
+ if (neck === null) {
+ leaf.novelty = noveltyAgainst(leaf.shape, references);
+ leaf.bound = -leaf.novelty + leaf.jitter;
+ } else {
+ leaf.bound = costLowerBound(leaf.shape, profile, ctx, neck) + leaf.jitter;
+ }
+ }
+ // Stable sort: equal bounds keep enumeration order, so the searched set (and the stats) stay deterministic
+ leaves.sort((a, b) => a.bound - b.bound);
+ let next = 0;
+ while (candidates.length < Math.max(1, limit) && next < leaves.length) evaluate(leaves[next++]);
+ // Growing batches: the first picks often cost far more than their bounds, so re-selecting early tightens the
+ // stopping score before most shapes are searched. Every stop follows a selection on the current pool.
+ let batch = LAZY_FIRST_BATCH;
+ while (limit > 0) {
+ candidates.sort(compareCandidates);
+ const { picked, worst } = select(candidates, limit, diversify);
+ chosen = picked;
+ const stop = worst + BOUND_EPS;
+ if (next >= leaves.length || leaves[next].bound > stop) break;
+ for (let n = 0; n < batch && next < leaves.length && leaves[next].bound <= stop; n++) evaluate(leaves[next++]);
+ batch *= 2;
+ }
+ }
+
+ const voicings: GeneratedVoicing[] = chosen.map((c) => {
+ const naming = nameVoicing(c.shape, tuning, rootPc, family);
+ const breakdown = scoreVoicing(c.shape, c.fingering, ctx);
+ return {
+ shape: c.shape,
+ fingering: c.fingering,
+ midi: shapeToMidi(c.shape, tuning),
+ degrees: naming.degrees,
+ degreesByString: naming.degreesByString,
+ symbol: naming.symbol,
+ name: naming.name,
+ familyId: family.id,
+ cost: breakdown.total,
+ breakdown,
+ novelty: Number.isNaN(c.novelty) ? noveltyAgainst(c.shape, references) : c.novelty,
+ tags: voicingTags(c.shape, tuning, rootPc, family)
+ };
+ });
+
+ const result: GenerateResult = { voicings, stats };
+ if (candidates.length === 0 && diagnose) result.empty = diagnoseEmpty(family, rootPc, tuning, profile);
+ return result;
+};
+
+/**
+ * All playable voicings of `family` on `root` for the hand profile, best first. Hard rules: exact pitch classes,
+ * the profile's fret/open/mute limits and a valid fingering. An empty result names its binding constraint.
+ */
+export const generateVoicings = (params: GenerateParams): GenerateResult => run(params, true);
+
+export interface ClosestVoicingResult {
+ result: GenerateResult;
+ /** Relaxation labels used, null when the exact recipe worked or nothing did. */
+ relaxed: string[] | null;
+ /** Why the exact recipe was empty (present whenever it was), so callers need not search it again. */
+ exactEmpty?: NonNullable;
+}
+
+/**
+ * The exact recipe first, then each step of relaxFamily's ladder; returns the first result with voicings and the
+ * relaxation labels used (null when the exact recipe worked). When nothing works, the exact (empty) result.
+ */
+export const findClosestVoicing = (params: GenerateParams): ClosestVoicingResult => {
+ const exact = run(params, true);
+ if (exact.empty === undefined) return { result: exact, relaxed: null };
+ const exactEmpty = exact.empty;
+ for (const step of relaxFamily(resolveFamily(params.family))) {
+ const result = run({ ...params, family: step.family }, false);
+ if (result.voicings.length > 0) return { result, relaxed: step.relaxed, exactEmpty };
+ }
+ return { result: exact, relaxed: null, exactEmpty };
+};
diff --git a/engine/geometry.test.ts b/engine/geometry.test.ts
new file mode 100644
index 0000000..009b133
--- /dev/null
+++ b/engine/geometry.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest';
+import {
+ DEFAULT_SCALE_LENGTH_MM,
+ SCALE_LENGTH_PRESETS,
+ fretWireMm,
+ fingertipMm,
+ spanMm,
+ highestReachableFret
+} from './geometry';
+
+const L = DEFAULT_SCALE_LENGTH_MM;
+
+describe('fret wire distance', () => {
+ it('uses 647.7 mm as the default scale', () => {
+ expect(L).toBeCloseTo(647.7, 6);
+ });
+
+ it('puts the 12th fret at half the scale length', () => {
+ expect(Math.abs(fretWireMm(12, L) - L / 2)).toBeLessThan(0.01);
+ expect(Math.abs(fretWireMm(24, L) - (3 * L) / 4)).toBeLessThan(0.01);
+ expect(fretWireMm(0, L)).toBe(0);
+ });
+
+ it('grows monotonically with shrinking fret widths', () => {
+ for (let n = 1; n < 24; n++) {
+ const width = fretWireMm(n, L) - fretWireMm(n - 1, L);
+ const nextWidth = fretWireMm(n + 1, L) - fretWireMm(n, L);
+ expect(nextWidth).toBeLessThan(width);
+ }
+ });
+});
+
+describe('fingertip model', () => {
+ it('is 0 for open strings and sits behind the wire', () => {
+ expect(fingertipMm(0, L)).toBe(0);
+ const tip = fingertipMm(5, L);
+ expect(tip).toBeGreaterThan(fretWireMm(4, L));
+ expect(tip).toBeLessThan(fretWireMm(5, L));
+ });
+
+ it('matches the documented reference spans at 25.5"', () => {
+ expect(spanMm(1, 4, L)).toBeCloseTo(98.98, 1);
+ expect(spanMm(7, 11, L)).toBeCloseTo(90.77, 1);
+ });
+
+ it('makes fret 12 -> 17 shorter than fret 1 -> 4', () => {
+ expect(spanMm(12, 17, L)).toBeLessThan(spanMm(1, 4, L));
+ });
+
+ it('scales with scale length', () => {
+ const short = SCALE_LENGTH_PRESETS.find((p) => p.id === '24.75')!.mm;
+ const long = SCALE_LENGTH_PRESETS.find((p) => p.id === '27')!.mm;
+ expect(spanMm(1, 4, short)).toBeLessThan(spanMm(1, 4, long));
+ });
+});
+
+describe('highestReachableFret', () => {
+ it('reaches fret 4 from fret 1 with the default low reach', () => {
+ expect(highestReachableFret(1, spanMm(1, 4, L), L)).toBe(4);
+ expect(highestReachableFret(1, spanMm(1, 4, L) - 1, L)).toBe(3);
+ });
+
+ it('covers more frets higher up the neck for the same reach', () => {
+ const reach = spanMm(1, 4, L);
+ expect(highestReachableFret(12, reach, L) - 12).toBeGreaterThan(3);
+ });
+
+ it('respects the fret limit', () => {
+ expect(highestReachableFret(20, 500, L, 22)).toBe(22);
+ });
+});
diff --git a/engine/geometry.ts b/engine/geometry.ts
new file mode 100644
index 0000000..4e2dd3c
--- /dev/null
+++ b/engine/geometry.ts
@@ -0,0 +1,50 @@
+// Neck geometry. Distances are millimetres along the neck from the nut.
+
+export const MM_PER_INCH = 25.4;
+
+/** Fender-style 25.5" scale. */
+export const DEFAULT_SCALE_LENGTH_MM = 25.5 * MM_PER_INCH;
+
+export const SCALE_LENGTH_PRESETS: readonly { id: string; label: string; mm: number }[] = [
+ { id: '24.75', label: '24.75"', mm: 24.75 * MM_PER_INCH },
+ { id: '25.5', label: '25.5"', mm: 25.5 * MM_PER_INCH },
+ { id: '26.5', label: '26.5"', mm: 26.5 * MM_PER_INCH },
+ { id: '27', label: '27"', mm: 27 * MM_PER_INCH }
+];
+
+/** Model: the fingertip presses 70 percent of the way from the previous fret wire to this one. */
+export const FINGERTIP_FRACTION = 0.7;
+
+/** Approximate centre-to-centre string spacing at mid-neck. Used only for minor lateral costs. */
+export const DEFAULT_STRING_SPACING_MM = 10.5;
+
+/** Distance from the nut to fret wire n: d(n) = L - L / 2^(n/12). */
+export const fretWireMm = (fret: number, scaleLengthMm: number): number =>
+ scaleLengthMm - scaleLengthMm / Math.pow(2, fret / 12);
+
+/** Fingertip position for a note at `fret`; 0 for open strings. */
+export const fingertipMm = (fret: number, scaleLengthMm: number): number => {
+ if (fret <= 0) return 0;
+ const prev = fretWireMm(fret - 1, scaleLengthMm);
+ return prev + FINGERTIP_FRACTION * (fretWireMm(fret, scaleLengthMm) - prev);
+};
+
+/** Fingertip distance between two fretted notes (order-independent). */
+export const spanMm = (fretA: number, fretB: number, scaleLengthMm: number): number =>
+ Math.abs(fingertipMm(fretB, scaleLengthMm) - fingertipMm(fretA, scaleLengthMm));
+
+/** Highest fret reachable from `fromFret` within `reachMm` of fingertip travel (>= fromFret). */
+export const highestReachableFret = (
+ fromFret: number,
+ reachMm: number,
+ scaleLengthMm: number,
+ limitFret = 36
+): number => {
+ const start = Math.max(1, fromFret);
+ const origin = fingertipMm(start, scaleLengthMm);
+ let fret = start;
+ while (fret + 1 <= limitFret && fingertipMm(fret + 1, scaleLengthMm) - origin <= reachMm + 1e-9) {
+ fret++;
+ }
+ return fret;
+};
diff --git a/engine/handProfile.test.ts b/engine/handProfile.test.ts
new file mode 100644
index 0000000..40ccb1c
--- /dev/null
+++ b/engine/handProfile.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect } from 'vitest';
+import {
+ DEFAULT_HAND_PROFILE,
+ DEFAULT_CALIBRATION,
+ createDefaultHandProfile,
+ maxReachMm,
+ allowedSpanMm,
+ pairLimitFraction,
+ profileFromCalibration,
+ calibrationFromProfile,
+ validateHandProfile,
+ migrateHandProfile,
+ handProfileHash
+} from './handProfile';
+import { spanMm, DEFAULT_SCALE_LENGTH_MM } from './geometry';
+
+const L = DEFAULT_SCALE_LENGTH_MM;
+
+describe('default profile', () => {
+ it('uses the documented defaults', () => {
+ const p = DEFAULT_HAND_PROFILE;
+ expect(p.scaleLengthMm).toBeCloseTo(647.7, 6);
+ expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 4, L), 6);
+ expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 11, L), 6);
+ expect(p.lowRefFret).toBe(1);
+ expect(p.highRefFret).toBe(7);
+ expect(p.pairLimits).toEqual({ indexMiddle: 0.65, middleRing: 0.35, ringPinky: 0.4 });
+ expect(p.stretchTolerance).toBe(1);
+ expect(p.noBarre).toBe(true);
+ expect(p.allowTwoStringPartialBarre).toBe(false);
+ expect(p.allowThumb).toBe(false);
+ expect(p.maxFret).toBe(15);
+ expect(p.allowOpenStrings).toBe(true);
+ expect(p.maxInteriorMutes).toBe(1);
+ expect(p.comfortableSixteenthBpm).toBe(110);
+ });
+
+ it('matches the default calibration answers', () => {
+ expect(profileFromCalibration(DEFAULT_CALIBRATION)).toEqual(DEFAULT_HAND_PROFILE);
+ });
+});
+
+describe('maxReachMm', () => {
+ const p = DEFAULT_HAND_PROFILE;
+
+ it('returns the calibration points exactly', () => {
+ expect(maxReachMm(p, 1)).toBeCloseTo(p.reachAtLowMm, 9);
+ expect(maxReachMm(p, 7)).toBeCloseTo(p.reachAtHighMm, 9);
+ });
+
+ it('interpolates linearly in between', () => {
+ expect(maxReachMm(p, 4)).toBeCloseTo((p.reachAtLowMm + p.reachAtHighMm) / 2, 9);
+ });
+
+ it('clamps outside the calibration range', () => {
+ expect(maxReachMm(p, 0)).toBe(p.reachAtLowMm);
+ expect(maxReachMm(p, 15)).toBe(p.reachAtHighMm);
+ });
+
+ it('applies the stretch tolerance to the allowed span', () => {
+ const stretchy = { ...p, stretchTolerance: 1.1 };
+ expect(allowedSpanMm(stretchy, 1)).toBeCloseTo(p.reachAtLowMm * 1.1, 9);
+ });
+});
+
+describe('pairLimitFraction', () => {
+ it('uses adjacent limits and sums across skipped fingers', () => {
+ const p = DEFAULT_HAND_PROFILE;
+ expect(pairLimitFraction(p, 1, 2)).toBeCloseTo(0.65, 9);
+ expect(pairLimitFraction(p, 2, 3)).toBeCloseTo(0.35, 9);
+ expect(pairLimitFraction(p, 3, 4)).toBeCloseTo(0.4, 9);
+ expect(pairLimitFraction(p, 1, 3)).toBeCloseTo(1.0, 9);
+ expect(pairLimitFraction(p, 4, 1)).toBeCloseTo(1.4, 9);
+ });
+});
+
+describe('calibration', () => {
+ it('converts pinky answers to mm reach', () => {
+ const p = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 5, highPinkyFret: 12, allowStretches: true });
+ expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 5, L), 9);
+ expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 12, L), 9);
+ expect(p.stretchTolerance).toBe(1.1);
+ });
+
+ it('clamps answers to their documented ranges', () => {
+ const p = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 1, highPinkyFret: 20 });
+ expect(p.reachAtLowMm).toBeCloseTo(spanMm(1, 3, L), 9);
+ expect(p.reachAtHighMm).toBeCloseTo(spanMm(7, 13, L), 9);
+ });
+
+ it('round-trips through calibrationFromProfile', () => {
+ const answers = { ...DEFAULT_CALIBRATION, lowPinkyFret: 6, highPinkyFret: 10, maxFret: 12 };
+ expect(calibrationFromProfile(profileFromCalibration(answers))).toEqual(answers);
+ });
+
+ it('scales reach with scale length', () => {
+ const baritone = profileFromCalibration({ ...DEFAULT_CALIBRATION, scaleLengthMm: 27 * 25.4 });
+ expect(baritone.reachAtLowMm).toBeGreaterThan(DEFAULT_HAND_PROFILE.reachAtLowMm);
+ });
+});
+
+describe('validateHandProfile', () => {
+ it('falls back to defaults for garbage', () => {
+ expect(validateHandProfile(null)).toEqual(DEFAULT_HAND_PROFILE);
+ expect(validateHandProfile('nope')).toEqual(DEFAULT_HAND_PROFILE);
+ expect(validateHandProfile({ reachAtLowMm: 'far', noBarre: 'yes' })).toEqual(DEFAULT_HAND_PROFILE);
+ });
+
+ it('clamps out-of-range numbers and keeps valid fields', () => {
+ const p = validateHandProfile({ ...DEFAULT_HAND_PROFILE, maxFret: 99, maxInteriorMutes: -3, noBarre: false });
+ expect(p.maxFret).toBe(24);
+ expect(p.maxInteriorMutes).toBe(0);
+ expect(p.noBarre).toBe(false);
+ });
+
+ it('repairs inverted reference frets', () => {
+ const p = validateHandProfile({ ...DEFAULT_HAND_PROFILE, lowRefFret: 9, highRefFret: 3 });
+ expect(p.lowRefFret).toBe(1);
+ expect(p.highRefFret).toBe(7);
+ });
+
+ it('migrates version-less stored data', () => {
+ const { version: _v, ...legacy } = createDefaultHandProfile();
+ expect(migrateHandProfile(legacy)).toEqual(DEFAULT_HAND_PROFILE);
+ });
+});
+
+describe('handProfileHash', () => {
+ it('is stable and changes when a relevant field changes', () => {
+ expect(handProfileHash(DEFAULT_HAND_PROFILE)).toBe(handProfileHash(createDefaultHandProfile()));
+ expect(handProfileHash({ ...DEFAULT_HAND_PROFILE, maxFret: 12 })).not.toBe(handProfileHash(DEFAULT_HAND_PROFILE));
+ });
+});
diff --git a/engine/handProfile.ts b/engine/handProfile.ts
new file mode 100644
index 0000000..56a0c20
--- /dev/null
+++ b/engine/handProfile.ts
@@ -0,0 +1,204 @@
+// Personal hand profile: comfort guidance for one player's hand, not a medical or safety assessment.
+import type { CalibrationAnswers, FingerNumber, HandProfile, PairLimits } from './types';
+import { DEFAULT_SCALE_LENGTH_MM, spanMm } from './geometry';
+
+/** Heuristic defaults (not research-backed): middle-ring is the most constrained pair. */
+export const DEFAULT_PAIR_LIMITS: PairLimits = { indexMiddle: 0.65, middleRing: 0.35, ringPinky: 0.4 };
+
+export const STRETCH_TOLERANCE_ALLOW = 1.1;
+
+export const CALIBRATION_LOW_INDEX_FRET = 1;
+export const CALIBRATION_HIGH_INDEX_FRET = 7;
+export const CALIBRATION_LOW_PINKY_RANGE = [3, 7] as const;
+export const CALIBRATION_HIGH_PINKY_RANGE = [9, 13] as const;
+
+export const PROFILE_LIMITS = {
+ scaleLengthMm: [550, 800],
+ reachMm: [30, 220],
+ refFret: [1, 15],
+ pairLimit: [0.1, 1],
+ stretchTolerance: [0.8, 1.5],
+ maxFret: [3, 24],
+ maxInteriorMutes: [0, 4],
+ comfortableSixteenthBpm: [40, 300]
+} as const;
+
+export const createDefaultHandProfile = (scaleLengthMm: number = DEFAULT_SCALE_LENGTH_MM): HandProfile => ({
+ version: 1,
+ scaleLengthMm,
+ reachAtLowMm: spanMm(CALIBRATION_LOW_INDEX_FRET, 4, scaleLengthMm),
+ reachAtHighMm: spanMm(CALIBRATION_HIGH_INDEX_FRET, 11, scaleLengthMm),
+ lowRefFret: CALIBRATION_LOW_INDEX_FRET,
+ highRefFret: CALIBRATION_HIGH_INDEX_FRET,
+ pairLimits: { ...DEFAULT_PAIR_LIMITS },
+ stretchTolerance: 1,
+ noBarre: true,
+ allowTwoStringPartialBarre: false,
+ allowThumb: false,
+ maxFret: 15,
+ allowOpenStrings: true,
+ maxInteriorMutes: 1,
+ comfortableSixteenthBpm: 110
+});
+
+export const DEFAULT_HAND_PROFILE: HandProfile = createDefaultHandProfile();
+
+export const DEFAULT_CALIBRATION: CalibrationAnswers = {
+ scaleLengthMm: DEFAULT_SCALE_LENGTH_MM,
+ lowPinkyFret: 4,
+ highPinkyFret: 11,
+ allowStretches: false,
+ noBarre: true,
+ allowOpenStrings: true,
+ maxFret: 15,
+ comfortableSixteenthBpm: 110
+};
+
+/**
+ * Comfortable index -> pinky fingertip reach with the index at `atFret`.
+ * Linear between the two calibration points, constant outside them.
+ */
+export const maxReachMm = (profile: HandProfile, atFret: number): number => {
+ const { lowRefFret, highRefFret, reachAtLowMm, reachAtHighMm } = profile;
+ if (highRefFret <= lowRefFret) return reachAtLowMm;
+ if (atFret <= lowRefFret) return reachAtLowMm;
+ if (atFret >= highRefFret) return reachAtHighMm;
+ const t = (atFret - lowRefFret) / (highRefFret - lowRefFret);
+ return reachAtLowMm + t * (reachAtHighMm - reachAtLowMm);
+};
+
+/** Allowed total span for a shape whose lowest fretted note is at `lowestFret`. */
+export const allowedSpanMm = (profile: HandProfile, lowestFret: number): number =>
+ maxReachMm(profile, lowestFret) * profile.stretchTolerance;
+
+/**
+ * Pair limit as a fraction of the allowed span for fingers `a` < `b` (1..4).
+ * Non-adjacent fingers get the sum of the adjacent limits between them.
+ */
+export const pairLimitFraction = (profile: HandProfile, a: FingerNumber, b: FingerNumber): number => {
+ const lo = Math.min(a, b);
+ const hi = Math.max(a, b);
+ const steps: number[] = [profile.pairLimits.indexMiddle, profile.pairLimits.middleRing, profile.pairLimits.ringPinky];
+ let sum = 0;
+ for (let f = Math.max(1, lo); f < hi; f++) sum += steps[f - 1];
+ return sum;
+};
+
+const clamp = (value: number, [lo, hi]: readonly [number, number]): number => Math.min(hi, Math.max(lo, value));
+
+export const profileFromCalibration = (answers: CalibrationAnswers): HandProfile => {
+ const L = clamp(answers.scaleLengthMm, PROFILE_LIMITS.scaleLengthMm);
+ const lowPinky = Math.round(clamp(answers.lowPinkyFret, CALIBRATION_LOW_PINKY_RANGE));
+ const highPinky = Math.round(clamp(answers.highPinkyFret, CALIBRATION_HIGH_PINKY_RANGE));
+ return {
+ ...createDefaultHandProfile(L),
+ reachAtLowMm: spanMm(CALIBRATION_LOW_INDEX_FRET, lowPinky, L),
+ reachAtHighMm: spanMm(CALIBRATION_HIGH_INDEX_FRET, highPinky, L),
+ stretchTolerance: answers.allowStretches ? STRETCH_TOLERANCE_ALLOW : 1,
+ noBarre: answers.noBarre,
+ allowOpenStrings: answers.allowOpenStrings,
+ maxFret: Math.round(clamp(answers.maxFret, PROFILE_LIMITS.maxFret)),
+ comfortableSixteenthBpm: Math.round(clamp(answers.comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm))
+ };
+};
+
+/** Nearest calibration answers that reproduce a profile (for pre-filling the calibration form). */
+export const calibrationFromProfile = (profile: HandProfile): CalibrationAnswers => {
+ const nearestPinky = (indexFret: number, reach: number, [lo, hi]: readonly [number, number]): number => {
+ let best = lo;
+ for (let f = lo; f <= hi; f++) {
+ if (Math.abs(spanMm(indexFret, f, profile.scaleLengthMm) - reach) < Math.abs(spanMm(indexFret, best, profile.scaleLengthMm) - reach)) {
+ best = f;
+ }
+ }
+ return best;
+ };
+ return {
+ scaleLengthMm: profile.scaleLengthMm,
+ lowPinkyFret: nearestPinky(CALIBRATION_LOW_INDEX_FRET, profile.reachAtLowMm, CALIBRATION_LOW_PINKY_RANGE),
+ highPinkyFret: nearestPinky(CALIBRATION_HIGH_INDEX_FRET, profile.reachAtHighMm, CALIBRATION_HIGH_PINKY_RANGE),
+ allowStretches: profile.stretchTolerance > 1,
+ noBarre: profile.noBarre,
+ allowOpenStrings: profile.allowOpenStrings,
+ maxFret: profile.maxFret,
+ comfortableSixteenthBpm: profile.comfortableSixteenthBpm
+ };
+};
+
+const num = (value: unknown, fallback: number, range: readonly [number, number], integer = false): number => {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
+ const clamped = clamp(value, range);
+ return integer ? Math.round(clamped) : clamped;
+};
+
+const bool = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback);
+
+/**
+ * Sanitizes untrusted input (localStorage, imported JSON) into a valid profile.
+ * Missing or invalid fields fall back to defaults; numbers are clamped to sane ranges.
+ */
+export const validateHandProfile = (input: unknown): HandProfile => {
+ const raw = (typeof input === 'object' && input !== null ? input : {}) as Record;
+ const scaleLengthMm = num(raw.scaleLengthMm, DEFAULT_SCALE_LENGTH_MM, PROFILE_LIMITS.scaleLengthMm);
+ const base = createDefaultHandProfile(scaleLengthMm);
+ const pairsRaw = (typeof raw.pairLimits === 'object' && raw.pairLimits !== null ? raw.pairLimits : {}) as Record;
+
+ let lowRefFret = num(raw.lowRefFret, base.lowRefFret, PROFILE_LIMITS.refFret, true);
+ let highRefFret = num(raw.highRefFret, base.highRefFret, PROFILE_LIMITS.refFret, true);
+ if (highRefFret <= lowRefFret) {
+ lowRefFret = base.lowRefFret;
+ highRefFret = base.highRefFret;
+ }
+
+ return {
+ version: 1,
+ scaleLengthMm,
+ reachAtLowMm: num(raw.reachAtLowMm, base.reachAtLowMm, PROFILE_LIMITS.reachMm),
+ reachAtHighMm: num(raw.reachAtHighMm, base.reachAtHighMm, PROFILE_LIMITS.reachMm),
+ lowRefFret,
+ highRefFret,
+ pairLimits: {
+ indexMiddle: num(pairsRaw.indexMiddle, base.pairLimits.indexMiddle, PROFILE_LIMITS.pairLimit),
+ middleRing: num(pairsRaw.middleRing, base.pairLimits.middleRing, PROFILE_LIMITS.pairLimit),
+ ringPinky: num(pairsRaw.ringPinky, base.pairLimits.ringPinky, PROFILE_LIMITS.pairLimit)
+ },
+ stretchTolerance: num(raw.stretchTolerance, base.stretchTolerance, PROFILE_LIMITS.stretchTolerance),
+ noBarre: bool(raw.noBarre, base.noBarre),
+ allowTwoStringPartialBarre: bool(raw.allowTwoStringPartialBarre, base.allowTwoStringPartialBarre),
+ allowThumb: bool(raw.allowThumb, base.allowThumb),
+ maxFret: num(raw.maxFret, base.maxFret, PROFILE_LIMITS.maxFret, true),
+ allowOpenStrings: bool(raw.allowOpenStrings, base.allowOpenStrings),
+ maxInteriorMutes: num(raw.maxInteriorMutes, base.maxInteriorMutes, PROFILE_LIMITS.maxInteriorMutes, true),
+ comfortableSixteenthBpm: num(raw.comfortableSixteenthBpm, base.comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm, true)
+ };
+};
+
+/**
+ * Upgrades any stored shape to the current version. Version 1 is the first version, so today this
+ * only sanitizes; unknown future versions are treated as untrusted input.
+ */
+export const migrateHandProfile = (raw: unknown): HandProfile => validateHandProfile(raw);
+
+/** Stable key for memoizing generation per profile. */
+export const handProfileHash = (profile: HandProfile): string => {
+ const r = (n: number) => Math.round(n * 1000) / 1000;
+ return [
+ profile.version,
+ r(profile.scaleLengthMm),
+ r(profile.reachAtLowMm),
+ r(profile.reachAtHighMm),
+ profile.lowRefFret,
+ profile.highRefFret,
+ r(profile.pairLimits.indexMiddle),
+ r(profile.pairLimits.middleRing),
+ r(profile.pairLimits.ringPinky),
+ r(profile.stretchTolerance),
+ profile.noBarre ? 1 : 0,
+ profile.allowTwoStringPartialBarre ? 1 : 0,
+ profile.allowThumb ? 1 : 0,
+ profile.maxFret,
+ profile.allowOpenStrings ? 1 : 0,
+ profile.maxInteriorMutes,
+ profile.comfortableSixteenthBpm
+ ].join('|');
+};
diff --git a/engine/index.test.ts b/engine/index.test.ts
new file mode 100644
index 0000000..4d0a654
--- /dev/null
+++ b/engine/index.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import * as engine from './index';
+
+describe('engine barrel', () => {
+ it('exposes the version and the main entry points', () => {
+ expect(engine.ENGINE_VERSION).toMatch(/^\d+\.\d+\.\d+$/);
+ for (const name of [
+ 'shapeToMidi',
+ 'findBestFingering',
+ 'generateVoicings',
+ 'findClosestVoicing',
+ 'optimizeSequence',
+ 'generateRhythm',
+ 'rhythmToPlaybackEvents',
+ 'fromLegacyNotes',
+ 'nameVoicing',
+ 'DEFAULT_HAND_PROFILE'
+ ]) {
+ expect(engine).toHaveProperty(name);
+ }
+ });
+
+ it('runs one generation end to end', () => {
+ const result = engine.generateVoicings({
+ root: 'E',
+ family: 'power5',
+ tuning: engine.E_STANDARD,
+ profile: engine.DEFAULT_HAND_PROFILE
+ });
+ expect(result.voicings.length).toBeGreaterThan(0);
+ const first = result.voicings[0];
+ expect(first.midi).toEqual(engine.shapeToMidi(first.shape, engine.E_STANDARD));
+ });
+});
diff --git a/engine/index.ts b/engine/index.ts
new file mode 100644
index 0000000..baff5cd
--- /dev/null
+++ b/engine/index.ts
@@ -0,0 +1,19 @@
+// Public API of the playability engine. Pure TypeScript, no runtime dependencies.
+// Vendored into Chord Explorer by its scripts/sync-engine.mjs; bump on any behaviour change.
+export const ENGINE_VERSION = '0.1.0';
+
+export * from './types';
+export * from './pitch';
+export * from './tuning';
+export * from './random';
+export * from './geometry';
+export * from './shape';
+export * from './handProfile';
+export * from './weights';
+export * from './fingering';
+export * from './voicingSpec';
+export * from './naming';
+export * from './playability';
+export * from './generateVoicings';
+export * from './transition';
+export * from './rhythm';
diff --git a/engine/naming.test.ts b/engine/naming.test.ts
new file mode 100644
index 0000000..5d89999
--- /dev/null
+++ b/engine/naming.test.ts
@@ -0,0 +1,368 @@
+import { describe, it, expect } from 'vitest';
+import { degreesByString, degreeString, chordSymbol, nameVoicing } from './naming';
+import { getFamily, fromLegacyNotes } from './voicingSpec';
+import type { Shape, VoicingFamily } from './types';
+import { E_STANDARD, DROP_D } from './tuning';
+import { createRng } from './random';
+import { pitchClassName, pitchClass, intervalName } from './pitch';
+import { shapeToMidi, bassMidi } from './shape';
+
+const x = null;
+const C = 0;
+const D = 2;
+const E = 4;
+
+const fam = (id: string): VoicingFamily => {
+ const family = getFamily(id);
+ if (!family) throw new Error(`missing family ${id}`);
+ return family;
+};
+
+describe('degreesByString', () => {
+ it('labels each string relative to the root, null when muted', () => {
+ expect(degreesByString([x, 3, 2, 0, 1, 0], E_STANDARD, C)).toEqual([null, '1', '3', '5', '1', '3']);
+ expect(degreesByString([0, 1, x, x, x, x], E_STANDARD, E)).toEqual(['1', 'b5', null, null, null, null]);
+ });
+
+ it('follows the tuning (Drop D low string)', () => {
+ expect(degreesByString([0, 0, 0, x, x, x], DROP_D, D)).toEqual(['1', '5', '1', null, null, null]);
+ expect(degreesByString([0, 0, 0, x, x, x], E_STANDARD, D)).toEqual(['2', '5', '1', null, null, null]);
+ });
+});
+
+describe('degreeString', () => {
+ it('lists sounding notes low -> high using the pitch.ts degree table', () => {
+ // E2 B2 F3
+ expect(degreeString([0, 2, 3, x, x, x], E_STANDARD, E)).toBe('1 5 b2');
+ expect(degreeString([0, 2, 2, 1, 0, 0], E_STANDARD, E)).toBe('1 5 1 3 5 1');
+ });
+
+ it('keeps octave doublings', () => {
+ expect(degreeString([0, 2, 2, x, x, x], E_STANDARD, E)).toBe('1 5 1');
+ expect(degreeString([0, 0, 0, x, x, x], DROP_D, D)).toBe('1 5 1');
+ });
+
+ it('sorts by pitch, not by string', () => {
+ // String 0 fret 7 is B2, above the open A2 on string 1.
+ expect(degreeString([7, 0, x, x, x, x], E_STANDARD, E)).toBe('4 5');
+ expect(degreeString([1, x, 2, x, x, x], E_STANDARD, E)).toBe('b2 1');
+ });
+
+ it('is empty for a fully muted shape', () => {
+ expect(degreeString([x, x, x, x, x, x], E_STANDARD, E)).toBe('');
+ });
+});
+
+describe('chordSymbol templates', () => {
+ const templates: [string, number[]][] = [
+ ['5', [0, 7]],
+ ['5(b9)', [0, 1, 7]],
+ ['', [0, 4, 7]],
+ ['m', [0, 3, 7]],
+ ['dim', [0, 3, 6]],
+ ['aug', [0, 4, 8]],
+ ['sus2', [0, 2, 7]],
+ ['sus4', [0, 5, 7]],
+ ['7', [0, 4, 7, 10]],
+ ['m7', [0, 3, 7, 10]],
+ ['maj7', [0, 4, 7, 11]],
+ ['mMaj7', [0, 3, 7, 11]],
+ ['m7b5', [0, 3, 6, 10]],
+ ['dim7', [0, 3, 6, 9]],
+ ['7sus4', [0, 5, 7, 10]],
+ ['6', [0, 4, 7, 9]],
+ ['m6', [0, 3, 7, 9]],
+ ['add9', [0, 2, 4, 7]],
+ ['m(add9)', [0, 2, 3, 7]],
+ ['(addb9)', [0, 1, 4, 7]],
+ ['7(b9)', [0, 1, 4, 7, 10]],
+ ['9', [0, 2, 4, 7, 10]],
+ ['m9', [0, 2, 3, 7, 10]],
+ ['maj9', [0, 2, 4, 7, 11]],
+ ['m11', [0, 2, 3, 5, 7, 10]]
+ ];
+
+ it.each(templates)('matches %j exactly in root position', (symbol, intervals) => {
+ expect(chordSymbol(C, intervals, 0)).toEqual({ symbol, omissions: [] });
+ });
+
+ it('ignores order, duplicates and octave-wrapped intervals', () => {
+ expect(chordSymbol(C, [7, 4, 0, 7, 12, 16, -5], 0)).toEqual({ symbol: '', omissions: [] });
+ expect(chordSymbol(E, [10, 0, 4, 10, 0], null).symbol).toBe('7(no5)');
+ });
+});
+
+describe('chordSymbol omissions', () => {
+ const sym = (intervals: number[], bass: number | null = 0): string | null => chordSymbol(C, intervals, bass).symbol;
+
+ it('marks a missing perfect fifth', () => {
+ expect(chordSymbol(C, [0, 4, 10], 0)).toEqual({ symbol: '7(no5)', omissions: ['(no5)'] });
+ expect(sym([0, 5, 10])).toBe('7sus4(no5)');
+ expect(sym([0, 3, 10])).toBe('m7(no5)');
+ expect(sym([0, 4, 11])).toBe('maj7(no5)');
+ expect(sym([0, 2, 4])).toBe('add9(no5)');
+ expect(sym([0, 2, 3])).toBe('m(add9)(no5)');
+ expect(sym([0, 1, 4])).toBe('(addb9)(no5)');
+ expect(sym([0, 1, 4, 10])).toBe('7(b9)(no5)');
+ expect(sym([0, 4, 9])).toBe('6(no5)');
+ });
+
+ it('marks a missing third only where the symbol does not claim a quality', () => {
+ expect(chordSymbol(C, [0, 7, 10], 0)).toEqual({ symbol: '7(no3)', omissions: ['(no3)'] });
+ expect(sym([0, 7, 11])).toBe('maj7(no3)');
+ // m7b5 without its third would need a quality the notes no longer show.
+ expect(sym([0, 6, 10])).toBeNull();
+ });
+
+ it('never drops a flat or sharp fifth: that would erase the quality', () => {
+ expect(sym([0, 3, 9])).toBe('m6(no5)');
+ expect(sym([0, 4])).toBeNull();
+ });
+
+ it('marks a missing root without inventing another root', () => {
+ expect(chordSymbol(C, [4, 7, 10], 4)).toEqual({ symbol: '7(no root)/E', omissions: ['(no root)'] });
+ expect(sym([3, 7, 10], 3)).toBe('m7(no root)/D#');
+ expect(sym([2, 4, 10], 2)).toBe('9(no5)(no root)/D');
+ expect(sym([4, 7, 10], null)).toBe('7(no root)');
+ // Two pitch classes are too thin for a rootless symbol.
+ expect(sym([4, 7], 4)).toBeNull();
+ expect(sym([4, 10], 4)).toBeNull();
+ });
+
+ it('needs at least three pitch classes once anything is omitted', () => {
+ expect(sym([0, 3])).toBeNull();
+ expect(sym([0, 10])).toBeNull();
+ });
+});
+
+describe('chordSymbol slash bass and non-chords', () => {
+ it('appends the bass when it is not the root, spelled with sharps', () => {
+ expect(chordSymbol(C, [0, 4, 7], 4)).toEqual({ symbol: '/E', omissions: [] });
+ expect(chordSymbol(C, [0, 4, 7], 7).symbol).toBe('/G');
+ expect(chordSymbol(E, [0, 7], 7).symbol).toBe('5/B');
+ expect(chordSymbol(10, [0, 3, 7], 3).symbol).toBe('m/C#');
+ expect(chordSymbol(C, [0, 4, 7], null).symbol).toBe('');
+ });
+
+ it('counts the bass as sounding even if the caller left it out of the intervals', () => {
+ expect(chordSymbol(C, [0, 4], 7).symbol).toBe('/G');
+ });
+
+ it('gives dyads and clusters no symbol', () => {
+ for (const intervals of [[0, 1], [0, 6], [0, 5], [0, 2], [0, 3], [0, 4], [0, 11], [0, 1, 2], [0, 1, 2, 3]]) {
+ expect(chordSymbol(E, intervals, 0), JSON.stringify(intervals)).toEqual({ symbol: null, omissions: [] });
+ }
+ });
+
+ it('gives nothing for empty or single pitch classes', () => {
+ expect(chordSymbol(E, [], null)).toEqual({ symbol: null, omissions: [] });
+ expect(chordSymbol(E, [0, 0, 12], 0)).toEqual({ symbol: null, omissions: [] });
+ expect(chordSymbol(E, [3], 3)).toEqual({ symbol: null, omissions: [] });
+ });
+
+ it('gives no symbol to unknown sets', () => {
+ expect(chordSymbol(C, [0, 4, 8, 11], 0).symbol).toBeNull();
+ expect(chordSymbol(C, [0, 3, 5, 10], 0).symbol).toBeNull();
+ expect(chordSymbol(C, [0, 3, 4, 7], 0).symbol).toBeNull();
+ });
+});
+
+describe('nameVoicing', () => {
+ it('names open chords with the full symbol', () => {
+ expect(nameVoicing([0, 2, 2, 1, 0, 0], E_STANDARD, E)).toEqual({
+ symbol: 'E',
+ name: 'E',
+ degrees: '1 5 1 3 5 1',
+ degreesByString: ['1', '5', '1', '3', '5', '1']
+ });
+ expect(nameVoicing([x, 3, 2, 0, 1, 0], E_STANDARD, C).name).toBe('C');
+ expect(nameVoicing([0, 2, 4, 0, 0, 0], E_STANDARD, E)).toMatchObject({
+ symbol: 'Em(add9)',
+ degrees: '1 5 2 b3 5 1'
+ });
+ });
+
+ it('adds the slash bass for inversions', () => {
+ const v = nameVoicing([0, 3, 2, 0, 1, x], E_STANDARD, C);
+ expect(v.symbol).toBe('C/E');
+ expect(v.degrees).toBe('3 1 3 5 1');
+ });
+
+ it('names power chords, including Drop D', () => {
+ expect(nameVoicing([0, 2, 2, x, x, x], E_STANDARD, E).symbol).toBe('E5');
+ expect(nameVoicing([0, 2, 3, x, x, x], E_STANDARD, E)).toMatchObject({ symbol: 'E5(b9)', degrees: '1 5 b2' });
+ expect(nameVoicing([0, 0, 0, x, x, x], DROP_D, D)).toMatchObject({ symbol: 'D5', name: 'D5', degrees: '1 5 1' });
+ expect(nameVoicing([3, 3, 3, x, x, x], DROP_D, 5).symbol).toBe('F5');
+ });
+
+ it('describes dyads without a chord symbol', () => {
+ expect(nameVoicing([0, x, 3, x, x, x], E_STANDARD, E)).toMatchObject({
+ symbol: null,
+ name: 'E + F (minor 2nd)',
+ degrees: '1 b2'
+ });
+ expect(nameVoicing([0, 1, 2, x, x, x], E_STANDARD, E)).toMatchObject({
+ symbol: null,
+ name: 'E tritone dyad',
+ degrees: '1 b5 1'
+ });
+ expect(nameVoicing([0, 0, x, x, x, x], E_STANDARD, E).name).toBe('E + A (perfect 4th)');
+ });
+
+ it('describes clusters by their degrees', () => {
+ // E3 F4 F#4
+ expect(nameVoicing([x, x, 2, x, 6, 2], E_STANDARD, E)).toMatchObject({
+ symbol: null,
+ name: 'E cluster (1 b2 2)',
+ degrees: '1 b2 2'
+ });
+ });
+
+ it('names the interval that sounds when a dyad is inverted', () => {
+ // F2 E3: the root sits a major 7th above the bass, not a minor 2nd.
+ expect(nameVoicing([1, x, 2, x, x, x], E_STANDARD, E)).toMatchObject({
+ symbol: null,
+ name: 'E over F (major 7th)',
+ degrees: 'b2 1'
+ });
+ // A2 E3 and A2 E3 A3: a perfect 5th up from the bass.
+ expect(nameVoicing([x, 0, 2, x, x, x], E_STANDARD, E).name).toBe('E over A (perfect 5th)');
+ expect(nameVoicing([x, 0, 2, 2, x, x], E_STANDARD, E).name).toBe('E over A (perfect 5th)');
+ // C#3 C4 for a b2 dyad on C
+ expect(nameVoicing([9, x, 10, x, x, x], E_STANDARD, C).name).toBe('C over C# (major 7th)');
+ // F#2 D#3 on D#
+ expect(nameVoicing([2, 6, x, x, x, x], E_STANDARD, 3).name).toBe('D# over F# (major 6th)');
+ // Drop D: D#2 B2 D#3 on B
+ expect(nameVoicing([1, 2, 1, x, x, x], DROP_D, 11).name).toBe('B over D# (minor 6th)');
+ // The tritone is its own inversion.
+ expect(nameVoicing([x, 1, 2, x, x, x], E_STANDARD, E).name).toBe('E tritone dyad over A#');
+ });
+
+ it('names the sounding interval of every two-pitch-class dyad up from its bass', () => {
+ const rng = createRng('dyad-intervals');
+ let checked = 0;
+ for (let n = 0; n < 3000; n++) {
+ const tuning = rng.chance(0.5) ? E_STANDARD : DROP_D;
+ const shape: Shape = tuning.openMidi.map(() => (rng.chance(0.6) ? null : rng.int(0, 12)));
+ const root = rng.int(0, 11);
+ const midi = shapeToMidi(shape, tuning);
+ const pcs = [...new Set(midi.map(pitchClass))];
+ if (pcs.length !== 2 || !pcs.includes(root)) continue;
+ const bass = pitchClass(bassMidi(shape, tuning) as number);
+ const upper = pcs.find((pc) => pc !== bass) as number;
+ const sounding = pitchClass(upper - bass);
+ if (sounding === 6) continue;
+ const v = nameVoicing(shape, tuning, root);
+ if (v.symbol !== null) continue;
+ expect(v.name, `${JSON.stringify(shape)} on ${root}`).toContain(`(${intervalName(sounding)})`);
+ checked++;
+ }
+ expect(checked).toBeGreaterThan(50);
+ });
+
+ it('names rootless voicings against the caller root only', () => {
+ // E3 G3 A#4 over an intended C
+ expect(nameVoicing([x, x, 2, 0, x, 6], E_STANDARD, C)).toMatchObject({
+ symbol: 'C7(no root)/E',
+ degrees: '3 5 b7'
+ });
+ // F4 B4 over an intended E: no symbol, plain description
+ expect(nameVoicing([x, x, x, x, 6, 7], E_STANDARD, E)).toMatchObject({
+ symbol: null,
+ name: 'F + B (b2 5 of E, no root)'
+ });
+ });
+
+ it('handles single notes, octaves and silence', () => {
+ expect(nameVoicing([0, x, x, x, x, x], E_STANDARD, E).name).toBe('E single note');
+ expect(nameVoicing([0, x, 2, x, x, x], E_STANDARD, E).name).toBe('E octaves');
+ // E4 on two strings is a unison, not an octave.
+ expect(nameVoicing([x, x, x, x, 5, 0], E_STANDARD, E).name).toBe('E single note');
+ expect(nameVoicing([x, x, x, x, x, 3], E_STANDARD, E).name).toBe('G (b3 of E, no root)');
+ expect(nameVoicing([x, x, x, x, x, x], E_STANDARD, E)).toEqual({
+ symbol: null,
+ name: 'No notes',
+ degrees: '',
+ degreesByString: [null, null, null, null, null, null]
+ });
+ });
+
+ it('uses the family label only when the shape really matches the family', () => {
+ const shape: Shape = [0, 0, 0, 0, x, x]; // E A D G
+ expect(nameVoicing(shape, E_STANDARD, E).name).toBe('E voicing (1 b3 4 b7)');
+ expect(nameVoicing(shape, E_STANDARD, E, fam('quartal'))).toMatchObject({
+ symbol: null,
+ name: 'E Quartal (1 b3 4 b7)'
+ });
+ expect(nameVoicing(shape, E_STANDARD, E, fam('power5')).name).toBe('E voicing (1 b3 4 b7)');
+ const legacy = fromLegacyNotes(['E2', 'A2', 'D3', 'G3'], 'E');
+ expect(nameVoicing(shape, E_STANDARD, E, legacy).name).toBe('E voicing (1 b3 4 b7)');
+ });
+
+ it('says Quartal only when the notes sound as stacked fourths', () => {
+ // E2 A3 D4 G4: an 11th then two fourths still stacks fourths.
+ expect(nameVoicing([0, 12, 12, 12, x, x], E_STANDARD, E, fam('quartal')).name).toBe('E Quartal (1 b3 4 b7)');
+ // Drop D G#3 B3 C#4 F#4: the b3 sits right above the root, gaps m3 M2 P4.
+ const notStacked: Shape = [x, 11, 11, 11, 0, x];
+ expect(nameVoicing(notStacked, DROP_D, 8, fam('quartal'))).toMatchObject({
+ symbol: null,
+ name: 'G# voicing (1 b3 4 b7)',
+ degrees: '1 b3 4 b7'
+ });
+ });
+
+ it('says cluster only when a close second sounds', () => {
+ // E3 F4 F#4: the F-F# semitone is packed.
+ expect(nameVoicing([x, x, 2, x, 6, 2], E_STANDARD, E, fam('cluster_m2')).name).toBe('E m2 cluster (1 b2 2)');
+ // E2 F#3 F4: gaps of 14 and 11 semitones, no second anywhere.
+ const spread: Shape = [0, 9, x, 10, x, x];
+ expect(nameVoicing(spread, E_STANDARD, E, fam('cluster_m2'))).toMatchObject({
+ symbol: null,
+ name: 'E voicing (1 b2 2)',
+ degrees: '1 2 b2'
+ });
+ expect(nameVoicing(spread, E_STANDARD, E).name).toBe('E voicing (1 b2 2)');
+ // D3 C4 C#5 on C: gaps 10 and 13.
+ expect(nameVoicing([x, x, 0, x, 1, 9], E_STANDARD, C, fam('cluster_m2')).name).toBe('C voicing (1 b2 2) over D');
+ });
+
+ it('never lets a family override a confident symbol', () => {
+ expect(nameVoicing([0, 2, 2, x, x, x], E_STANDARD, E, fam('drone_power5')).name).toBe('E5');
+ // E E G# B is a plain E major triad even when the caller asked for Phrygian.
+ expect(nameVoicing([0, 7, 6, 4, x, x], E_STANDARD, E, fam('phrygian')).symbol).toBe('E');
+ expect(nameVoicing([0, 8, 6, 4, x, x], E_STANDARD, E, fam('phrygian')).symbol).toBe('E(addb9)');
+ });
+
+ it('stays honest on random shapes', () => {
+ const rng = createRng('naming-honesty');
+ const majorQuality = new Set(['', '7', 'maj7', '6', 'add9', '(addb9)', '7(b9)', '9', 'maj9']);
+ for (let n = 0; n < 2000; n++) {
+ const tuning = rng.chance(0.5) ? E_STANDARD : DROP_D;
+ const shape: Shape = tuning.openMidi.map(() => (rng.chance(0.4) ? null : rng.int(0, 12)));
+ const root = rng.int(0, 11);
+ const v = nameVoicing(shape, tuning, root);
+ const tokens = v.degrees === '' ? [] : v.degrees.split(' ');
+ expect(tokens.length).toBe(shapeToMidi(shape, tuning).length);
+ expect(v.degreesByString.filter((d) => d !== null).length).toBe(tokens.length);
+ if (v.symbol === null) {
+ expect(v.name).toMatch(/^(No notes$|[A-G]#? )/);
+ expect(v.name).not.toMatch(/sus|dim|aug|add|\(no/);
+ continue;
+ }
+ const rootName = pitchClassName(root);
+ expect(v.name).toBe(v.symbol);
+ expect(v.symbol.startsWith(rootName)).toBe(true);
+ const bass = bassMidi(shape, tuning) as number;
+ expect(v.symbol.includes('/')).toBe(pitchClass(bass) !== root);
+ expect(v.symbol.includes('(no root)')).toBe(!tokens.includes('1'));
+ if (v.symbol.includes('(no5)')) expect(tokens).not.toContain('5');
+ if (v.symbol.includes('(no3)')) {
+ expect(tokens).not.toContain('3');
+ expect(tokens).not.toContain('b3');
+ }
+ const quality = v.symbol.slice(rootName.length).split('/')[0].replace(/\((no3|no5|no root)\)/g, '');
+ if (/^m(?!aj)/.test(quality)) expect(tokens).toContain('b3');
+ if (majorQuality.has(quality) && !v.symbol.includes('(no3)')) expect(tokens).toContain('3');
+ }
+ });
+});
diff --git a/engine/naming.ts b/engine/naming.ts
new file mode 100644
index 0000000..51ecaf2
--- /dev/null
+++ b/engine/naming.ts
@@ -0,0 +1,233 @@
+// Honest voicing labels: degrees always come from the sounding notes; a chord symbol is given only for a
+// confident template match against the caller's root, otherwise the name only states what sounds.
+import type { Shape, Tuning, VoicingFamily } from './types';
+import { degreeLabel, intervalFrom, intervalName, pitchClass, pitchClassName } from './pitch';
+import { shapeToMidiByString } from './shape';
+import { matchFamily } from './voicingSpec';
+
+interface ChordTemplate {
+ /** Suffix after the root letter, e.g. 'm7'. */
+ symbol: string;
+ intervals: readonly number[];
+ /** The symbol states no third quality of its own, so '(no3)' stays truthful. */
+ allowNo3?: boolean;
+}
+
+const CHORD_TEMPLATES: readonly ChordTemplate[] = [
+ { symbol: '5', intervals: [0, 7] },
+ { symbol: '5(b9)', intervals: [0, 1, 7] },
+ { symbol: '', intervals: [0, 4, 7] },
+ { symbol: 'm', intervals: [0, 3, 7] },
+ { symbol: 'dim', intervals: [0, 3, 6] },
+ { symbol: 'aug', intervals: [0, 4, 8] },
+ { symbol: 'sus2', intervals: [0, 2, 7] },
+ { symbol: 'sus4', intervals: [0, 5, 7] },
+ { symbol: '7', intervals: [0, 4, 7, 10], allowNo3: true },
+ { symbol: 'm7', intervals: [0, 3, 7, 10] },
+ { symbol: 'maj7', intervals: [0, 4, 7, 11], allowNo3: true },
+ { symbol: 'mMaj7', intervals: [0, 3, 7, 11] },
+ { symbol: 'm7b5', intervals: [0, 3, 6, 10] },
+ { symbol: 'dim7', intervals: [0, 3, 6, 9] },
+ { symbol: '7sus4', intervals: [0, 5, 7, 10] },
+ { symbol: '6', intervals: [0, 4, 7, 9] },
+ { symbol: 'm6', intervals: [0, 3, 7, 9] },
+ { symbol: 'add9', intervals: [0, 2, 4, 7] },
+ { symbol: 'm(add9)', intervals: [0, 2, 3, 7] },
+ { symbol: '(addb9)', intervals: [0, 1, 4, 7] },
+ { symbol: '7(b9)', intervals: [0, 1, 4, 7, 10] },
+ { symbol: '9', intervals: [0, 2, 4, 7, 10] },
+ { symbol: 'm9', intervals: [0, 2, 3, 7, 10] },
+ { symbol: 'maj9', intervals: [0, 2, 4, 7, 11] },
+ { symbol: 'm11', intervals: [0, 2, 3, 5, 7, 10] }
+];
+
+const ROOT_BIT = 1 << 0;
+const THIRD_BITS = (1 << 3) | (1 << 4);
+// Only the perfect fifth may be omitted: dropping a b5 or #5 would erase the chord quality.
+const FIFTH_BIT = 1 << 7;
+
+/** A symbol that omits anything must still rest on at least this many sounding pitch classes. */
+const MIN_PITCH_CLASSES_WITH_OMISSIONS = 3;
+
+const TEMPLATE_MASKS: readonly { template: ChordTemplate; mask: number }[] = CHORD_TEMPLATES.map((template) => ({
+ template,
+ mask: template.intervals.reduce((mask, i) => mask | (1 << i), 0)
+}));
+
+const popcount = (mask: number): number => {
+ let count = 0;
+ for (let m = mask; m; m &= m - 1) count++;
+ return count;
+};
+
+const omissionLabels = (missing: number): string[] => {
+ const labels: string[] = [];
+ if (missing & THIRD_BITS) labels.push('(no3)');
+ if (missing & FIFTH_BIT) labels.push('(no5)');
+ if (missing & ROOT_BIT) labels.push('(no root)');
+ return labels;
+};
+
+export interface ChordSymbolResult {
+ symbol: string | null;
+ omissions: string[];
+}
+
+export interface VoicingName {
+ symbol: string | null;
+ name: string;
+ degrees: string;
+ degreesByString: (string | null)[];
+}
+
+const noSymbol = (): ChordSymbolResult => ({ symbol: null, omissions: [] });
+
+/**
+ * Chord-symbol suffix relative to `rootPc` (e.g. '', 'm7(no5)', '/E'), or null when no template matches
+ * confidently. `intervals` may contain duplicates or unreduced values; the bass always counts as sounding.
+ * Omissions: perfect fifth, the root (only reported, never replaced by another root), and the third only for
+ * templates whose symbol makes no third claim. Ties between different templates at the same omission count
+ * give no symbol.
+ */
+export const chordSymbol = (
+ rootPc: number,
+ intervals: readonly number[],
+ bassInterval: number | null
+): ChordSymbolResult => {
+ let present = 0;
+ for (const i of intervals) present |= 1 << pitchClass(i);
+ if (bassInterval !== null) present |= 1 << pitchClass(bassInterval);
+ const pitchClasses = popcount(present);
+ if (pitchClasses < 2) return noSymbol();
+
+ let best: { template: ChordTemplate; missing: number }[] = [];
+ let bestOmitted = Infinity;
+ for (const { template, mask } of TEMPLATE_MASKS) {
+ if (present & ~mask) continue;
+ const missing = mask & ~present;
+ const omitted = popcount(missing);
+ if (omitted > 0) {
+ if (pitchClasses < MIN_PITCH_CLASSES_WITH_OMISSIONS) continue;
+ const omissible = ROOT_BIT | (mask & FIFTH_BIT) | (template.allowNo3 ? mask & THIRD_BITS : 0);
+ if (missing & ~omissible) continue;
+ }
+ if (omitted < bestOmitted) {
+ best = [{ template, missing }];
+ bestOmitted = omitted;
+ } else if (omitted === bestOmitted) {
+ best.push({ template, missing });
+ }
+ }
+ if (best.length !== 1) return noSymbol();
+
+ const { template, missing } = best[0];
+ const omissions = omissionLabels(missing);
+ const bass = bassInterval === null ? 0 : pitchClass(bassInterval);
+ const slash = bass === 0 ? '' : `/${pitchClassName(rootPc + bass)}`;
+ return { symbol: `${template.symbol}${omissions.join('')}${slash}`, omissions };
+};
+
+export const degreesByString = (shape: Shape, tuning: Tuning, rootPc: number): (string | null)[] =>
+ shapeToMidiByString(shape, tuning).map((midi) =>
+ midi === null ? null : degreeLabel(intervalFrom(rootPc, pitchClass(midi)))
+ );
+
+/** Sounding notes as [midi, string] sorted by pitch ascending, ties by string. */
+const soundingNotes = (shape: Shape, tuning: Tuning): [number, number][] =>
+ shapeToMidiByString(shape, tuning)
+ .flatMap((midi, s): [number, number][] => (midi === null ? [] : [[midi, s]]))
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1]);
+
+const degreesOf = (notes: readonly [number, number][], rootPc: number): string =>
+ notes.map(([midi]) => degreeLabel(intervalFrom(rootPc, pitchClass(midi)))).join(' ');
+
+/** Degree labels of the sounding notes sorted by pitch ascending (ties by string), space-separated. */
+export const degreeString = (shape: Shape, tuning: Tuning, rootPc: number): string =>
+ degreesOf(soundingNotes(shape, tuning), rootPc);
+
+const hasChromaticRun = (intervals: readonly number[]): boolean =>
+ intervals.some((i) => intervals.includes((i + 1) % 12) && intervals.includes((i + 2) % 12));
+
+/** Some neighbouring sounding notes (ascending, distinct) are a minor or major 2nd apart. */
+const soundsPacked = (pitches: readonly number[]): boolean =>
+ pitches.some((p, i) => i > 0 && p - pitches[i - 1] <= 2);
+
+/** Every neighbouring pair of sounding notes (ascending, distinct) is a perfect 4th, possibly an octave wider. */
+const soundsStackedFourths = (pitches: readonly number[]): boolean =>
+ pitches.length >= 3 && pitches.every((p, i) => i === 0 || (p - pitches[i - 1]) % 12 === 5);
+
+/** Words in a family label that claim a voicing structure, not just pitch content, and the check backing each. */
+const STRUCTURAL_WORDS: readonly [RegExp, (pitches: readonly number[]) => boolean][] = [
+ [/cluster/i, soundsPacked],
+ [/quartal/i, soundsStackedFourths]
+];
+
+const labelSounds = (label: string, pitches: readonly number[]): boolean =>
+ STRUCTURAL_WORDS.every(([word, holds]) => !word.test(label) || holds(pitches));
+
+/**
+ * States only what sounds: root, the other notes or degrees, and a non-root bass. An inverted dyad names the
+ * interval up from its bass, which is the one that sounds.
+ */
+const describeNotes = (
+ root: number,
+ intervals: readonly number[],
+ bassInterval: number,
+ pitches: readonly number[],
+ familyLabel: string | null
+): string => {
+ const rootName = pitchClassName(root);
+ const noteName = (interval: number): string => pitchClassName(root + interval);
+ const degrees = intervals.map(degreeLabel).join(' ');
+ const over = bassInterval === 0 ? '' : ` over ${noteName(bassInterval)}`;
+
+ if (!intervals.includes(0)) return `${intervals.map(noteName).join(' + ')} (${degrees} of ${rootName}, no root)`;
+ if (intervals.length === 1) return pitches.length > 1 ? `${rootName} octaves` : `${rootName} single note`;
+
+ if (intervals.length === 2) {
+ const other = intervals[1];
+ if (other === 6) return `${rootName} tritone dyad${over}`;
+ if (bassInterval === 0) return `${rootName} + ${noteName(other)} (${intervalName(other)})`;
+ return `${rootName}${over} (${intervalName(-other)})`;
+ }
+ const generic = hasChromaticRun(intervals) && soundsPacked(pitches) ? 'cluster' : 'voicing';
+ const kind = familyLabel !== null && labelSounds(familyLabel, pitches) ? familyLabel : generic;
+ return `${rootName} ${kind} (${degrees})${over}`;
+};
+
+/**
+ * `symbol` is the full chord symbol including the root (e.g. 'C/E', 'E5(b9)'), or null. `name` equals the
+ * symbol when there is one, otherwise a descriptive name. A family label is used in descriptive names only
+ * when the shape really matches that (non-legacy) family and, for labels that name a structure ('cluster',
+ * 'Quartal'), when the sounding notes show it.
+ */
+export const nameVoicing = (
+ shape: Shape,
+ tuning: Tuning,
+ rootPc: number,
+ family?: VoicingFamily
+): VoicingName => {
+ const root = pitchClass(rootPc);
+ const notes = soundingNotes(shape, tuning);
+ const byString = degreesByString(shape, tuning, root);
+ const degrees = degreesOf(notes, root);
+ if (notes.length === 0) return { symbol: null, name: 'No notes', degrees, degreesByString: byString };
+
+ const intervals = [...new Set(notes.map(([midi]) => intervalFrom(root, pitchClass(midi))))].sort((a, b) => a - b);
+ const bassInterval = intervalFrom(root, pitchClass(notes[0][0]));
+ const { symbol: suffix } = chordSymbol(root, intervals, bassInterval);
+ if (suffix !== null) {
+ const symbol = `${pitchClassName(root)}${suffix}`;
+ return { symbol, name: symbol, degrees, degreesByString: byString };
+ }
+
+ const familyLabel =
+ family && family.group !== 'legacy' && matchFamily(family, root, shape, tuning).ok ? family.label : null;
+ const pitches = [...new Set(notes.map(([midi]) => midi))];
+ return {
+ symbol: null,
+ name: describeNotes(root, intervals, bassInterval, pitches, familyLabel),
+ degrees,
+ degreesByString: byString
+ };
+};
diff --git a/engine/pitch.test.ts b/engine/pitch.test.ts
new file mode 100644
index 0000000..79402b9
--- /dev/null
+++ b/engine/pitch.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'vitest';
+import {
+ pitchClass,
+ midiToName,
+ nameToMidi,
+ parseNoteName,
+ parsePitchClass,
+ pitchClassName,
+ intervalFrom,
+ degreeLabel,
+ intervalName,
+ DEGREE_LABELS
+} from './pitch';
+
+describe('pitchClass', () => {
+ it('wraps into 0..11 including negatives', () => {
+ expect(pitchClass(40)).toBe(4);
+ expect(pitchClass(60)).toBe(0);
+ expect(pitchClass(-1)).toBe(11);
+ expect(pitchClass(-12)).toBe(0);
+ });
+});
+
+describe('midiToName / nameToMidi', () => {
+ it('uses C4 = 60 and sharp spelling', () => {
+ expect(midiToName(60)).toBe('C4');
+ expect(midiToName(40)).toBe('E2');
+ expect(midiToName(38)).toBe('D2');
+ expect(midiToName(46)).toBe('A#2');
+ expect(midiToName(23)).toBe('B0');
+ });
+
+ it('parses sharps and flats', () => {
+ expect(nameToMidi('E2')).toBe(40);
+ expect(nameToMidi('Bb2')).toBe(46);
+ expect(nameToMidi('A#2')).toBe(46);
+ expect(nameToMidi('Db3')).toBe(49);
+ expect(nameToMidi('Cb4')).toBe(59);
+ expect(nameToMidi('B#3')).toBe(60);
+ expect(nameToMidi('E1')).toBe(28);
+ });
+
+ it('round-trips every MIDI note', () => {
+ for (let m = 0; m <= 127; m++) {
+ expect(nameToMidi(midiToName(m))).toBe(m);
+ }
+ });
+
+ it('returns null from parseNoteName and throws from nameToMidi on bad input', () => {
+ expect(parseNoteName('x')).toBeNull();
+ expect(parseNoteName('H2')).toBeNull();
+ expect(parseNoteName('E')).toBeNull();
+ expect(() => nameToMidi('nope')).toThrow();
+ });
+});
+
+describe('pitch class names', () => {
+ it('parses names with flats and normalizes case of the letter', () => {
+ expect(parsePitchClass('C')).toBe(0);
+ expect(parsePitchClass('Db')).toBe(1);
+ expect(parsePitchClass('C#')).toBe(1);
+ expect(parsePitchClass('e')).toBe(4);
+ expect(parsePitchClass('Bb')).toBe(10);
+ expect(parsePitchClass('X')).toBeNull();
+ });
+
+ it('spells pitch classes with sharps', () => {
+ expect(pitchClassName(1)).toBe('C#');
+ expect(pitchClassName(10)).toBe('A#');
+ expect(pitchClassName(13)).toBe('C#');
+ });
+});
+
+describe('intervals and degrees', () => {
+ it('measures the interval above a root as a pitch class', () => {
+ expect(intervalFrom(4, 5)).toBe(1);
+ expect(intervalFrom(4, 4 + 7)).toBe(7);
+ expect(intervalFrom(9, 4)).toBe(7);
+ });
+
+ it('labels degrees with the documented table', () => {
+ expect(DEGREE_LABELS).toEqual(['1', 'b2', '2', 'b3', '3', '4', 'b5', '5', '#5', '6', 'b7', '7']);
+ expect(degreeLabel(0)).toBe('1');
+ expect(degreeLabel(1)).toBe('b2');
+ expect(degreeLabel(8)).toBe('#5');
+ expect(degreeLabel(10)).toBe('b7');
+ expect(degreeLabel(19)).toBe('5');
+ });
+
+ it('names intervals', () => {
+ expect(intervalName(0)).toBe('unison');
+ expect(intervalName(1)).toBe('minor 2nd');
+ expect(intervalName(6)).toBe('tritone');
+ expect(intervalName(7)).toBe('perfect 5th');
+ });
+});
diff --git a/engine/pitch.ts b/engine/pitch.ts
new file mode 100644
index 0000000..f14a561
--- /dev/null
+++ b/engine/pitch.ts
@@ -0,0 +1,73 @@
+// Pitch classes, MIDI <-> note names, interval and degree labels.
+// Spelling is always sharps on output; flats are accepted on input.
+
+export const PITCH_CLASS_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] as const;
+
+export const DEGREE_LABELS = ['1', 'b2', '2', 'b3', '3', '4', 'b5', '5', '#5', '6', 'b7', '7'] as const;
+
+export const INTERVAL_NAMES = [
+ 'unison',
+ 'minor 2nd',
+ 'major 2nd',
+ 'minor 3rd',
+ 'major 3rd',
+ 'perfect 4th',
+ 'tritone',
+ 'perfect 5th',
+ 'minor 6th',
+ 'major 6th',
+ 'minor 7th',
+ 'major 7th'
+] as const;
+
+const LETTER_PC: Record = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
+
+export const pitchClass = (midi: number): number => ((midi % 12) + 12) % 12;
+
+export const pitchClassName = (pc: number): string => PITCH_CLASS_NAMES[pitchClass(pc)];
+
+/** Semitone offset of an accidental string: '#', '##', 'b', 'bb', ''. */
+const accidentalOffset = (acc: string): number => {
+ let offset = 0;
+ for (const ch of acc) offset += ch === '#' ? 1 : -1;
+ return offset;
+};
+
+/** "C#", "Db", "e" -> pitch class; null when unparseable. */
+export const parsePitchClass = (name: string): number | null => {
+ const match = name.trim().match(/^([A-Ga-g])(#{1,2}|b{1,2})?$/);
+ if (!match) return null;
+ return pitchClass(LETTER_PC[match[1].toUpperCase()] + accidentalOffset(match[2] ?? ''));
+};
+
+/** "E2", "Bb2", "C#4" -> MIDI (C4 = 60); null when unparseable. Octave follows the letter (Cb4 = B3). */
+export const parseNoteName = (name: string): number | null => {
+ const match = name.trim().match(/^([A-Ga-g])(#{1,2}|b{1,2})?(-?\d+)$/);
+ if (!match) return null;
+ const octave = parseInt(match[3], 10);
+ return (octave + 1) * 12 + LETTER_PC[match[1].toUpperCase()] + accidentalOffset(match[2] ?? '');
+};
+
+export const nameToMidi = (name: string): number => {
+ const midi = parseNoteName(name);
+ if (midi === null) throw new Error(`Invalid note name: ${name}`);
+ return midi;
+};
+
+export const midiToName = (midi: number): string =>
+ `${PITCH_CLASS_NAMES[pitchClass(midi)]}${Math.floor(midi / 12) - 1}`;
+
+/** Interval of `pc` above `rootPc`, as a pitch class 0..11. */
+export const intervalFrom = (rootPc: number, pc: number): number => pitchClass(pc - rootPc);
+
+export const degreeLabel = (interval: number): string => DEGREE_LABELS[pitchClass(interval)];
+
+export const intervalName = (interval: number): string => INTERVAL_NAMES[pitchClass(interval)];
+
+/** Accepts a pitch class number or a note name; throws on invalid names. */
+export const toPitchClass = (root: number | string): number => {
+ if (typeof root === 'number') return pitchClass(root);
+ const pc = parsePitchClass(root);
+ if (pc === null) throw new Error(`Invalid root: ${root}`);
+ return pc;
+};
diff --git a/engine/playability.test.ts b/engine/playability.test.ts
new file mode 100644
index 0000000..dcfa2e3
--- /dev/null
+++ b/engine/playability.test.ts
@@ -0,0 +1,460 @@
+import { describe, it, expect } from 'vitest';
+import {
+ COMMON_SHAPES,
+ countInteriorMutes,
+ explainCost,
+ nonPhysicalCost,
+ noveltyScore,
+ scoreTotal,
+ scoreVoicing,
+ shapeConstraintViolation,
+ type ScoreContext
+} from './playability';
+import { findBestFingering } from './fingering';
+import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, allowedSpanMm, maxReachMm, profileFromCalibration } from './handProfile';
+import { fingertipMm } from './geometry';
+import { createRng } from './random';
+import { fromRiffForgeTab, interiorMutes, shapeKey } from './shape';
+import { E_STANDARD, DROP_D } from './tuning';
+import { fromLegacyNotes, getFamily, relaxFamily } from './voicingSpec';
+import { MUSICAL_TERMS, PHYSICAL_TERMS, PLAYABILITY_WEIGHTS, RIGHT_HAND_TERMS } from './weights';
+import type { CostBreakdown, CostTermKey, Fingering, HandProfile, Shape, VoicingFamily } from './types';
+
+const P = DEFAULT_HAND_PROFILE;
+const L = P.scaleLengthMm;
+const tip = (fret: number): number => fingertipMm(fret, L);
+
+const tab = (text: string): Shape => {
+ const shape = fromRiffForgeTab(text);
+ if (!shape) throw new Error(`bad tab ${text}`);
+ return shape;
+};
+
+const fingeringOf = (shape: Shape, profile: HandProfile = P): Fingering => {
+ const result = findBestFingering(shape, profile);
+ if (result.ok === false) throw new Error(`expected a fingering for ${shapeKey(shape)}: ${result.reason}`);
+ return result.fingering;
+};
+
+const fam = (id: string): VoicingFamily => {
+ const family = getFamily(id);
+ if (!family) throw new Error(`missing family ${id}`);
+ return family;
+};
+
+const ctxOf = (over: Partial = {}): ScoreContext => ({
+ tuning: E_STANDARD,
+ profile: P,
+ rootPc: 4,
+ distortion: true,
+ context: 'strum',
+ musical: true,
+ ...over
+});
+
+const score = (text: string, over: Partial = {}): CostBreakdown =>
+ scoreVoicing(tab(text), fingeringOf(tab(text)), ctxOf(over));
+
+const term = (b: CostBreakdown, key: CostTermKey) => {
+ const found = b.terms.find((t) => t.key === key);
+ if (!found) throw new Error(`missing term ${key}`);
+ return found;
+};
+
+/** Cost per term, for compact snapshot comparisons. */
+const costs = (b: CostBreakdown): Record =>
+ Object.fromEntries(b.terms.map((t) => [t.key, Math.round(t.cost * 1e6) / 1e6]));
+
+const ZERO_COSTS: Record = {
+ reach: 0,
+ pairStretch: 0,
+ fingerCount: 0,
+ pinky: 0,
+ contortion: 0,
+ interiorMutes: 0,
+ position: 0,
+ thumb: 0,
+ barre: 0,
+ stringSkips: 0,
+ chugStrings: 0,
+ lowMud: 0,
+ doubledThird: 0,
+ rootNotInBass: 0
+};
+
+const expectCosts = (b: CostBreakdown, expected: Partial>): void => {
+ const actual = costs(b);
+ const want = { ...ZERO_COSTS, ...expected };
+ for (const key of Object.keys(want) as CostTermKey[]) {
+ expect(actual[key], key).toBeCloseTo(want[key], 6);
+ }
+};
+
+const reachCost = (minFret: number, maxFret: number): number => {
+ const ratio = (tip(maxFret) - tip(minFret)) / allowedSpanMm(P, minFret);
+ return 3 * ratio * ratio;
+};
+
+describe('scoreVoicing structure', () => {
+ const shapes = ['0 2 2 1 0 0', '0 2 4 0 0 0', '0 2 2 x x x', '3 x 0 0 0 3', 'x 3 2 0 1 0', '3 0 x x x x'];
+
+ it('lists all 14 terms in weights.ts key order with buckets and weights from weights.ts', () => {
+ const keys = Object.keys(PLAYABILITY_WEIGHTS);
+ expect(keys).toEqual([...PHYSICAL_TERMS, ...RIGHT_HAND_TERMS, ...MUSICAL_TERMS]);
+ for (const text of shapes) {
+ for (const context of ['strum', 'chug'] as const) {
+ for (const musical of [true, false]) {
+ const b = score(text, { context, musical, family: fam('major') });
+ expect(b.terms.map((t) => t.key)).toEqual(keys);
+ for (const t of b.terms) {
+ expect(t.bucket).toBe(PLAYABILITY_WEIGHTS[t.key].bucket);
+ expect(t.weight).toBe(PLAYABILITY_WEIGHTS[t.key].weight);
+ expect(t.normalized).toBeGreaterThanOrEqual(0);
+ expect(t.normalized).toBeLessThanOrEqual(1);
+ expect(t.cost).toBeCloseTo(t.normalized * t.weight, 12);
+ }
+ }
+ }
+ }
+ });
+
+ it('sums buckets and total from the terms', () => {
+ for (const text of shapes) {
+ const b = score(text, { context: 'chug', family: fam('sus2') });
+ const sum = (bucket: string) => b.terms.filter((t) => t.bucket === bucket).reduce((s, t) => s + t.cost, 0);
+ expect(b.physical).toBeCloseTo(sum('physical'), 12);
+ expect(b.rightHand).toBeCloseTo(sum('rightHand'), 12);
+ expect(b.musical).toBeCloseTo(sum('musical'), 12);
+ expect(b.total).toBeCloseTo(b.physical + b.rightHand + b.musical, 12);
+ }
+ });
+
+ it('keeps the physical bucket identical to the fingering breakdown', () => {
+ for (const text of shapes) {
+ const f = fingeringOf(tab(text));
+ const b = scoreVoicing(tab(text), f, ctxOf());
+ expect(b.physical).toBe(f.costBreakdown.total);
+ expect(b.terms.slice(0, PHYSICAL_TERMS.length)).toEqual(f.costBreakdown.terms);
+ }
+ });
+
+ it('scoreTotal and nonPhysicalCost match scoreVoicing bit for bit', () => {
+ const rng = createRng('score-total');
+ let checked = 0;
+ for (let i = 0; i < 3000 && checked < 200; i++) {
+ const shape = Array.from({ length: 6 }, () => (rng.chance(0.35) ? null : rng.int(0, 9)));
+ const result = findBestFingering(shape, P);
+ if (result.ok === false) continue;
+ checked++;
+ for (const over of [
+ {},
+ { context: 'chug' as const },
+ { distortion: false },
+ { musical: false },
+ { rootPc: 0, family: fam('sus2') },
+ { rootPc: 7, family: fam('major'), context: 'chug' as const }
+ ]) {
+ const ctx = ctxOf(over);
+ const b = scoreVoicing(shape, result.fingering, ctx);
+ expect(scoreTotal(shape, result.fingering, ctx)).toBe(b.total);
+ expect(nonPhysicalCost(shape, ctx)).toBeCloseTo(b.rightHand + b.musical, 12);
+ }
+ }
+ expect(checked).toBe(200);
+ });
+
+ it('countInteriorMutes matches shape.ts interiorMutes', () => {
+ const rng = createRng('mutes');
+ for (let i = 0; i < 300; i++) {
+ const shape = Array.from({ length: rng.int(1, 8) }, () => (rng.chance(0.5) ? null : rng.int(0, 5)));
+ expect(countInteriorMutes(shape)).toBe(interiorMutes(shape));
+ }
+ });
+
+ it('does not alias the fingering terms', () => {
+ const f = fingeringOf(tab('0 2 2 1 0 0'));
+ const b = scoreVoicing(tab('0 2 2 1 0 0'), f, ctxOf());
+ b.terms[0].cost = 99;
+ expect(f.costBreakdown.terms[0].cost).not.toBe(99);
+ });
+});
+
+describe('scoreVoicing snapshots (E standard, default hand)', () => {
+ it('open E major 0 2 2 1 0 0', () => {
+ const base = { reach: reachCost(1, 2), fingerCount: 0.75, position: 0.3 / 12 };
+ expectCosts(score('0 2 2 1 0 0'), base);
+ expectCosts(score('0 2 2 1 0 0', { distortion: false }), base);
+ // chug: 0.5 * min(1, 5/5) + 0.5 * mean index 2.5 / 5 = 0.75, weight 0.5
+ expectCosts(score('0 2 2 1 0 0', { context: 'chug' }), { ...base, chugStrings: 0.375 });
+ expect(score('0 2 2 1 0 0').total).toBeCloseTo(reachCost(1, 2) + 0.75 + 0.025, 9);
+ });
+
+ it('Em(add9) 0 2 4 0 0 0 with the m(add9) family', () => {
+ const family = fam('m_add9');
+ const f = fingeringOf(tab('0 2 4 0 0 0'));
+ expect(f.fingers).toEqual([null, 1, 3, null, null, null]);
+ const base = { reach: reachCost(2, 4), fingerCount: 0.5, position: (0.3 * 2) / 12 };
+ expectCosts(score('0 2 4 0 0 0', { family }), base);
+ expectCosts(score('0 2 4 0 0 0', { family, distortion: false }), base);
+ expectCosts(score('0 2 4 0 0 0', { family, context: 'chug' }), { ...base, chugStrings: 0.375 });
+ });
+
+ it('power chord 0 2 2 x x x', () => {
+ const family = fam('power5');
+ const base = { fingerCount: 0.5, position: (0.3 * 2) / 12 };
+ expectCosts(score('0 2 2 x x x', { family }), base);
+ expectCosts(score('0 2 2 x x x', { family, distortion: false }), base);
+ // chug: 0.5 * (2/5) + 0.5 * (mean index 1 / 5) = 0.3, weight 0.5
+ expectCosts(score('0 2 2 x x x', { family, context: 'chug' }), { ...base, chugStrings: 0.15 });
+ });
+
+ it('G major with an interior mute 3 x 0 0 0 3', () => {
+ const ctx = { rootPc: 7, family: fam('major') };
+ // interiorMutes 1 -> 0.5 * 1.5; stringSkips 1 -> 0.5 * 2
+ const base = { fingerCount: 0.5, interiorMutes: 0.75, position: (0.3 * 3) / 12, stringSkips: 1 };
+ expectCosts(score('3 x 0 0 0 3', ctx), base);
+ expectCosts(score('3 x 0 0 0 3', { ...ctx, distortion: false }), base);
+ // chug: 0.5 * (4/5) + 0.5 * (mean index 14/5 / 5) = 0.68, weight 0.5
+ expectCosts(score('3 x 0 0 0 3', { ...ctx, context: 'chug' }), { ...base, chugStrings: 0.34 });
+ });
+
+ it('low-register mud only counts under distortion', () => {
+ // G2 (43) and A2 (45): both below E3, 2 semitones apart -> 1 pair -> 0.5 * 2
+ const ctx = { rootPc: 7 };
+ const muddy = score('3 0 x x x x', ctx);
+ expect(term(muddy, 'lowMud').raw).toBe(1);
+ expect(term(muddy, 'lowMud').cost).toBeCloseTo(1, 12);
+ const clean = score('3 0 x x x x', { ...ctx, distortion: false });
+ expect(term(clean, 'lowMud').raw).toBe(1);
+ expect(term(clean, 'lowMud').cost).toBe(0);
+ expect(clean.total).toBeCloseTo(muddy.total - 1, 12);
+ });
+
+ it('counts muddy pairs below E3 and ignores fifths, octaves and notes from E3 up', () => {
+ // G2 (43), A#2 (46), D3 (50): 43-46 and 46-50 are 3rds below E3, 43-50 is a 5th -> 2 pairs -> capped at 1
+ const b = score('3 1 0 x x x', { rootPc: 7 });
+ expect(term(b, 'lowMud').raw).toBe(2);
+ expect(term(b, 'lowMud').cost).toBeCloseTo(2, 12);
+ expect(term(score('0 2 2 x x x'), 'lowMud').raw).toBe(0);
+ // E3 (52) is not below the threshold
+ expect(term(score('x x 2 1 x x'), 'lowMud').raw).toBe(0);
+ // Drop D D2 A2 D3: a fifth, an octave and a fourth
+ const dropD = scoreVoicing(tab('0 0 0 x x x'), fingeringOf(tab('0 0 0 x x x')), ctxOf({ tuning: DROP_D, rootPc: 2 }));
+ expect(term(dropD, 'lowMud').raw).toBe(0);
+ });
+
+ it('costs a doubled third', () => {
+ // C major x 3 2 0 1 0: E3 and E4 -> one extra third
+ const b = score('x 3 2 0 1 0', { rootPc: 0, family: fam('major') });
+ expect(term(b, 'doubledThird').raw).toBe(1);
+ expect(term(b, 'doubledThird').cost).toBeCloseTo(0.5, 12);
+ expect(term(score('0 2 2 1 0 0'), 'doubledThird').raw).toBe(0);
+ });
+
+ it('costs a non-root bass only when the family prefers a bass interval', () => {
+ // Dsus2 over A: x 0 0 2 3 0 -> bass A is the fifth of D
+ const ctx = { rootPc: 2 };
+ expect(term(score('x 0 0 2 3 0', { ...ctx, family: fam('sus2') }), 'rootNotInBass').cost).toBeCloseTo(0.5, 12);
+ expect(term(score('x x 0 2 3 0', { ...ctx, family: fam('sus2') }), 'rootNotInBass').cost).toBe(0);
+ expect(term(score('x 0 0 2 3 0', { ...ctx }), 'rootNotInBass').cost).toBe(0);
+ const triad = score('x 0 0 2 3 2', { ...ctx, family: fam('major') });
+ expect(term(triad, 'rootNotInBass').cost).toBe(0);
+ });
+
+ it('never costs a root-position voicing as "root not in the bass" when the preferred bass is another degree', () => {
+ // A legacy C/G row relaxed to any bass keeps the 5th as its preferred bass
+ const slash = relaxFamily(fromLegacyNotes(['G2', 'C3', 'E3'], 'C'))[0].family;
+ expect(slash.bass).toBe('any');
+ expect(slash.preferredBass).toBe(7);
+ const ctx = { rootPc: 0, family: slash };
+ // x 3 2 0 x x = C3 E3 G3: the root is in the bass
+ expect(term(score('x 3 2 0 x x', ctx), 'rootNotInBass').cost).toBe(0);
+ // x x 5 5 5 x = G3 C4 E4: the root is not in the bass, but this family never asked for it there
+ expect(term(score('x x 5 5 5 x', ctx), 'rootNotInBass').cost).toBe(0);
+ });
+
+ it('drops the whole musical bucket when musical is off', () => {
+ const on = score('x 3 2 0 1 0', { rootPc: 0, family: fam('sus2') });
+ const off = score('x 3 2 0 1 0', { rootPc: 0, family: fam('sus2'), musical: false });
+ expect(on.musical).toBeGreaterThan(0);
+ expect(off.musical).toBe(0);
+ expect(off.total).toBeCloseTo(off.physical + off.rightHand, 12);
+ for (const key of MUSICAL_TERMS) expect(term(off, key).cost).toBe(0);
+ });
+});
+
+describe('COMMON_SHAPES', () => {
+ it('holds valid, unique 6-string shapes including the open chords and movable forms', () => {
+ const keys = COMMON_SHAPES.map(shapeKey);
+ expect(new Set(keys).size).toBe(keys.length);
+ for (const shape of COMMON_SHAPES) {
+ expect(shape.length).toBe(6);
+ expect(shape.some((f) => f !== null)).toBe(true);
+ }
+ for (const text of ['0 2 2 1 0 0', 'x 3 2 0 1 0', '3 2 0 0 0 3', 'x x 0 2 3 2', '1 3 3 x x x', '1 3 3 2 1 1', 'x 1 3 3 2 1']) {
+ expect(keys).toContain(text);
+ }
+ });
+});
+
+describe('noveltyScore', () => {
+ it('is 0 for open E major and every common shape', () => {
+ expect(noveltyScore(tab('0 2 2 1 0 0'))).toBe(0);
+ for (const shape of COMMON_SHAPES) expect(noveltyScore(shape)).toBe(0);
+ });
+
+ it('matches movable references at any position', () => {
+ expect(noveltyScore(tab('5 7 7 x x x'))).toBe(0);
+ expect(noveltyScore(tab('x 7 9 9 8 7'))).toBe(0);
+ });
+
+ it('matches open-string references only as written', () => {
+ expect(noveltyScore(tab('x 3 2 0 1 0'))).toBe(0);
+ expect(noveltyScore(tab('x 5 4 0 3 0'))).toBeGreaterThan(0);
+ });
+
+ it('scores a weird cluster high', () => {
+ // open strings ringing under a high fretted cluster
+ expect(noveltyScore(tab('0 x 9 7 0 8'))).toBeGreaterThanOrEqual(0.5);
+ expect(noveltyScore(tab('x 9 8 x 7 x'))).toBeGreaterThan(0.4);
+ // close to the root-5 dominant shell x 1 x 1 3 x
+ expect(noveltyScore(tab('x 9 x 8 10 x'))).toBeCloseTo(0.5 / 6, 12);
+ });
+
+ it('uses the signature distance: one string one fret off a reference costs 0.5 / 6', () => {
+ const weird = tab('x 9 8 x 7 x');
+ expect(noveltyScore(tab('x 10 8 x 7 x'), [weird])).toBeCloseTo(0.5 / 6, 12);
+ expect(noveltyScore(tab('x 10 8 x 7 x'), [tab('x 4 3 x 2 x')])).toBeCloseTo(0.5 / 6, 12);
+ // two frets off saturates at 1 for that string; a muted vs sounding string is 1
+ expect(noveltyScore(tab('x 11 8 x 7 x'), [weird])).toBeCloseTo(1 / 6, 12);
+ expect(noveltyScore(tab('x 9 8 x 7 7'), [weird])).toBeCloseTo(1 / 6, 12);
+ // open-string references compare frets as written
+ expect(noveltyScore(tab('0 x 9 7 0 9'), [tab('0 x 9 7 0 8')])).toBeCloseTo(0.5 / 6, 12);
+ });
+
+ it('treats extra reference shapes as known', () => {
+ const weird = tab('x 9 8 x 7 x');
+ expect(noveltyScore(weird, [weird])).toBe(0);
+ expect(noveltyScore(weird, [tab('x 4 3 x 2 x')])).toBe(0);
+ expect(noveltyScore(tab('0 x 9 7 0 8'), [tab('0 x 4 2 0 3')])).toBeGreaterThan(0);
+ });
+
+ it('compares extended-range shapes against references aligned to the top strings', () => {
+ // 7-string B E A D G B E: open E major with the low B muted is the open E major shape
+ expect(noveltyScore([null, 0, 2, 2, 1, 0, 0])).toBe(0);
+ expect(noveltyScore([null, null, 3, 2, 0, 1, 0])).toBe(0);
+ // movable references keep matching at any position
+ expect(noveltyScore([null, 5, 7, 7, null, null, null])).toBe(0);
+ // a sounding low string the reference does not have counts as one differing string
+ expect(noveltyScore([0, 0, 2, 2, 1, 0, 0])).toBeCloseTo(1 / 7, 12);
+ expect(noveltyScore([null, null, 0, 2, 2, 1, 0, 0])).toBe(0);
+ // extra references of another length are aligned the same way
+ expect(noveltyScore([null, 9, 8, null, 7, null, null], [tab('9 8 x 7 x x')])).toBe(0);
+ });
+
+ it('stays within 0..1 and is deterministic', () => {
+ const rng = createRng('novelty');
+ for (let i = 0; i < 300; i++) {
+ const shape = Array.from({ length: 6 }, () => (rng.chance(0.3) ? null : rng.int(0, 15)));
+ if (shape.every((f) => f === null)) continue;
+ const n = noveltyScore(shape);
+ expect(n).toBeGreaterThanOrEqual(0);
+ expect(n).toBeLessThanOrEqual(1);
+ expect(noveltyScore(shape)).toBe(n);
+ }
+ });
+});
+
+describe('explainCost', () => {
+ const explain = (text: string, over: Partial = {}): string[] => {
+ const profile = over.profile ?? P;
+ const f = fingeringOf(tab(text), profile);
+ return explainCost(scoreVoicing(tab(text), f, ctxOf(over)), f, profile);
+ };
+
+ it('describes open E major in short phrases', () => {
+ const lines = explain('0 2 2 1 0 0');
+ expect(lines[0]).toBe('3 fingers');
+ const ratio = (tip(2) - tip(1)) / allowedSpanMm(P, 1);
+ expect(lines).toContain(`reach ${Math.round(ratio * 100)} percent of your comfort`);
+ expect(lines.some((l) => l.includes('pinky'))).toBe(false);
+ });
+
+ it('measures reach against the comfortable reach, not the stretch allowance', () => {
+ const stretchy = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true });
+ expect(stretchy.stretchTolerance).toBeGreaterThan(1);
+ // C4 + C#5 (b2 dyad on C): 102 mm at fret 5, inside the 1.1 stretch allowance but beyond the 93.5 mm comfort
+ const span = tip(9) - tip(5);
+ expect(span).toBeGreaterThan(maxReachMm(stretchy, 5));
+ expect(span).toBeLessThanOrEqual(allowedSpanMm(stretchy, 5));
+ const lines = explain('x x x 5 x 9', { profile: stretchy, rootPc: 0 });
+ const pct = Math.round((span / maxReachMm(stretchy, 5)) * 100);
+ expect(pct).toBe(109);
+ expect(lines).toContain(`reach ${pct} percent of your comfort (a stretch)`);
+ // 96 mm at fret 6 against 92 mm of comfort: printed 95 percent before, 104 percent now
+ expect(explain('x x 10 6 x x', { profile: stretchy, rootPc: 0 })).toContain('reach 104 percent of your comfort (a stretch)');
+ // inside the comfortable reach the phrase stays plain
+ expect(explain('x x 7 6 x x', { profile: stretchy, rootPc: 0 }).find((l) => l.startsWith('reach'))).toMatch(
+ /^reach \d+ percent of your comfort$/
+ );
+ });
+
+ it('without a profile, names the allowed reach instead of claiming comfort', () => {
+ const stretchy = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true });
+ const f = fingeringOf(tab('x x x 5 x 9'), stretchy);
+ const lines = explainCost(scoreVoicing(tab('x x x 5 x 9'), f, ctxOf({ profile: stretchy, rootPc: 0 })), f);
+ expect(lines).toContain(`reach ${Math.round(f.metrics.reachRatio * 100)} percent of your allowed reach`);
+ expect(lines.some((l) => l.includes('comfort'))).toBe(false);
+ });
+
+ it('does not report "root not in the bass" for a root-position voicing of a slash-bass recipe', () => {
+ const slash = relaxFamily(fromLegacyNotes(['G2', 'C3', 'E3'], 'C'))[0].family;
+ expect(explain('x 3 2 0 x x', { rootPc: 0, family: slash })).not.toContain('root not in the bass');
+ });
+
+ it('names interior mutes, skipped strings, pinky, mud, doubled thirds and the bass', () => {
+ expect(explain('3 x 0 0 0 3', { rootPc: 7 })).toEqual(expect.arrayContaining(['2 fingers', '1 interior mute', '1 skipped string for the picking hand']));
+ expect(explain('3 0 x x x x', { rootPc: 7 })).toContain('1 muddy low interval under gain');
+ expect(explain('x 3 2 0 1 0', { rootPc: 0 })).toContain('doubled third');
+ expect(explain('x 0 0 2 3 0', { rootPc: 2, family: fam('sus2') })).toContain('root not in the bass');
+ expect(explain('x 2 4 4 x x', { rootPc: 11 })).toEqual(expect.arrayContaining(['3 fingers', 'uses pinky']));
+ expect(explain('0 0 0 0 0 0')).toContain('open strings only');
+ });
+
+ it('never claims a voicing is safe', () => {
+ const rng = createRng('explain');
+ let checked = 0;
+ for (let i = 0; i < 400 && checked < 150; i++) {
+ const shape = Array.from({ length: 6 }, () => (rng.chance(0.4) ? null : rng.int(0, 7)));
+ const result = findBestFingering(shape, P);
+ if (result.ok === false) continue;
+ for (const context of ['strum', 'chug'] as const) {
+ const lines = explainCost(scoreVoicing(shape, result.fingering, ctxOf({ context })), result.fingering);
+ for (const line of lines) {
+ expect(line.toLowerCase()).not.toContain('safe');
+ expect(line.length).toBeGreaterThan(0);
+ }
+ }
+ checked++;
+ }
+ expect(checked).toBeGreaterThan(50);
+ });
+});
+
+describe('shapeConstraintViolation', () => {
+ it('returns null for shapes inside the profile', () => {
+ expect(shapeConstraintViolation(tab('0 2 2 1 0 0'), P)).toBeNull();
+ expect(shapeConstraintViolation(tab('x 15 x x x x'), P)).toBeNull();
+ expect(shapeConstraintViolation(tab('3 x 0 0 0 3'), P)).toBeNull();
+ });
+
+ it('flags max fret, open strings and interior mutes, in that order', () => {
+ const closed = { ...P, allowOpenStrings: false };
+ expect(shapeConstraintViolation(tab('x 16 x x x x'), P)).toBe('MAX_FRET');
+ expect(shapeConstraintViolation(tab('0 2 2 1 0 0'), closed)).toBe('OPEN_STRINGS');
+ expect(shapeConstraintViolation(tab('0 x x 2 x x'), P)).toBe('INTERIOR_MUTES');
+ expect(shapeConstraintViolation(tab('0 x x 2 x x'), { ...P, maxInteriorMutes: 2 })).toBeNull();
+ expect(shapeConstraintViolation(tab('0 x x 16 x x'), closed)).toBe('MAX_FRET');
+ expect(shapeConstraintViolation(tab('0 x x 2 x x'), closed)).toBe('OPEN_STRINGS');
+ });
+});
diff --git a/engine/playability.ts b/engine/playability.ts
new file mode 100644
index 0000000..9a66996
--- /dev/null
+++ b/engine/playability.ts
@@ -0,0 +1,457 @@
+// Playability cost of a fingered voicing: the physical bucket comes from the fingering, this module adds the
+// right-hand and musical buckets. Novelty is a separate ranking axis and never part of the difficulty.
+import type {
+ CostBreakdown,
+ CostTerm,
+ CostTermKey,
+ Fingering,
+ HandProfile,
+ Shape,
+ Tuning,
+ VoicingFamily
+} from './types';
+import { FINGER_NAMES, physicalCostBreakdown } from './fingering';
+import { intervalFrom, pitchClass } from './pitch';
+import { fromRiffForgeTab, shapeToMidiByString } from './shape';
+import { LOW_MUD_THRESHOLD_MIDI, PLAYABILITY_WEIGHTS } from './weights';
+
+export interface ScoreContext {
+ tuning: Tuning;
+ profile: HandProfile;
+ rootPc: number;
+ family?: VoicingFamily;
+ /** High-gain context: enables the low-register mud term. */
+ distortion: boolean;
+ /** 'chug' favours fewer, lower strings for palm-muted playing. */
+ context: 'strum' | 'chug';
+ /** Include the musical bucket. */
+ musical: boolean;
+}
+
+/** Semitone distances (1..4) that turn to mud when both notes sit below LOW_MUD_THRESHOLD_MIDI. */
+const MUD_MAX_SEMITONES = 4;
+
+const TERM_KEYS = Object.keys(PLAYABILITY_WEIGHTS) as CostTermKey[];
+
+const clamp01 = (x: number): number => (x > 1 ? 1 : x > 0 ? x : 0);
+
+/** shape.ts interiorMutes without allocating: muted strings strictly between the lowest and highest sounding ones. */
+export const countInteriorMutes = (shape: Shape): number => {
+ let lo = -1;
+ let hi = -1;
+ for (let s = 0; s < shape.length; s++) {
+ if (shape[s] === null) continue;
+ if (lo < 0) lo = s;
+ hi = s;
+ }
+ let count = 0;
+ for (let s = lo + 1; s < hi; s++) if (shape[s] === null) count++;
+ return count;
+};
+
+const makeTerm = (key: CostTermKey, raw: number, normalized: number): CostTerm => {
+ const { bucket, weight } = PLAYABILITY_WEIGHTS[key];
+ return { key, bucket, raw, normalized, weight, cost: normalized * weight };
+};
+
+/** Note pairs below the mud threshold that are 1..4 semitones apart. */
+const lowMudPairs = (notes: readonly number[]): number => {
+ let pairs = 0;
+ for (let i = 0; i < notes.length; i++) {
+ if (notes[i] >= LOW_MUD_THRESHOLD_MIDI) continue;
+ for (let j = i + 1; j < notes.length; j++) {
+ if (notes[j] >= LOW_MUD_THRESHOLD_MIDI) continue;
+ const d = Math.abs(notes[i] - notes[j]);
+ if (d >= 1 && d <= MUD_MAX_SEMITONES) pairs++;
+ }
+ }
+ return pairs;
+};
+
+/** Extra copies of the most doubled third (minor or major), 0 when no third repeats. */
+const extraThirds = (notes: readonly number[], rootPc: number): number => {
+ let minor = 0;
+ let major = 0;
+ for (const midi of notes) {
+ const interval = intervalFrom(rootPc, pitchClass(midi));
+ if (interval === 3) minor++;
+ else if (interval === 4) major++;
+ }
+ return Math.max(0, Math.max(minor, major) - 1);
+};
+
+/** Raw and normalized values of the right-hand and musical terms, in weights.ts key order. */
+interface HandAndMusic {
+ skips: number;
+ skipsN: number;
+ chugRaw: number;
+ chugN: number;
+ mud: number;
+ mudN: number;
+ thirds: number;
+ thirdsN: number;
+ wrongBass: number;
+ wrongBassN: number;
+}
+
+const measureHandAndMusic = (shape: Shape, ctx: ScoreContext): HandAndMusic => {
+ const byString = shapeToMidiByString(shape, ctx.tuning);
+ const notes: number[] = [];
+ let stringIndexSum = 0;
+ let bass = Infinity;
+ for (let s = 0; s < byString.length; s++) {
+ const midi = byString[s];
+ if (midi === null) continue;
+ notes.push(midi);
+ stringIndexSum += s;
+ if (midi < bass) bass = midi;
+ }
+ const sounding = notes.length;
+ const strings = shape.length;
+
+ const skips = countInteriorMutes(shape);
+ const chug =
+ ctx.context === 'chug' && sounding > 0
+ ? 0.5 * clamp01((sounding - 1) / Math.max(1, strings - 1)) + 0.5 * ((stringIndexSum / sounding) / Math.max(1, strings - 1))
+ : 0;
+
+ const mud = lowMudPairs(notes);
+ const thirds = extraThirds(notes, ctx.rootPc);
+ // Only a family that prefers the root in the bass is charged: a relaxed slash-bass recipe (preferred bass on
+ // another degree) has no term of its own, and calling its root-position voicings "root not in the bass" is false.
+ const preferred = ctx.family?.preferredBass;
+ const wrongBass =
+ preferred !== undefined && pitchClass(preferred) === 0 && sounding > 0 && intervalFrom(ctx.rootPc, pitchClass(bass)) !== 0 ? 1 : 0;
+ const musical = ctx.musical;
+
+ return {
+ skips,
+ skipsN: clamp01(skips / 2),
+ chugRaw: ctx.context === 'chug' ? sounding : 0,
+ chugN: clamp01(chug),
+ mud,
+ mudN: musical && ctx.distortion ? clamp01(mud / 2) : 0,
+ thirds,
+ thirdsN: musical ? clamp01(thirds) : 0,
+ wrongBass,
+ wrongBassN: musical ? wrongBass : 0
+ };
+};
+
+const rightHandAndMusicalTerms = (shape: Shape, ctx: ScoreContext): CostTerm[] => {
+ const m = measureHandAndMusic(shape, ctx);
+ return [
+ makeTerm('stringSkips', m.skips, m.skipsN),
+ makeTerm('chugStrings', m.chugRaw, m.chugN),
+ makeTerm('lowMud', m.mud, m.mudN),
+ makeTerm('doubledThird', m.thirds, m.thirdsN),
+ makeTerm('rootNotInBass', m.wrongBass, m.wrongBassN)
+ ];
+};
+
+const W = PLAYABILITY_WEIGHTS;
+
+const rightHandCost = (m: HandAndMusic): number => {
+ let rightHand = 0;
+ rightHand += m.skipsN * W.stringSkips.weight;
+ rightHand += m.chugN * W.chugStrings.weight;
+ return rightHand;
+};
+
+const musicalCost = (m: HandAndMusic): number => {
+ let musical = 0;
+ musical += m.mudN * W.lowMud.weight;
+ musical += m.thirdsN * W.doubledThird.weight;
+ musical += m.wrongBassN * W.rootNotInBass.weight;
+ return musical;
+};
+
+/** The right-hand and musical buckets of scoreVoicing: the part of the cost that needs no fingering. */
+export const nonPhysicalCost = (shape: Shape, ctx: ScoreContext): number => {
+ const m = measureHandAndMusic(shape, ctx);
+ return rightHandCost(m) + musicalCost(m);
+};
+
+/**
+ * scoreVoicing(...).total without building the breakdown: the same terms summed in the same order, so the result
+ * is bit-identical. Used to rank thousands of candidates; only the chosen ones get a full breakdown.
+ */
+export const scoreTotal = (shape: Shape, fingering: Fingering, ctx: ScoreContext): number => {
+ const fromFingering = fingering.costBreakdown.terms;
+ const physicalTerms =
+ fromFingering.length > 0
+ ? fromFingering
+ : physicalCostBreakdown(shape, fingering.fingers, fingering.barres, fingering.metrics).terms;
+ let physical = 0;
+ for (const key of TERM_KEYS) {
+ if (W[key].bucket !== 'physical') continue;
+ for (const t of physicalTerms) {
+ if (t.key === key) {
+ physical += t.cost;
+ break;
+ }
+ }
+ }
+ const m = measureHandAndMusic(shape, ctx);
+ return physical + rightHandCost(m) + musicalCost(m);
+};
+
+/**
+ * Full breakdown: the 9 physical terms copied from the fingering, then right-hand and musical terms, always all
+ * 14 in weights.ts key order. Musical terms switched off (musical false, or lowMud without distortion) keep their
+ * measured raw value with normalized 0 and cost 0; chugStrings is measured only in the 'chug' context.
+ */
+export const scoreVoicing = (shape: Shape, fingering: Fingering, ctx: ScoreContext): CostBreakdown => {
+ const fromFingering = fingering.costBreakdown.terms;
+ const physicalTerms =
+ fromFingering.length > 0
+ ? fromFingering
+ : physicalCostBreakdown(shape, fingering.fingers, fingering.barres, fingering.metrics).terms;
+ const byKey = new Map();
+ for (const t of physicalTerms) byKey.set(t.key, t);
+ for (const t of rightHandAndMusicalTerms(shape, ctx)) byKey.set(t.key, t);
+
+ const terms: CostTerm[] = [];
+ let physical = 0;
+ let rightHand = 0;
+ let musical = 0;
+ for (const key of TERM_KEYS) {
+ const source = byKey.get(key) ?? makeTerm(key, 0, 0);
+ const t: CostTerm = { ...source };
+ terms.push(t);
+ if (t.bucket === 'physical') physical += t.cost;
+ else if (t.bucket === 'rightHand') rightHand += t.cost;
+ else musical += t.cost;
+ }
+ return { total: physical + rightHand + musical, physical, rightHand, musical, terms };
+};
+
+// ---------------------------------------------------------------------------
+// Novelty
+// ---------------------------------------------------------------------------
+
+const COMMON_TABS: readonly string[] = [
+ // Open chords, E standard
+ '0 2 2 1 0 0', // E
+ '0 2 2 0 0 0', // Em
+ '0 2 0 1 0 0', // E7
+ '0 2 0 0 0 0', // Em7
+ '0 2 2 2 0 0', // Esus4
+ 'x 0 2 2 2 0', // A
+ 'x 0 2 2 1 0', // Am
+ 'x 0 2 0 2 0', // A7
+ 'x 0 2 0 1 0', // Am7
+ 'x 0 2 1 2 0', // Amaj7
+ 'x 0 2 2 0 0', // Asus2
+ 'x 0 2 2 3 0', // Asus4
+ 'x 3 2 0 1 0', // C
+ 'x 3 2 0 0 0', // Cmaj7
+ 'x 3 2 3 1 0', // C7
+ 'x 3 2 0 3 0', // Cadd9
+ 'x x 0 2 3 2', // D
+ 'x x 0 2 3 1', // Dm
+ 'x x 0 2 1 2', // D7
+ 'x x 0 2 2 2', // Dmaj7
+ 'x x 0 2 3 0', // Dsus2
+ 'x x 0 2 3 3', // Dsus4
+ '3 2 0 0 0 3', // G
+ '3 2 0 0 3 3', // G (rock)
+ '3 2 0 0 0 1', // G7
+ 'x x 3 2 1 1', // small F
+ 'x x 3 2 1 0', // Fmaj7
+ 'x 2 1 2 0 2', // B7
+ // Open power chords and drop-tuning one-finger power chord as written
+ '0 2 2 x x x', // E5
+ 'x 0 2 2 x x', // A5
+ 'x x 0 2 3 x', // D5
+ '0 0 0 x x x', // drop-D 5
+ // Movable power chords and octaves
+ '1 3 3 x x x',
+ 'x 1 3 3 x x',
+ 'x x 1 3 3 x',
+ '1 3 x x x x',
+ 'x 1 3 x x x',
+ 'x x 1 3 x x',
+ '1 1 1 x x x', // drop-tuning one-finger power chord
+ '1 x 3 x x x',
+ 'x 1 x 3 x x',
+ 'x x 1 x 4 x',
+ 'x x x 1 x 4',
+ // CAGED barre forms
+ '1 3 3 2 1 1', // E shape
+ '1 3 3 1 1 1', // E-shape minor
+ '1 3 1 2 1 1', // E-shape 7
+ '1 3 1 1 1 1', // E-shape m7
+ 'x 1 3 3 3 1', // A shape
+ 'x 1 3 3 2 1', // A-shape minor
+ 'x 1 3 1 3 1', // A-shape 7
+ 'x 1 3 1 2 1', // A-shape m7
+ 'x 4 3 1 2 1', // C shape
+ 'x x 1 3 4 3', // D shape
+ 'x x 1 3 4 2', // D-shape minor
+ '4 3 1 1 1 4', // G shape
+ // Common shells
+ '1 x 1 2 x x', // 7 shell, root on string 0
+ '1 x 1 1 x x', // m7 shell
+ '1 x 2 2 x x', // maj7 shell
+ 'x 1 x 1 3 x', // 7 shell, root on string 1
+ 'x 1 x 1 2 x', // m7 shell
+ 'x 1 x 2 3 x' // maj7 shell
+];
+
+const parseCommon = (text: string): Shape => {
+ const shape = fromRiffForgeTab(text);
+ if (!shape) throw new Error(`Invalid common shape ${text}`);
+ return Object.freeze(shape);
+};
+
+/**
+ * E-standard open chords and common movable shapes (power chords, octaves, CAGED forms, shells). Shapes without
+ * open strings match at any position; shapes with open strings match only as written.
+ */
+export const COMMON_SHAPES: readonly Shape[] = Object.freeze(COMMON_TABS.map(parseCommon));
+
+const MUTED = -2;
+const OPEN = -1;
+
+/** Per string: -2 muted, -1 open, otherwise the fret minus the lowest fretted fret, or the fret itself when absolute. */
+const signatureOf = (shape: Shape, absolute: boolean): Int16Array => {
+ let min = Infinity;
+ for (const f of shape) if (f !== null && f > 0 && f < min) min = f;
+ const offset = absolute || min === Infinity ? 0 : min;
+ const values = new Int16Array(shape.length);
+ for (let s = 0; s < shape.length; s++) {
+ const f = shape[s];
+ values[s] = f === null ? MUTED : f === 0 ? OPEN : f - offset;
+ }
+ return values;
+};
+
+/** A reference shape reduced to its signature. References with open strings compare frets as written. */
+export interface PreparedReference {
+ absolute: boolean;
+ signature: Int16Array;
+}
+
+const isUsableShape = (shape: unknown): shape is Shape =>
+ Array.isArray(shape) &&
+ shape.some((f) => f !== null) &&
+ shape.every((f) => f === null || (Number.isInteger(f) && (f as number) >= 0));
+
+/** Reduces reference shapes once so novelty can be computed for many candidates; malformed or silent shapes are skipped. */
+export const prepareReferences = (shapes: readonly Shape[]): PreparedReference[] =>
+ shapes.filter(isUsableShape).map((shape) => {
+ const absolute = shape.some((f) => f === 0);
+ return { absolute, signature: signatureOf(shape, absolute) };
+ });
+
+const COMMON_REFERENCES: readonly PreparedReference[] = prepareReferences(COMMON_SHAPES);
+
+/**
+ * Signatures of different lengths are aligned at their highest string and the shorter one is padded with muted low
+ * strings: the top six strings of a 7- or 8-string guitar are the strings the 6-string references were written for.
+ */
+const signatureDistance = (a: Int16Array, b: Int16Array): number => {
+ const n = a.length > b.length ? a.length : b.length;
+ const padA = n - a.length;
+ const padB = n - b.length;
+ let sum = 0;
+ for (let s = 0; s < n; s++) {
+ const x = s < padA ? MUTED : a[s - padA];
+ const y = s < padB ? MUTED : b[s - padB];
+ if (x === y) continue;
+ if (x >= 0 && y >= 0) {
+ const d = Math.abs(x - y) / 2;
+ sum += d > 1 ? 1 : d;
+ } else {
+ sum += 1;
+ }
+ }
+ return n === 0 ? 1 : sum / n;
+};
+
+/** Novelty against prepared references; COMMON_SHAPES are always included. */
+export const noveltyAgainst = (shape: Shape, extra: readonly PreparedReference[] = []): number => {
+ const relative = signatureOf(shape, false);
+ const absolute = signatureOf(shape, true);
+ let best = 1;
+ for (const list of [COMMON_REFERENCES, extra]) {
+ for (const ref of list) {
+ const d = signatureDistance(ref.absolute ? absolute : relative, ref.signature);
+ if (d < best) best = d;
+ if (best === 0) return 0;
+ }
+ }
+ return best;
+};
+
+/**
+ * 0..1 distance from the nearest common shape or reference: per string, equal => 0, both fretted => min(1, |d| / 2)
+ * on frets relative to each shape's lowest fret (as written for references with open strings), otherwise 1;
+ * averaged over strings. Shapes and references of different string counts are aligned at the highest string.
+ */
+export const noveltyScore = (shape: Shape, references: readonly Shape[] = []): number =>
+ noveltyAgainst(shape, prepareReferences(references));
+
+// ---------------------------------------------------------------------------
+// Explanations and profile constraints
+// ---------------------------------------------------------------------------
+
+const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`;
+
+/** Position is only worth mentioning from this fret up. */
+const POSITION_NOTE_FRET = 5;
+
+/**
+ * The span as a share of the comfortable reach (the calibrated index -> pinky reach). The fingering measures it
+ * against the allowed span, which includes the stretch allowance, so without the profile only that can be named.
+ */
+const reachPhrase = (metrics: Fingering['metrics'], profile?: HandProfile): string => {
+ if (!profile || !(profile.stretchTolerance > 0)) {
+ return `reach ${Math.round(metrics.reachRatio * 100)} percent of your allowed reach`;
+ }
+ const comfortMm = metrics.allowedSpanMm / profile.stretchTolerance;
+ const pct = Math.round((metrics.spanMm / comfortMm) * 100);
+ return `reach ${pct} percent of your comfort${pct > 100 ? ' (a stretch)' : ''}`;
+};
+
+/**
+ * Short human phrases for the UI. Comfort guidance only: phrases describe effort, never safety. Pass the hand
+ * profile the fingering was found with so reach is reported against the comfortable reach.
+ */
+export const explainCost = (breakdown: CostBreakdown, fingering: Fingering, profile?: HandProfile): string[] => {
+ const { metrics } = fingering;
+ const cost = (key: CostTermKey): number => breakdown.terms.find((t) => t.key === key)?.cost ?? 0;
+ const raw = (key: CostTermKey): number => breakdown.terms.find((t) => t.key === key)?.raw ?? 0;
+ const lines: string[] = [];
+
+ lines.push(metrics.fingerCount === 0 ? 'open strings only' : plural(metrics.fingerCount, 'finger'));
+ if (metrics.reachRatio > 0) lines.push(reachPhrase(metrics, profile));
+ if (metrics.maxPairStretch > 0) {
+ const worst = metrics.pairs.reduce((a, b) => (b.stretch > a.stretch ? b : a));
+ lines.push(`${FINGER_NAMES[worst.from]}-${FINGER_NAMES[worst.to]} stretch ${Math.round(worst.stretch * 100)} percent toward your limit`);
+ }
+ if (metrics.usesPinky) lines.push('uses pinky');
+ if (metrics.usesThumb) lines.push('thumb frets the low string');
+ if (fingering.barres.length > 0) lines.push(plural(fingering.barres.length, 'barre'));
+ if (metrics.contortions > 0) lines.push(plural(metrics.contortions, 'same-fret finger crossing'));
+ if (metrics.interiorMutes > 0) lines.push(plural(metrics.interiorMutes, 'interior mute'));
+ if (metrics.lowestFret !== null && metrics.lowestFret >= POSITION_NOTE_FRET) lines.push(`position at fret ${metrics.lowestFret}`);
+ if (cost('stringSkips') > 0) lines.push(`${plural(raw('stringSkips'), 'skipped string')} for the picking hand`);
+ if (cost('chugStrings') > 0) lines.push(`${plural(raw('chugStrings'), 'string')} to palm-mute`);
+ if (cost('lowMud') > 0) lines.push(`${plural(raw('lowMud'), 'muddy low interval')} under gain`);
+ if (cost('doubledThird') > 0) lines.push('doubled third');
+ if (cost('rootNotInBass') > 0) lines.push('root not in the bass');
+ return lines;
+};
+
+/** Profile rules that are not about fingers, checked in this order. */
+export const shapeConstraintViolation = (
+ shape: Shape,
+ profile: HandProfile
+): 'MAX_FRET' | 'OPEN_STRINGS' | 'INTERIOR_MUTES' | null => {
+ if (shape.some((f) => f !== null && f > profile.maxFret)) return 'MAX_FRET';
+ if (!profile.allowOpenStrings && shape.some((f) => f === 0)) return 'OPEN_STRINGS';
+ if (countInteriorMutes(shape) > profile.maxInteriorMutes) return 'INTERIOR_MUTES';
+ return null;
+};
diff --git a/engine/random.test.ts b/engine/random.test.ts
new file mode 100644
index 0000000..4dc2156
--- /dev/null
+++ b/engine/random.test.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect } from 'vitest';
+import { createRng, mulberry32, seedFromString } from './random';
+
+describe('seedFromString', () => {
+ it('is stable and distinguishes inputs', () => {
+ expect(seedFromString('riff')).toBe(seedFromString('riff'));
+ expect(seedFromString('riff')).not.toBe(seedFromString('riffs'));
+ expect(seedFromString('')).toBe(0x811c9dc5);
+ });
+});
+
+describe('mulberry32', () => {
+ it('produces the same stream for the same seed, in [0, 1)', () => {
+ const a = mulberry32(42);
+ const b = mulberry32(42);
+ for (let i = 0; i < 1000; i++) {
+ const x = a();
+ expect(x).toBe(b());
+ expect(x).toBeGreaterThanOrEqual(0);
+ expect(x).toBeLessThan(1);
+ }
+ });
+});
+
+describe('createRng', () => {
+ it('is deterministic per seed string', () => {
+ const seq = (seed: string) => {
+ const rng = createRng(seed);
+ return Array.from({ length: 20 }, () => rng.int(0, 9));
+ };
+ expect(seq('gallop')).toEqual(seq('gallop'));
+ expect(seq('gallop')).not.toEqual(seq('chug'));
+ });
+
+ it('keeps int within inclusive bounds and covers them', () => {
+ const rng = createRng('bounds');
+ const seen = new Set();
+ for (let i = 0; i < 500; i++) {
+ const v = rng.int(1, 4);
+ expect(v).toBeGreaterThanOrEqual(1);
+ expect(v).toBeLessThanOrEqual(4);
+ seen.add(v);
+ }
+ expect([...seen].sort()).toEqual([1, 2, 3, 4]);
+ });
+
+ it('never picks zero-weight items unless all weights are zero', () => {
+ const rng = createRng('weights');
+ for (let i = 0; i < 200; i++) {
+ expect(rng.weighted(['a', 'b', 'c'], [0, 1, 0])).toBe('b');
+ }
+ expect(['a', 'b']).toContain(rng.weighted(['a', 'b'], [0, 0]));
+ });
+
+ it('shuffles without losing items and forks stable child streams', () => {
+ const rng = createRng('shuffle');
+ expect(rng.shuffle([1, 2, 3, 4, 5]).sort()).toEqual([1, 2, 3, 4, 5]);
+ const childA = createRng('parent').fork('bar-1');
+ const childB = createRng('parent').fork('bar-1');
+ expect(childA.next()).toBe(childB.next());
+ expect(createRng('parent').fork('bar-1').next()).not.toBe(createRng('parent').fork('bar-2').next());
+ });
+
+ it('throws when picking from an empty list', () => {
+ expect(() => createRng('x').pick([])).toThrow();
+ });
+});
diff --git a/engine/random.ts b/engine/random.ts
new file mode 100644
index 0000000..aaf2c55
--- /dev/null
+++ b/engine/random.ts
@@ -0,0 +1,78 @@
+// Seeded randomness. The engine never calls Math.random.
+
+/** FNV-1a 32-bit hash of a string, used to derive numeric seeds. */
+export const seedFromString = (input: string): number => {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < input.length; i++) {
+ hash ^= input.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193);
+ }
+ return hash >>> 0;
+};
+
+/** mulberry32: small, fast, good enough for musical choices. Returns floats in [0, 1). */
+export const mulberry32 = (seed: number): (() => number) => {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+};
+
+export interface Rng {
+ /** Float in [0, 1). */
+ next: () => number;
+ /** Integer in [min, max] inclusive. */
+ int: (min: number, max: number) => number;
+ /** True with probability p. */
+ chance: (p: number) => boolean;
+ pick: (items: readonly T[]) => T;
+ /** Pick by non-negative weights; falls back to uniform when all weights are 0. */
+ weighted: (items: readonly T[], weights: readonly number[]) => T;
+ shuffle: (items: readonly T[]) => T[];
+ /** Derive an independent child stream, stable for a given label. */
+ fork: (label: string) => Rng;
+}
+
+export const createRng = (seed: string | number): Rng => {
+ const numeric = typeof seed === 'number' ? seed >>> 0 : seedFromString(seed);
+ const next = mulberry32(numeric);
+
+ const rng: Rng = {
+ next,
+ int: (min, max) => {
+ const lo = Math.ceil(Math.min(min, max));
+ const hi = Math.floor(Math.max(min, max));
+ return lo + Math.floor(next() * (hi - lo + 1));
+ },
+ chance: (p) => next() < p,
+ pick: (items) => {
+ if (items.length === 0) throw new Error('pick from empty list');
+ return items[Math.floor(next() * items.length)];
+ },
+ weighted: (items, weights) => {
+ if (items.length === 0) throw new Error('weighted pick from empty list');
+ const total = weights.reduce((sum, w) => sum + Math.max(0, w || 0), 0);
+ if (total <= 0) return items[Math.floor(next() * items.length)];
+ let r = next() * total;
+ for (let i = 0; i < items.length; i++) {
+ r -= Math.max(0, weights[i] || 0);
+ if (r < 0) return items[i];
+ }
+ return items[items.length - 1];
+ },
+ shuffle: (items) => {
+ const out = [...items];
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = Math.floor(next() * (i + 1));
+ [out[i], out[j]] = [out[j], out[i]];
+ }
+ return out;
+ },
+ fork: (label) => createRng(seedFromString(`${numeric}:${label}`))
+ };
+ return rng;
+};
diff --git a/engine/rhythm/edit.test.ts b/engine/rhythm/edit.test.ts
new file mode 100644
index 0000000..dc0c003
--- /dev/null
+++ b/engine/rhythm/edit.test.ts
@@ -0,0 +1,265 @@
+import { describe, it, expect } from 'vitest';
+import { clearHit, cycleAccent, eventAtUnit, setSlotCount, toggleHit, togglePalmMute } from './edit';
+import { generateRhythm } from './generate';
+import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles';
+import { validateRhythm } from './validate';
+import { patternUnitCount } from './grid';
+import { createRng } from '../random';
+import type { HitTarget, RhythmEvent, RhythmParams, RhythmPattern } from '../types';
+
+const SLOT0: HitTarget = { kind: 'slot', slot: 0 };
+const SLOT1: HitTarget = { kind: 'slot', slot: 1 };
+const PEDAL: HitTarget = { kind: 'pedal' };
+const DYAD0: HitTarget = { kind: 'dyad', slot: 0 };
+
+const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({
+ tick,
+ durationTicks,
+ target,
+ palmMute: false,
+ accent: 0,
+ pick: (tick / 120) % 2 === 0 ? 'down' : 'up',
+ ...extra
+});
+
+/** One bar of 4/4: accented stab tied over beat 2, a pedal chug, then silence. */
+const sparse = (params: Partial = {}): RhythmPattern => ({
+ id: 'sparse',
+ name: 'Sparse',
+ seed: 'sparse',
+ meter: { numerator: 4, denominator: 4 },
+ bars: 1,
+ ppq: 480,
+ tags: [],
+ params: { ...defaultRhythmParams('chugEngine'), bars: 1, ...params },
+ events: [
+ ev(0, 480, SLOT0, { accent: 2 }),
+ ev(480, 240, SLOT0, { tie: true }),
+ ev(960, 120, PEDAL, { palmMute: true })
+ ]
+});
+
+const expectValid = (p: RhythmPattern) => expect(validateRhythm(p).errors).toEqual([]);
+
+/** One beat of 32nd tremolo on dyad 0, then a palm-muted pedal. */
+const tremolo = (): RhythmPattern => ({
+ ...sparse(),
+ id: 'tremolo',
+ events: [
+ ...[0, 60, 120, 180, 240, 300, 360, 420].map((tick, i) =>
+ ev(tick, 60, DYAD0, { accent: i === 0 ? 2 : 0, pick: i % 2 === 0 ? 'down' : 'up' })
+ ),
+ ev(480, 120, PEDAL, { palmMute: true })
+ ]
+});
+
+const inCell = (p: RhythmPattern, unit: number) => p.events.filter((e) => e.tick >= unit * 120 && e.tick < (unit + 1) * 120);
+
+describe('eventAtUnit', () => {
+ it('finds events by onset unit on the pattern grid', () => {
+ const p = sparse();
+ expect(eventAtUnit(p, 0)?.target).toEqual(SLOT0);
+ expect(eventAtUnit(p, 4)?.tie).toBe(true);
+ expect(eventAtUnit(p, 8)?.target).toEqual(PEDAL);
+ expect(eventAtUnit(p, 1)).toBeUndefined();
+ expect(eventAtUnit(p, 99)).toBeUndefined();
+ expect(eventAtUnit(p, -1)).toBeUndefined();
+ });
+});
+
+describe('toggleHit', () => {
+ it('adds a hit in an empty unit, ringing until the next onset or one beat', () => {
+ expectValid(sparse());
+ const p = toggleHit(sparse(), 12, SLOT1);
+ expectValid(p);
+ expect(eventAtUnit(p, 12)).toMatchObject({ tick: 1440, durationTicks: 480, target: SLOT1, accent: 0 });
+ const q = toggleHit(p, 10, PEDAL);
+ expect(eventAtUnit(q, 10)).toMatchObject({ durationTicks: 240, palmMute: true, pick: 'down' });
+ expectValid(q);
+ });
+
+ it('removes the hit when toggled again with the same target', () => {
+ const p = toggleHit(toggleHit(sparse(), 12, SLOT1), 12, SLOT1);
+ expect(p.events).toEqual(sparse().events);
+ });
+
+ it('replaces the target of an existing hit and keeps its tie following it', () => {
+ const p = toggleHit(sparse(), 0, SLOT1);
+ expectValid(p);
+ expect(eventAtUnit(p, 0)).toMatchObject({ target: SLOT1, accent: 2 });
+ expect(eventAtUnit(p, 4)).toMatchObject({ target: SLOT1, tie: true });
+ });
+
+ it('truncates a ringing event when a hit lands inside it and drops that event\'s ties', () => {
+ const p = toggleHit(sparse(), 2, PEDAL);
+ expectValid(p);
+ expect(eventAtUnit(p, 0)?.durationTicks).toBe(240);
+ expect(eventAtUnit(p, 2)).toMatchObject({ tick: 240, durationTicks: 240, palmMute: true });
+ expect(eventAtUnit(p, 4)).toBeUndefined();
+ expect(p.events.map((e) => e.tick)).toEqual([0, 240, 960]);
+ });
+
+ it('never hands a chord\'s sustain to a palm-muted hit inserted inside it', () => {
+ const base = generateRhythm(defaultRhythmParams('halftimeStomp'), 'a');
+ expect(base.events[1]).toMatchObject({ tick: 480, tie: true });
+ const p = toggleHit(base, 2, PEDAL);
+ expectValid(p);
+ expect(eventAtUnit(p, 2)).toMatchObject({ durationTicks: 240, palmMute: true });
+ expect(p.events.some((e) => e.tie && e.palmMute)).toBe(false);
+ expect(eventAtUnit(p, 4)).toBeUndefined();
+ });
+
+ it('turns a tie into a new attack for a different target and removes it for the same one', () => {
+ const attack = toggleHit(sparse(), 4, PEDAL);
+ expectValid(attack);
+ expect(eventAtUnit(attack, 4)?.tie).toBeUndefined();
+ expect(eventAtUnit(attack, 4)?.target).toEqual(PEDAL);
+ const removed = toggleHit(sparse(), 4, SLOT0);
+ expect(eventAtUnit(removed, 4)).toBeUndefined();
+ expectValid(removed);
+ });
+
+ it('ignores invalid units and slots outside slotCount', () => {
+ const base = sparse();
+ expect(toggleHit(base, 16, PEDAL).events).toEqual(base.events);
+ expect(toggleHit(base, 1.5, PEDAL).events).toEqual(base.events);
+ expect(toggleHit(base, 3, { kind: 'slot', slot: 2 }).events).toEqual(base.events);
+ });
+});
+
+describe('32nd tremolo cells', () => {
+ it('reports, clears and toggles every 32nd in a grid cell', () => {
+ const t = tremolo();
+ expectValid(t);
+ expect(eventAtUnit(t, 1)?.tick).toBe(120);
+ const cleared = clearHit(t, 1);
+ expectValid(cleared);
+ expect(inCell(cleared, 1)).toEqual([]);
+ expect(eventAtUnit(cleared, 1)).toBeUndefined();
+ const toggled = toggleHit(t, 1, DYAD0);
+ expectValid(toggled);
+ expect(inCell(toggled, 1)).toEqual([]);
+ });
+
+ it('retargets and palm-mutes the whole cell', () => {
+ const t = tremolo();
+ const retargeted = toggleHit(t, 1, SLOT1);
+ expectValid(retargeted);
+ expect(inCell(retargeted, 1).map((e) => e.target)).toEqual([SLOT1, SLOT1]);
+ const muted = togglePalmMute(t, 2);
+ expectValid(muted);
+ expect(inCell(muted, 2).map((e) => e.palmMute)).toEqual([true, true]);
+ });
+
+ it('finds a 32nd that sits between grid units', () => {
+ const t = { ...tremolo(), events: tremolo().events.filter((e) => e.tick !== 120) };
+ expectValid(t);
+ expect(eventAtUnit(t, 1)?.tick).toBe(180);
+ expect(inCell(clearHit(t, 1), 1)).toEqual([]);
+ expect(inCell(toggleHit(t, 1, DYAD0), 1)).toEqual([]);
+ });
+
+ it('keeps the tremolo pendulum after a cell is cleared', () => {
+ const p = clearHit(generateRhythm(defaultRhythmParams('tremoloWall'), 'riff-1'), 1);
+ expectValid(p);
+ expect(eventAtUnit(p, 1)).toBeUndefined();
+ expect(inCell(p, 1)).toEqual([]);
+ expect(p.events.filter((e) => e.tick % 120 === 0 && e.tick < 1920).every((e) => e.tick % 480 !== 0 || e.pick === 'down')).toBe(true);
+ });
+});
+
+describe('setSlotCount', () => {
+ const base = generateRhythm({ ...defaultRhythmParams('syncopatedStabs'), slotCount: 4 }, 'k1');
+ const slots = (p: RhythmPattern) => p.events.map((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? e.target.slot : -1));
+ const rhythm = (p: RhythmPattern) => p.events.map((e) => [e.tick, e.durationTicks, e.accent, e.palmMute, !!e.tie, e.target.kind]);
+
+ it('folds slots that no longer exist back into range, keeping the rhythm', () => {
+ expect(slots(base).some((s) => s >= 2)).toBe(true);
+ const reduced = setSlotCount(base, 2);
+ expectValid(reduced);
+ expect(reduced.params.slotCount).toBe(2);
+ expect(rhythm(reduced)).toEqual(rhythm(base));
+ expect(slots(reduced)).toEqual(slots(base).map((s) => (s < 0 ? s : s % 2)));
+ expect(setSlotCount(base, 4).events).toEqual(base.events);
+ expect(setSlotCount(base, 9).params.slotCount).toBe(4);
+ });
+
+ it('lets every edit helper recover a pattern whose slotCount was lowered by hand', () => {
+ const stale: RhythmPattern = { ...base, params: { ...base.params, slotCount: 2 } };
+ expect(validateRhythm(stale).ok).toBe(false);
+ expectValid(toggleHit(stale, 7, PEDAL));
+ expectValid(clearHit(stale, 3));
+ expectValid(cycleAccent(stale, 0));
+ expectValid(togglePalmMute(stale, 5));
+ });
+});
+
+describe('cycleAccent, togglePalmMute, clearHit', () => {
+ it('cycles accents 2 -> 0 -> 1 -> 2 and leaves ties alone', () => {
+ const a = cycleAccent(sparse(), 0);
+ expect(eventAtUnit(a, 0)?.accent).toBe(0);
+ expect(eventAtUnit(cycleAccent(a, 0), 0)?.accent).toBe(1);
+ expect(eventAtUnit(cycleAccent(cycleAccent(a, 0), 0), 0)?.accent).toBe(2);
+ expect(cycleAccent(sparse(), 4).events).toEqual(sparse().events);
+ });
+
+ it('toggles palm mute and leaves dead notes and ties alone', () => {
+ const p = togglePalmMute(sparse(), 8);
+ expect(eventAtUnit(p, 8)?.palmMute).toBe(false);
+ expect(eventAtUnit(togglePalmMute(p, 8), 8)?.palmMute).toBe(true);
+ const dead = toggleHit(sparse(), 8, { kind: 'dead' });
+ expect(eventAtUnit(togglePalmMute(dead, 8), 8)?.palmMute).toBe(false);
+ expect(togglePalmMute(sparse(), 4).events).toEqual(sparse().events);
+ });
+
+ it('clears a hit and drops ties that depended on it', () => {
+ const p = clearHit(sparse({ anticipation: true }), 0);
+ expect(p.events.map((e) => e.tick)).toEqual([960]);
+ expectValid(p);
+ });
+
+ it('switches anticipation on when the first beat is emptied', () => {
+ const p = clearHit(sparse(), 0);
+ expect(p.params.anticipation).toBe(true);
+ expectValid(p);
+ expect(sparse().params.anticipation).toBe(false);
+ });
+
+ it('recomputes picks for the mode', () => {
+ const down = sparse({ picking: 'downstrokes' });
+ const p = toggleHit({ ...down, events: down.events.map((e) => ({ ...e, pick: 'down' as const })) }, 3, PEDAL);
+ expect(p.events.every((e) => e.pick === 'down')).toBe(true);
+ const alt = toggleHit(sparse(), 3, PEDAL);
+ expect(eventAtUnit(alt, 3)?.pick).toBe('up');
+ });
+});
+
+describe('edit helpers on generated patterns', () => {
+ it('keep every style valid through a random sequence of edits, without mutating inputs', () => {
+ const targets: HitTarget[] = [PEDAL, SLOT0, SLOT1, { kind: 'dyad', slot: 0 }, { kind: 'dead' }];
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const grid of ['16th', '16th-triplet'] as const) {
+ let p = generateRhythm({ ...defaultRhythmParams(style), grid }, `edit-${style}`);
+ const rng = createRng(`edits-${style}-${grid}`);
+ const units = patternUnitCount(p);
+ for (let step = 0; step < 60; step++) {
+ const before = JSON.stringify(p);
+ const unit = rng.int(0, units - 1);
+ const op = rng.int(0, 3);
+ const next =
+ op === 0
+ ? toggleHit(p, unit, rng.pick(targets))
+ : op === 1
+ ? cycleAccent(p, unit)
+ : op === 2
+ ? togglePalmMute(p, unit)
+ : clearHit(p, unit);
+ expect(JSON.stringify(p)).toBe(before);
+ expect(next).not.toBe(p);
+ expectValid(next);
+ p = next;
+ }
+ }
+ }
+ });
+});
diff --git a/engine/rhythm/edit.ts b/engine/rhythm/edit.ts
new file mode 100644
index 0000000..8686858
--- /dev/null
+++ b/engine/rhythm/edit.ts
@@ -0,0 +1,134 @@
+// Grid-editing helpers for the UI. `unit` = grid unit index from the pattern start in the pattern's grid.
+// Every helper returns a new, valid pattern and never mutates its input; an invalid request returns an
+// unchanged copy.
+import type { HitTarget, RhythmEvent, RhythmPattern } from '../types';
+import { PPQ, gridUnitTicks, patternLengthTicks, patternUnitCount } from './grid';
+import {
+ copyEvent,
+ finalizeEvents,
+ finalizeOptions,
+ hasFirstBeatAttack,
+ isSlotTarget,
+ makeEvent,
+ sameTarget
+} from './events';
+
+const unitTick = (pattern: RhythmPattern, unit: number): number | null =>
+ Number.isInteger(unit) && unit >= 0 && unit < patternUnitCount(pattern) ? unit * gridUnitTicks(pattern.params.grid) : null;
+
+const isUsableTarget = (pattern: RhythmPattern, target: HitTarget): boolean => {
+ if (isSlotTarget(target)) return Number.isInteger(target.slot) && target.slot >= 0 && target.slot < pattern.params.slotCount;
+ return target.kind === 'pedal' || target.kind === 'dead';
+};
+
+/**
+ * Re-derives durations, ties and picks. Emptying the first beat by hand switches params.anticipation on,
+ * since the player has deliberately made a pickup.
+ */
+const rebuild = (pattern: RhythmPattern, events: readonly RhythmEvent[]): RhythmPattern => {
+ const finalized = finalizeEvents(events, finalizeOptions(pattern));
+ const anticipation = pattern.params.anticipation || !hasFirstBeatAttack(finalized, pattern);
+ return {
+ ...pattern,
+ events: finalized,
+ tags: [...pattern.tags],
+ params: anticipation === pattern.params.anticipation ? pattern.params : { ...pattern.params, anticipation }
+ };
+};
+
+/**
+ * Events whose onset lies in this grid cell, in pattern order: usually one, two in a 32nd tremolo cell
+ * (32nds between units belong to the cell they start in).
+ */
+export const eventsInUnit = (pattern: RhythmPattern, unit: number): RhythmEvent[] => {
+ const tick = unitTick(pattern, unit);
+ if (tick === null) return [];
+ const end = tick + gridUnitTicks(pattern.params.grid);
+ return pattern.events.filter((e) => e.tick >= tick && e.tick < end);
+};
+
+/** First event of this grid cell (see eventsInUnit). */
+export const eventAtUnit = (pattern: RhythmPattern, unit: number): RhythmEvent | undefined => eventsInUnit(pattern, unit)[0];
+
+/** Pedal hits added by hand follow the style's palm-mute habit. Heuristic. */
+const defaultPalmMute = (pattern: RhythmPattern, target: HitTarget): boolean =>
+ target.kind === 'pedal' && pattern.params.palmMuteRatio >= 0.5;
+
+/**
+ * Edits address whole grid cells. Empty cell: adds an unaccented attack ringing until the next event, at
+ * most one quarter note; ties right after it sustained the event it interrupts and are dropped, so the new
+ * hit never inherits that sound. Same target as the cell's first event (attack or tie): clears the cell.
+ * Different target: retargets every attack in the cell (keeping accent, palm mute and length) and turns a
+ * leading tie into a fresh attack. Ties after a changed event follow its new target; ties left without a
+ * source are dropped.
+ */
+export const toggleHit = (pattern: RhythmPattern, unit: number, target: HitTarget): RhythmPattern => {
+ const tick = unitTick(pattern, unit);
+ if (tick === null || !isUsableTarget(pattern, target)) return rebuild(pattern, pattern.events);
+ const cell = new Set(eventsInUnit(pattern, unit));
+ const events = pattern.events.map(copyEvent);
+ const indices = pattern.events.flatMap((e, i) => (cell.has(e) ? [i] : []));
+ if (indices.length === 0) {
+ const nextIndex = pattern.events.findIndex((e) => e.tick > tick);
+ const length = patternLengthTicks(pattern);
+ const end = Math.min(nextIndex >= 0 ? pattern.events[nextIndex].tick : length, tick + PPQ, length);
+ let stop = nextIndex;
+ while (stop >= 0 && stop < events.length && events[stop].tie) stop++;
+ const kept = nextIndex < 0 ? events : [...events.slice(0, nextIndex), ...events.slice(stop)];
+ return rebuild(pattern, [...kept, makeEvent(tick, end - tick, target, defaultPalmMute(pattern, target), 0)]);
+ }
+ if (sameTarget(events[indices[0]].target, target)) {
+ return rebuild(pattern, events.filter((_, i) => !indices.includes(i)));
+ }
+ indices.forEach((i, k) => {
+ const e = events[i];
+ if (!e.tie) {
+ events[i] = makeEvent(e.tick, e.durationTicks, target, target.kind === 'dead' ? false : e.palmMute, e.accent);
+ } else if (k === 0) {
+ events[i] = makeEvent(e.tick, e.durationTicks, target, defaultPalmMute(pattern, target), 0);
+ }
+ });
+ return rebuild(pattern, events);
+};
+
+/** 0 -> 1 -> 2 -> 0 on the cell's first event when it is an attack; ties and empty cells are left alone. */
+export const cycleAccent = (pattern: RhythmPattern, unit: number): RhythmPattern => {
+ const target = eventAtUnit(pattern, unit);
+ if (!target || target.tie) return rebuild(pattern, pattern.events);
+ return rebuild(
+ pattern,
+ pattern.events.map((e) => (e === target ? { ...copyEvent(e), accent: ((e.accent + 1) % 3) as 0 | 1 | 2 } : copyEvent(e)))
+ );
+};
+
+/**
+ * Flips palm mute on the cell's first attack and sets every other attack in the cell to match; a cell that
+ * starts with a dead note or a tie, and an empty cell, is left alone (dead notes are never palm-muted).
+ */
+export const togglePalmMute = (pattern: RhythmPattern, unit: number): RhythmPattern => {
+ const first = eventAtUnit(pattern, unit);
+ if (!first || first.tie || first.target.kind === 'dead') return rebuild(pattern, pattern.events);
+ const cell = new Set(eventsInUnit(pattern, unit));
+ const value = !first.palmMute;
+ return rebuild(
+ pattern,
+ pattern.events.map((e) =>
+ cell.has(e) && !e.tie && e.target.kind !== 'dead' ? { ...copyEvent(e), palmMute: value } : copyEvent(e)
+ )
+ );
+};
+
+/** Removes every event of this cell, leaving a rest; ties that sustained them are dropped too. */
+export const clearHit = (pattern: RhythmPattern, unit: number): RhythmPattern => {
+ const cell = new Set(eventsInUnit(pattern, unit));
+ return rebuild(pattern, pattern.events.filter((e) => !cell.has(e)));
+};
+
+/**
+ * Sets params.slotCount (rounded and clamped to 1..4; a non-number keeps the current count) and folds slot
+ * targets that no longer exist back into range with slot % slotCount, e.g. when the user drops voicings.
+ */
+export const setSlotCount = (pattern: RhythmPattern, slotCount: number): RhythmPattern => {
+ const count = Number.isFinite(slotCount) ? Math.min(4, Math.max(1, Math.round(slotCount))) : pattern.params.slotCount;
+ return rebuild({ ...pattern, params: { ...pattern.params, slotCount: count } }, pattern.events);
+};
diff --git a/engine/rhythm/events.ts b/engine/rhythm/events.ts
new file mode 100644
index 0000000..5853efa
--- /dev/null
+++ b/engine/rhythm/events.ts
@@ -0,0 +1,123 @@
+// Event-list normalisation shared by the generator, the edit helpers and the variation tools.
+import type { HitTarget, PickingMode, RhythmEvent, RhythmPattern } from '../types';
+import { assignPicks } from './picking';
+import { barTicks, firstBeatTicks, patternLengthTicks } from './grid';
+
+export const isSlotTarget = (target: HitTarget): target is Extract =>
+ target.kind === 'slot' || target.kind === 'dyad';
+
+export const slotTarget = (kind: 'slot' | 'dyad', slot: number): HitTarget =>
+ kind === 'slot' ? { kind: 'slot', slot } : { kind: 'dyad', slot };
+
+export const copyTarget = (target: HitTarget): HitTarget => ({ ...target });
+
+export const sameTarget = (a: HitTarget, b: HitTarget): boolean =>
+ a.kind === b.kind && (!isSlotTarget(a) || (isSlotTarget(b) && a.slot === b.slot));
+
+/** Canonical event object: `tie` is present only on ties. */
+export const makeEvent = (
+ tick: number,
+ durationTicks: number,
+ target: HitTarget,
+ palmMute: boolean,
+ accent: 0 | 1 | 2,
+ pick: 'down' | 'up' = 'down',
+ tie = false
+): RhythmEvent =>
+ tie
+ ? { tick, durationTicks, target: copyTarget(target), palmMute, accent, pick, tie: true }
+ : { tick, durationTicks, target: copyTarget(target), palmMute, accent, pick };
+
+export const copyEvent = (e: RhythmEvent): RhythmEvent =>
+ makeEvent(e.tick, e.durationTicks, e.target, e.palmMute, e.accent, e.pick, e.tie === true);
+
+export const sameEvents = (a: readonly RhythmEvent[], b: readonly RhythmEvent[]): boolean =>
+ a.length === b.length &&
+ a.every((e, i) => {
+ const o = b[i];
+ return (
+ e.tick === o.tick &&
+ e.durationTicks === o.durationTicks &&
+ sameTarget(e.target, o.target) &&
+ e.palmMute === o.palmMute &&
+ e.accent === o.accent &&
+ e.pick === o.pick &&
+ !!e.tie === !!o.tie
+ );
+ });
+
+export interface FinalizeOptions {
+ lengthTicks: number;
+ barTicks: number;
+ picking: PickingMode;
+ /** When set, slot targets outside 0..slotCount-1 are folded back with slot % slotCount. */
+ slotCount?: number;
+}
+
+const slotInRange = (slot: number, slotCount: number): boolean => Number.isInteger(slot) && slot >= 0 && slot < slotCount;
+
+/** slot % slotCount keeps a lowered slot count's chord changes audible and consistent within each accent. */
+const foldSlot = (target: HitTarget, slotCount: number | undefined): HitTarget => {
+ if (slotCount === undefined || !isSlotTarget(target) || slotInRange(target.slot, slotCount)) return target;
+ const slot = Number.isInteger(target.slot) && target.slot >= 0 ? target.slot % Math.max(1, Math.floor(slotCount)) : 0;
+ return slotTarget(target.kind, slot);
+};
+
+/**
+ * Sorts, drops events outside the pattern, keeps the first event per tick, caps durations at the next onset
+ * and the pattern end (never extends them, so a removed hit leaves a rest), drops ties that no longer continue
+ * the previous event, makes surviving ties copy their source's target and palm mute, folds out-of-range slots
+ * (opts.slotCount), then assigns picks.
+ */
+export const finalizeEvents = (input: readonly RhythmEvent[], opts: FinalizeOptions): RhythmEvent[] => {
+ const sorted = input
+ .map((e, i) => ({ e, i }))
+ .filter(({ e }) => Number.isInteger(e.tick) && e.tick >= 0 && e.tick < opts.lengthTicks && e.durationTicks > 0)
+ .sort((a, b) => a.e.tick - b.e.tick || a.i - b.i)
+ .map(({ e }) => e)
+ .filter((e, i, list) => i === 0 || e.tick !== list[i - 1].tick);
+
+ const out: RhythmEvent[] = [];
+ sorted.forEach((e, i) => {
+ const limit = (i + 1 < sorted.length ? sorted[i + 1].tick : opts.lengthTicks) - e.tick;
+ const duration = Math.min(Math.round(e.durationTicks), limit);
+ if (e.tie) {
+ const prev = out[out.length - 1];
+ if (!prev || prev.tick + prev.durationTicks !== e.tick) return;
+ out.push(makeEvent(e.tick, duration, prev.target, prev.palmMute, 0, 'down', true));
+ } else {
+ out.push(makeEvent(e.tick, duration, foldSlot(e.target, opts.slotCount), e.palmMute, e.accent, 'down'));
+ }
+ });
+
+ const picks = assignPicks(out, opts.picking, opts.barTicks);
+ return out.map((e, i) => ({ ...e, pick: picks[i] }));
+};
+
+export const finalizeOptions = (pattern: Pick): FinalizeOptions => ({
+ lengthTicks: patternLengthTicks(pattern),
+ barTicks: barTicks(pattern.meter),
+ picking: pattern.params.picking,
+ slotCount: pattern.params.slotCount
+});
+
+/** The pattern itself when every slot target exists, else a copy with out-of-range slots folded back. */
+export const withSlotsInRange = (pattern: RhythmPattern): RhythmPattern =>
+ pattern.events.every((e) => !isSlotTarget(e.target) || slotInRange(e.target.slot, pattern.params.slotCount))
+ ? pattern
+ : { ...pattern, events: finalizeEvents(pattern.events, finalizeOptions(pattern)) };
+
+/** Slot/dyad events whose slot differs from the previous chord event without being an accented attack. */
+export const unaccentedSlotChangeCount = (events: readonly RhythmEvent[]): number => {
+ let current: number | null = null;
+ let count = 0;
+ for (const e of events) {
+ if (!isSlotTarget(e.target)) continue;
+ if (current !== null && e.target.slot !== current && (e.tie || e.accent === 0)) count++;
+ current = e.target.slot;
+ }
+ return count;
+};
+
+export const hasFirstBeatAttack = (events: readonly RhythmEvent[], pattern: Pick): boolean =>
+ events.some((e) => !e.tie && e.tick < firstBeatTicks(pattern.meter));
diff --git a/engine/rhythm/generate.test.ts b/engine/rhythm/generate.test.ts
new file mode 100644
index 0000000..ef17127
--- /dev/null
+++ b/engine/rhythm/generate.test.ts
@@ -0,0 +1,264 @@
+import { describe, it, expect } from 'vitest';
+import { generateRhythm, sanitizeRhythmParams } from './generate';
+import { RHYTHM_STYLES, RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles';
+import { validateRhythm } from './validate';
+import { patternLengthTicks } from './grid';
+import { createRng } from '../random';
+import type { GroupingSpec, RhythmParams, RhythmPattern } from '../types';
+
+const SEEDS = ['a', 'b', 'c', 'riff-1', 'riff-2', 'djent', 'x9', '42', 'night', 'sludge', 'z', 'Q7'];
+
+const expectValid = (pattern: RhythmPattern) => {
+ const result = validateRhythm(pattern);
+ expect(result.errors).toEqual([]);
+ expect(result.ok).toBe(true);
+};
+
+/** Slot index may only change on an accented attack; the first accented hit uses slot 0. */
+const expectHarmonyOnAccents = (pattern: RhythmPattern) => {
+ let current: number | null = null;
+ let firstAccent = true;
+ for (const e of pattern.events) {
+ if (e.target.kind !== 'slot' && e.target.kind !== 'dyad') continue;
+ if (!e.tie && e.accent > 0 && firstAccent) {
+ expect(e.target.slot).toBe(0);
+ firstAccent = false;
+ }
+ if (current !== null && e.target.slot !== current) {
+ expect(e.tie).toBeFalsy();
+ expect(e.accent).toBeGreaterThan(0);
+ }
+ current = e.target.slot;
+ }
+};
+
+describe('RHYTHM_STYLES', () => {
+ it('defines every style with a label, description and default params', () => {
+ expect(RHYTHM_STYLE_IDS).toHaveLength(9);
+ for (const style of RHYTHM_STYLE_IDS) {
+ const info = RHYTHM_STYLES[style];
+ expect(info.label.length).toBeGreaterThan(0);
+ expect(info.description.length).toBeGreaterThan(10);
+ expect(info.params.style).toBe(style);
+ expect(info.params.bars).toBe(2);
+ expect(info.params.slotCount).toBe(2);
+ }
+ });
+
+ it('hands out independent copies of the defaults', () => {
+ const a = defaultRhythmParams('fiveOverFour');
+ (a.meter as { numerator: number }).numerator = 3;
+ expect(defaultRhythmParams('fiveOverFour').meter.numerator).toBe(4);
+ expect(RHYTHM_STYLES.fiveOverFour.params.meter.numerator).toBe(4);
+ });
+
+ it('uses downstrokes for the halftime stomp and alternate picking elsewhere', () => {
+ expect(defaultRhythmParams('halftimeStomp').picking).toBe('downstrokes');
+ expect(defaultRhythmParams('gallop').picking).toBe('alternate');
+ expect(defaultRhythmParams('tremoloWall').picking).toBe('alternate');
+ });
+});
+
+describe('generateRhythm validity', () => {
+ it.each(RHYTHM_STYLE_IDS)('%s: valid for every seed with default params', (style) => {
+ for (const seed of SEEDS) {
+ const pattern = generateRhythm(defaultRhythmParams(style), seed);
+ expectValid(pattern);
+ expectHarmonyOnAccents(pattern);
+ expect(pattern.events.length).toBeGreaterThan(0);
+ expect(pattern.ppq).toBe(480);
+ expect(pattern.id).toBe(`${style}-${seed}`);
+ expect(pattern.name.startsWith(RHYTHM_STYLES[style].label)).toBe(true);
+ expect(pattern.seed).toBe(seed);
+ }
+ });
+
+ it.each(RHYTHM_STYLE_IDS)('%s: valid on the triplet grid, with 1 and 4 bars, 1..4 slots and anticipation', (style) => {
+ const variants: Partial[] = [
+ { grid: '16th-triplet' },
+ { bars: 1 },
+ { bars: 4 },
+ { slotCount: 1 },
+ { slotCount: 3 },
+ { slotCount: 4, bars: 4 },
+ { anticipation: true },
+ { picking: 'downstrokes' },
+ { density: 0, syncopation: 1, pedalRatio: 0, palmMuteRatio: 0 },
+ { density: 1, syncopation: 1, pedalRatio: 1, palmMuteRatio: 1 },
+ { grouping: 'random-odd' },
+ { grouping: '3-3-2', cycleSixteenths: 7 },
+ { meter: { numerator: 7, denominator: 8 } },
+ { meter: { numerator: 5, denominator: 4 }, grid: '16th-triplet' }
+ ];
+ for (const variant of variants) {
+ for (const seed of SEEDS.slice(0, 6)) {
+ const pattern = generateRhythm({ ...defaultRhythmParams(style), ...variant }, seed);
+ expectValid(pattern);
+ expectHarmonyOnAccents(pattern);
+ }
+ }
+ });
+
+ it('keeps every event inside the pattern and slots below slotCount', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const slotCount of [1, 2, 3, 4]) {
+ const pattern = generateRhythm({ ...defaultRhythmParams(style), slotCount, bars: 4 }, `slots-${slotCount}`);
+ const length = patternLengthTicks(pattern);
+ for (const e of pattern.events) {
+ expect(e.tick + e.durationTicks).toBeLessThanOrEqual(length);
+ if (e.target.kind === 'slot' || e.target.kind === 'dyad') expect(e.target.slot).toBeLessThan(slotCount);
+ }
+ }
+ }
+ });
+
+ it('uses more than one slot across a multi-bar pattern for most seeds', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const moving = SEEDS.filter((seed) => {
+ const p = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, seed);
+ return new Set(p.events.flatMap((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? [e.target.slot] : []))).size > 1;
+ });
+ expect(moving.length).toBeGreaterThanOrEqual(SEEDS.length / 2);
+ }
+ });
+
+ it('plays every harmony slot whenever there are at least as many accents as slots', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const slotCount of [2, 3, 4]) {
+ for (const bars of [1, 2, 4]) {
+ for (const seed of SEEDS) {
+ const p = generateRhythm({ ...defaultRhythmParams(style), slotCount, bars }, seed);
+ const accents = p.events.filter((e) => !e.tie && e.accent > 0).length;
+ if (accents < slotCount) continue;
+ const used = new Set(p.events.flatMap((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? [e.target.slot] : [])));
+ expect(used.size, `${style} ${seed} slots ${slotCount} bars ${bars}`).toBe(slotCount);
+ }
+ }
+ }
+ }
+ });
+
+ it('includes ties, rests, palm mutes and accents somewhere in the vocabulary', () => {
+ const all = RHYTHM_STYLE_IDS.flatMap((style) => SEEDS.map((seed) => generateRhythm(defaultRhythmParams(style), seed)));
+ const events = all.flatMap((p) => p.events);
+ expect(events.some((e) => e.tie)).toBe(true);
+ expect(events.some((e) => e.palmMute)).toBe(true);
+ expect(events.some((e) => e.accent === 1)).toBe(true);
+ expect(events.some((e) => e.accent === 2)).toBe(true);
+ expect(events.some((e) => e.target.kind === 'dead')).toBe(true);
+ const hasRest = (p: RhythmPattern) =>
+ p.events.some((e, i) => e.tick + e.durationTicks < (p.events[i + 1]?.tick ?? patternLengthTicks(p)));
+ expect(all.some(hasRest)).toBe(true);
+ });
+});
+
+describe('generateRhythm determinism', () => {
+ it('returns deep-equal patterns for equal params and seed', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const params = defaultRhythmParams(style);
+ expect(generateRhythm(params, 'same')).toEqual(generateRhythm(defaultRhythmParams(style), 'same'));
+ }
+ });
+
+ it('usually differs between seeds', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const distinct = new Set(SEEDS.slice(0, 6).map((seed) => JSON.stringify(generateRhythm(defaultRhythmParams(style), seed).events)));
+ expect(distinct.size).toBeGreaterThanOrEqual(4);
+ }
+ });
+
+ it('does not mutate its input params', () => {
+ const params = defaultRhythmParams('gallop');
+ const before = JSON.stringify(params);
+ generateRhythm(params, 'pure');
+ expect(JSON.stringify(params)).toBe(before);
+ });
+});
+
+describe('grids and meters', () => {
+ it('places 16th-grid events on 120 ticks (60 inside tremolo cells)', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const seed of SEEDS.slice(0, 4)) {
+ const pattern = generateRhythm(defaultRhythmParams(style), seed);
+ const step = style === 'tremoloWall' ? 60 : 120;
+ expect(pattern.events.every((e) => e.tick % step === 0 && e.durationTicks % step === 0)).toBe(true);
+ expect(pattern.tags).not.toContain('triplet');
+ }
+ }
+ });
+
+ it('places triplet-grid events on 80-tick units and tags them', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const pattern = generateRhythm({ ...defaultRhythmParams(style), grid: '16th-triplet' }, 'trip');
+ expect(pattern.events.every((e) => e.tick % 80 === 0 && e.durationTicks % 80 === 0)).toBe(true);
+ expect(pattern.tags).toContain('triplet');
+ }
+ });
+
+ it('builds 7/8 bars of 1680 ticks grouped 2+2+3', () => {
+ const pattern = generateRhythm(defaultRhythmParams('sevenEight'), 'odd');
+ expect(pattern.meter).toEqual({ numerator: 7, denominator: 8 });
+ expect(patternLengthTicks(pattern)).toBe(3360);
+ expect(pattern.events[pattern.events.length - 1].tick).toBeLessThan(3360);
+ expect(pattern.cycleTicks).toBeUndefined();
+ expect(pattern.tags).toContain('odd-meter');
+ });
+
+ it('sets cycleTicks 600 for the five-over-four polymeter and drifts its accents across the barline', () => {
+ const pattern = generateRhythm(defaultRhythmParams('fiveOverFour'), 'poly');
+ expect(pattern.cycleTicks).toBe(600);
+ expect(pattern.tags).toContain('polymeter');
+ const strong = pattern.events.filter((e) => e.accent === 2).map((e) => e.tick);
+ expect(strong).toEqual([0, 600, 1200, 1800, 2400, 3000, 3600]);
+ const barOffsets = (bar: number) => strong.filter((t) => Math.floor(t / 1920) === bar).map((t) => t % 1920);
+ expect(barOffsets(0)).toEqual([0, 600, 1200, 1800]);
+ expect(barOffsets(1)).toEqual([480, 1080, 1680]);
+ });
+});
+
+describe('generateRhythm fuzz', () => {
+ it('stays valid for 300 seeded random parameter combinations', () => {
+ const rng = createRng('fuzz');
+ const groupings: GroupingSpec[] = ['even', '3-3-2', '3-3-3-3-4', 'random-odd', [3, 2], [2, 2, 3], [5]];
+ for (let i = 0; i < 300; i++) {
+ const params: RhythmParams = {
+ ...defaultRhythmParams(rng.pick(RHYTHM_STYLE_IDS)),
+ meter: { numerator: rng.int(1, 13), denominator: rng.pick([4, 8] as const) },
+ bars: rng.int(1, 5),
+ grid: rng.pick(['16th', '16th-triplet'] as const),
+ grouping: rng.pick(groupings),
+ density: rng.next(),
+ syncopation: rng.next(),
+ pedalRatio: rng.next(),
+ palmMuteRatio: rng.next(),
+ slotCount: rng.int(1, 4),
+ picking: rng.pick(['alternate', 'downstrokes'] as const),
+ anticipation: rng.chance(0.3),
+ ...(rng.chance(0.3) ? { cycleSixteenths: rng.int(2, 13) } : {})
+ };
+ const pattern = generateRhythm(params, `fuzz-${i}`);
+ expectValid(pattern);
+ expectHarmonyOnAccents(pattern);
+ }
+ });
+});
+
+describe('sanitizeRhythmParams', () => {
+ it('clamps out-of-range params into a usable, valid pattern', () => {
+ const wild = {
+ ...defaultRhythmParams('chugEngine'),
+ bars: 0,
+ slotCount: 9,
+ density: 3,
+ syncopation: -1,
+ pedalRatio: Number.NaN,
+ cycleSixteenths: 1
+ } as RhythmParams;
+ const clean = sanitizeRhythmParams(wild);
+ expect(clean).toMatchObject({ bars: 1, slotCount: 4, density: 1, syncopation: 0, pedalRatio: 0 });
+ expect(clean.cycleSixteenths).toBeUndefined();
+ const pattern = generateRhythm(wild, 'wild');
+ expect(pattern.params).toEqual(clean);
+ expectValid(pattern);
+ });
+});
diff --git a/engine/rhythm/generate.ts b/engine/rhythm/generate.ts
new file mode 100644
index 0000000..95d14c3
--- /dev/null
+++ b/engine/rhythm/generate.ts
@@ -0,0 +1,550 @@
+// Seeded rhythm generator: accent groups -> cells -> syncopation (offbeats, pushes, tie-overs) -> harmony.
+// The first accent cycle is drawn as a motif and repeated with light variation, so riffs repeat like riffs
+// and a polymeter figure stays audible against the bar.
+import type { GroupingSpec, HitTarget, Meter, RhythmEvent, RhythmGrid, RhythmParams, RhythmPattern } from '../types';
+import { createRng, seedFromString, type Rng } from '../random';
+import {
+ PPQ,
+ THIRTY_SECOND_TICKS,
+ barTicks,
+ buildGroups,
+ gridUnitTicks,
+ isOddMeter,
+ isPolymeter,
+ type RhythmGroup
+} from './grid';
+import { RHYTHM_STYLES, RHYTHM_STYLE_IDS, STYLE_RECIPES, type CellKind, type StyleRecipe } from './styles';
+import { finalizeEvents, makeEvent, slotTarget } from './events';
+
+// ---------------------------------------------------------------------------
+// Params
+// ---------------------------------------------------------------------------
+
+const NAMED_GROUPINGS: readonly string[] = ['even', '3-3-2', '3-3-3-3-4', 'random-odd'];
+const MAX_BARS = 64;
+const MAX_CYCLE_UNITS = 1024;
+
+const clamp01 = (x: number): number => (Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0);
+const clampInt = (x: number | undefined, lo: number, hi: number, fallback: number): number =>
+ typeof x === 'number' && Number.isFinite(x) ? Math.min(hi, Math.max(lo, Math.round(x))) : fallback;
+
+const sanitizeGrouping = (grouping: GroupingSpec): GroupingSpec => {
+ if (typeof grouping === 'string') return NAMED_GROUPINGS.includes(grouping) ? grouping : 'even';
+ const groups = Array.isArray(grouping) ? grouping.filter((g) => Number.isInteger(g) && g > 0) : [];
+ return groups.length > 0 ? groups : 'even';
+};
+
+/** Clamps every param into its documented range; generateRhythm stores the result on the pattern. */
+export const sanitizeRhythmParams = (params: RhythmParams): RhythmParams => {
+ const meter: Meter = {
+ numerator: clampInt(params.meter?.numerator, 1, 32, 4),
+ denominator: params.meter?.denominator === 8 ? 8 : 4
+ };
+ const grid: RhythmGrid = params.grid === '16th-triplet' ? '16th-triplet' : '16th';
+ const out: RhythmParams = {
+ style: RHYTHM_STYLE_IDS.includes(params.style) ? params.style : 'chugEngine',
+ meter,
+ bars: clampInt(params.bars, 1, MAX_BARS, 2),
+ grid,
+ grouping: sanitizeGrouping(params.grouping),
+ density: clamp01(params.density),
+ syncopation: clamp01(params.syncopation),
+ pedalRatio: clamp01(params.pedalRatio),
+ palmMuteRatio: clamp01(params.palmMuteRatio),
+ slotCount: clampInt(params.slotCount, 1, 4, 2),
+ picking: params.picking === 'downstrokes' ? 'downstrokes' : 'alternate',
+ anticipation: params.anticipation === true
+ };
+ const cycle = params.cycleSixteenths;
+ if (typeof cycle === 'number' && Number.isFinite(cycle) && cycle >= 2) {
+ out.cycleSixteenths = Math.min(MAX_CYCLE_UNITS, Math.round(cycle));
+ }
+ return out;
+};
+
+// ---------------------------------------------------------------------------
+// Planning
+// ---------------------------------------------------------------------------
+
+export interface GenContext {
+ params: RhythmParams;
+ recipe: StyleRecipe;
+ unitTicks: number;
+ barTicks: number;
+ lengthTicks: number;
+ triplet: boolean;
+}
+
+export const makeContext = (params: RhythmParams): GenContext => {
+ const bar = barTicks(params.meter);
+ return {
+ params,
+ recipe: STYLE_RECIPES[params.style],
+ unitTicks: gridUnitTicks(params.grid),
+ barTicks: bar,
+ lengthTicks: params.bars * bar,
+ triplet: params.grid === '16th-triplet'
+ };
+};
+
+/** Decisions for one accent group. Plans of the first cycle are copied to later cycles. */
+export interface GroupPlan {
+ kind: CellKind;
+ /** Halftime weak-beat figure, 0..3. */
+ variant: number;
+ /** Rest on the group start; the head moves to the next hit (syncopation). */
+ offbeat: boolean;
+ /** Accent the head (cycle starts, stabs and strong halftime beats are accented regardless). */
+ accent: boolean;
+ /** An accented head advances the harmony to the next slot. */
+ move: boolean;
+ fillPedal: boolean;
+ palmMute: boolean;
+ /** Tremolo in 32nds instead of 16ths (16th grid only). */
+ fine: boolean;
+ /** Stab choked after at most two units instead of ringing through the group. */
+ choke: boolean;
+ /** Anticipate the head by this many units; a tie keeps it sounding through the group start. */
+ push: number;
+ /** Let a ringing head sustain into a silent next group through a tie. */
+ tieOver: boolean;
+ deadSeed: number;
+ /** The style's characteristic figure; neighbours never push into it. */
+ signature: boolean;
+}
+
+const CELL_ORDER: readonly CellKind[] = [
+ 'chug16',
+ 'tripletChug',
+ 'tremolo',
+ 'gallop',
+ 'reverseGallop',
+ 'chug8',
+ 'halftime',
+ 'stab',
+ 'rest'
+];
+
+/** Approximate hits per grid unit; cells close to params.density are favoured. */
+const CELL_DENSITY: Readonly> = {
+ chug16: 1,
+ tripletChug: 1,
+ tremolo: 1,
+ gallop: 0.75,
+ reverseGallop: 0.75,
+ chug8: 0.5,
+ halftime: 0.3,
+ stab: 0.25,
+ rest: 0
+};
+
+const DENSITY_SIGMA_SQ2 = 2 * 0.3 * 0.3;
+
+const densityFit = (kind: CellKind, density: number): number =>
+ Math.max(0.05, Math.exp(-((CELL_DENSITY[kind] - density) ** 2) / DENSITY_SIGMA_SQ2));
+
+const cellWeight = (kind: CellKind, group: RhythmGroup, ctx: GenContext): number => {
+ const { params, recipe } = ctx;
+ let weight = (recipe.cells[kind] ?? 0) * densityFit(kind, params.density);
+ if (kind === 'rest') weight *= group.indexInCycle === 0 ? 0 : 0.5 + params.syncopation;
+ if ((kind === 'gallop' || kind === 'reverseGallop') && group.length < 3) weight = 0;
+ return weight;
+};
+
+export const isHalftimeStrong = (group: RhythmGroup, ctx: GenContext): boolean => {
+ const offset = (group.startUnit * ctx.unitTicks) % ctx.barTicks;
+ return offset === 0 || offset * 2 === ctx.barTicks;
+};
+
+/** Draws one plan. Every random value is drawn unconditionally so a param tweak only changes what it touches. */
+export const drawPlan = (rng: Rng, group: RhythmGroup, ctx: GenContext): GroupPlan => {
+ const { params, recipe } = ctx;
+ const cycleStart = group.indexInCycle === 0;
+ const kinds = CELL_ORDER.filter((k) => (recipe.cells[k] ?? 0) > 0);
+ const weights = kinds.map((k) => cellWeight(k, group, ctx));
+ let kind = rng.weighted(kinds, weights);
+ if (kind === 'rest' && cycleStart) kind = kinds.find((k) => k !== 'rest') ?? 'chug8';
+ if (kind === 'chug16' && ctx.triplet) kind = 'tripletChug';
+
+ const offbeatRoll = rng.next();
+ const accentRoll = rng.next();
+ const moveRoll = rng.next();
+ const pedalRoll = rng.next();
+ const muteRoll = rng.next();
+ const fineRoll = rng.next();
+ const chokeRoll = rng.next();
+ const variant = rng.weighted([0, 1, 2, 3], [1.2 - params.density, 0.4 + params.density, 0.5, 0.4 + 0.5 * params.density]);
+ const pushRoll = rng.next();
+ const pushSizeRoll = rng.next();
+ const tieRoll = rng.next();
+ const deadSeed = rng.int(0, 0x7fffffff);
+
+ const offbeat = !cycleStart && kind !== 'rest' && group.length >= 2 && offbeatRoll < params.syncopation * recipe.offbeatRate;
+ const accentRate = offbeat ? Math.min(1, recipe.accentRate + 0.3) : recipe.accentRate;
+ const choke = kind === 'stab' && chokeRoll < recipe.stabChoke;
+ const canPush = !offbeat && kind !== 'rest' && kind !== 'tremolo';
+ return {
+ kind,
+ variant,
+ offbeat,
+ accent: cycleStart || accentRoll < accentRate,
+ move: moveRoll < recipe.harmonyMoveRate,
+ fillPedal: pedalRoll < params.pedalRatio,
+ palmMute: muteRoll < params.palmMuteRatio,
+ fine: fineRoll < 0.3 + 0.6 * params.density,
+ choke,
+ push: canPush && pushRoll < params.syncopation * recipe.pushRate ? (pushSizeRoll < 0.5 ? 1 : 2) : 0,
+ tieOver: ((kind === 'stab' && !choke) || kind === 'halftime') && tieRoll < recipe.tieOverRate,
+ deadSeed,
+ signature: false
+ };
+};
+
+const signatureKind = (ctx: GenContext): CellKind | null => {
+ const kind = ctx.recipe.signature;
+ return kind === 'chug16' && ctx.triplet ? 'tripletChug' : kind;
+};
+
+const isChug = (kind: CellKind) => kind === 'chug16' || kind === 'tripletChug';
+
+const matchesSignature = (plan: GroupPlan, group: RhythmGroup, kind: CellKind, ctx: GenContext): boolean => {
+ if (plan.kind !== kind || plan.offbeat || plan.push > 0) return false;
+ if (kind === 'stab') return plan.choke && group.length >= 2;
+ if (group.length < 4) return false;
+ if (isChug(kind)) return plan.fillPedal && plan.palmMute;
+ if (kind === 'tremolo') return plan.fine || ctx.triplet;
+ return true;
+};
+
+/** Guarantees the style's characteristic figure appears in the motif (signature groups also skip dead notes). */
+const enforceSignature = (motif: GroupPlan[], cycleGroups: RhythmGroup[], ctx: GenContext): GroupPlan[] => {
+ const kind = signatureKind(ctx);
+ if (!kind) return motif;
+ const found = motif.findIndex((plan, i) => matchesSignature(plan, cycleGroups[i], kind, ctx));
+ if (found >= 0) return motif.map((plan, i) => (i === found ? { ...plan, signature: true } : plan));
+ const need = kind === 'stab' ? 2 : 4;
+ const long = cycleGroups.filter((g) => g.length >= need);
+ const pool = long.length > 0 ? long : cycleGroups.filter((g) => g.length >= 2);
+ if (pool.length === 0) return motif;
+ const target = pool.find((g) => g.indexInCycle > 0) ?? pool[0];
+ return motif.map((plan, i) =>
+ i !== target.indexInCycle
+ ? plan
+ : {
+ ...plan,
+ kind,
+ offbeat: false,
+ push: 0,
+ signature: true,
+ choke: kind === 'stab' ? true : plan.choke,
+ tieOver: kind === 'stab' ? false : plan.tieOver,
+ fillPedal: isChug(kind) ? true : plan.fillPedal,
+ palmMute: isChug(kind) ? true : plan.palmMute,
+ fine: kind === 'tremolo' ? true : plan.fine
+ }
+ );
+};
+
+const planGroups = (groups: RhythmGroup[], ctx: GenContext, rng: Rng): GroupPlan[] => {
+ const cycle0 = groups.filter((g) => g.cycle === 0);
+ const motif = enforceSignature(
+ cycle0.map((g) => drawPlan(rng.fork(`motif-${g.indexInCycle}`), g, ctx)),
+ cycle0,
+ ctx
+ );
+ const lastCycle = groups[groups.length - 1].cycle;
+ const turnIndex =
+ lastCycle > 0 ? groups.reduce((idx, g) => (g.cycle === lastCycle && !g.truncated ? g.index : idx), -1) : -1;
+
+ let plans = groups.map((g) => {
+ if (g.cycle === 0) return motif[g.indexInCycle];
+ const vary = rng.fork(`vary-${g.index}`);
+ const redraw = vary.chance(ctx.recipe.variation);
+ const turn = g.index === turnIndex && vary.chance(ctx.recipe.turnaround);
+ return redraw || turn ? drawPlan(vary, g, ctx) : { ...motif[g.indexInCycle] };
+ });
+
+ if (ctx.recipe.halftimeStrong) {
+ plans = plans.map((plan, i) =>
+ isHalftimeStrong(groups[i], ctx) ? { ...plan, kind: 'halftime', offbeat: false, push: 0, accent: true } : plan
+ );
+ }
+
+ // Anticipation: sometimes leave the very first downbeat empty (pickup feel).
+ const first = plans[0];
+ if (
+ ctx.params.anticipation &&
+ rng.fork('anticipation').chance(0.5) &&
+ first.kind !== 'rest' &&
+ !first.signature &&
+ groups[0].length >= 2
+ ) {
+ plans[0] = { ...first, offbeat: true, push: 0 };
+ }
+ return plans;
+};
+
+// ---------------------------------------------------------------------------
+// Rendering
+// ---------------------------------------------------------------------------
+
+export interface RawHit {
+ tick: number;
+ dur: number;
+ role: 'head' | 'fill' | 'tie';
+ group: number;
+ dead: boolean;
+ tremolo: boolean;
+}
+
+/** [offset, length] in grid units within a group. */
+type Segment = readonly [number, number];
+
+const repeatFigure = (figure: readonly number[], n: number): Segment[] => {
+ const segments: Segment[] = [];
+ for (let pos = 0, i = 0; pos < n; i++) {
+ const length = Math.min(figure[i % figure.length], n - pos);
+ segments.push([pos, length]);
+ pos += length;
+ }
+ return segments;
+};
+
+const chokeUnits = (n: number): number => Math.max(1, Math.min(2, n - 1));
+
+/** Weak beats of a halftime bar: 0 rest, 1 two 16ths into the next beat, 2 one 8th, 3 an 8th pickup. */
+const halftimeWeak = (variant: number, n: number): Segment[] => {
+ switch (variant) {
+ case 1:
+ return n >= 3 ? [[n - 2, 1], [n - 1, 1]] : [[n - 1, 1]];
+ case 2:
+ return [[0, Math.min(2, n)]];
+ case 3:
+ return n >= 3 ? [[n - 2, 2]] : [[0, n]];
+ default:
+ return [];
+ }
+};
+
+/**
+ * Cell figures, adapted to the group length n:
+ * - chug16 / tripletChug: every unit. chug8: every 2 units (on the triplet grid: 8th-note triplets).
+ * - gallop: 8th + 16th + 16th repeated (n = 3: 8th + 16th; triplet grid: long-short); n <= 2: plain 16ths.
+ * - reverseGallop: 16th + 16th + 8th repeated (n = 3: 16th + 8th; triplet grid: short-long); n <= 2: 16ths.
+ * - stab: one hit ringing through the group, or choked after min(2, n - 1) units.
+ * - halftime: a ringing chord on beats 1 and 3, a sparse weak-beat figure elsewhere.
+ */
+const cellSegments = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): Segment[] => {
+ const n = group.length;
+ switch (plan.kind) {
+ case 'rest':
+ return [];
+ case 'stab':
+ return [[0, plan.choke ? chokeUnits(n) : n]];
+ case 'halftime':
+ return isHalftimeStrong(group, ctx) ? [[0, n]] : halftimeWeak(plan.variant, n);
+ case 'chug8':
+ return repeatFigure([2], n);
+ case 'gallop':
+ return n <= 2 ? repeatFigure([1], n) : repeatFigure(ctx.triplet ? [2, 1] : [2, 1, 1], n);
+ case 'reverseGallop':
+ if (n <= 2) return repeatFigure([1], n);
+ if (n === 3 && !ctx.triplet) return [[0, 1], [1, 2]];
+ return repeatFigure(ctx.triplet ? [1, 2] : [1, 1, 2], n);
+ default:
+ return repeatFigure([1], n);
+ }
+};
+
+/** Drop the downbeat hit; a single held hit is delayed to the next 8th (or 16th in short groups) instead. */
+const shiftOffbeat = (segments: Segment[], n: number): Segment[] => {
+ if (segments.length >= 2) return segments.slice(1);
+ if (segments.length === 1 && n >= 2) {
+ const delay = n >= 4 ? 2 : 1;
+ return [[delay, Math.max(1, Math.min(segments[0][1], n - delay))]];
+ }
+ return segments;
+};
+
+export const renderGroup = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): RawHit[] => {
+ const start = group.startUnit * ctx.unitTicks;
+ if (plan.kind === 'tremolo') {
+ const step = plan.fine && !ctx.triplet ? THIRTY_SECOND_TICKS : ctx.unitTicks;
+ const count = (group.length * ctx.unitTicks) / step;
+ const first = plan.offbeat && group.length >= 2 ? ctx.unitTicks / step : 0;
+ const hits: RawHit[] = [];
+ for (let i = first; i < count; i++) {
+ hits.push({ tick: start + i * step, dur: step, role: i === first ? 'head' : 'fill', group: group.index, dead: false, tremolo: true });
+ }
+ return hits;
+ }
+ const segments = plan.offbeat ? shiftOffbeat(cellSegments(plan, group, ctx), group.length) : cellSegments(plan, group, ctx);
+ const dead = createRng(plan.deadSeed);
+ return segments.map(([offset, length], i) => ({
+ tick: start + offset * ctx.unitTicks,
+ dur: length * ctx.unitTicks,
+ role: i === 0 ? 'head' : 'fill',
+ group: group.index,
+ dead: i > 0 && !plan.signature && dead.chance(ctx.recipe.deadRate),
+ tremolo: false
+ }));
+};
+
+export const headAccent = (plan: GroupPlan, group: RhythmGroup, ctx: GenContext): 0 | 1 | 2 => {
+ if (group.indexInCycle === 0) return 2;
+ if (plan.kind === 'stab' || (plan.kind === 'halftime' && isHalftimeStrong(group, ctx))) return 1;
+ return plan.accent ? 1 : 0;
+};
+
+/** Anticipation across the group boundary: the accented head moves earlier and a tie holds it through the beat. */
+const applyPushes = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext): void => {
+ for (let i = 1; i < groups.length; i++) {
+ const plan = plans[i];
+ if (plan.push <= 0 || plans[i - 1].signature) continue;
+ const group = groups[i];
+ const start = group.startUnit * ctx.unitTicks;
+ const head = hits[i][0];
+ if (!head || head.role !== 'head' || head.tick !== start || headAccent(plan, group, ctx) === 0) continue;
+ const pushTicks = plan.push * ctx.unitTicks;
+ const newTick = start - pushTicks;
+ const prevHits = hits[i - 1];
+ if (newTick <= groups[i - 1].startUnit * ctx.unitTicks || prevHits.length === 0 || prevHits[0].tick >= newTick) continue;
+ hits[i - 1] = prevHits.filter((h) => h.tick < newTick);
+ hits[i] = [{ ...head, tick: newTick, dur: pushTicks }, { ...head, role: 'tie', tick: start }, ...hits[i].slice(1)];
+ }
+};
+
+/** A ringing head followed by a group that starts silent keeps sounding into it. */
+const applyTieOvers = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext): void => {
+ for (let i = 0; i + 1 < groups.length; i++) {
+ if (!plans[i].tieOver) continue;
+ const end = (groups[i].startUnit + groups[i].length) * ctx.unitTicks;
+ const next = groups[i + 1];
+ const last = hits[i][hits[i].length - 1];
+ if (!last || last.role !== 'head' || last.tick + last.dur !== end || next.startUnit * ctx.unitTicks !== end) continue;
+ const firstNext = hits[i + 1].length > 0 ? hits[i + 1][0].tick : (next.startUnit + next.length) * ctx.unitTicks;
+ if (firstNext <= end) continue;
+ hits[i].push({ ...last, role: 'tie', tick: end, dur: firstNext - end });
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Harmony and assembly
+// ---------------------------------------------------------------------------
+
+/**
+ * Picks extra harmony moves so that every slot sounds when there are enough accents: moves step the slot by
+ * one, so slotCount - 1 moves reach them all. Cycle-start accents are preferred, and each forced move goes to
+ * the candidate farthest (in accents) from any existing change, so the slots get similar shares. Heuristic.
+ */
+const ensureSlotCoverage = (moves: readonly boolean[], strong: readonly boolean[], slotCount: number): boolean[] => {
+ const out = [...moves];
+ const needed = Math.min(slotCount - 1, out.length - 1);
+ let count = out.filter(Boolean).length;
+ while (count < needed) {
+ const open = out.flatMap((move, i) => (i > 0 && !move ? [i] : []));
+ const preferred = open.some((i) => strong[i]) ? open.filter((i) => strong[i]) : open;
+ const changes = [0, out.length, ...out.flatMap((move, i) => (move ? [i] : []))];
+ const spacing = (i: number) => Math.min(...changes.map((c) => Math.abs(c - i)));
+ const best = preferred.reduce((a, b) => (spacing(b) > spacing(a) ? b : a));
+ out[best] = true;
+ count++;
+ }
+ return out;
+};
+
+/**
+ * Slot changes happen only on accented heads. The first accent uses slot 0; afterwards a cycle-start accent
+ * advances to the next slot with recipe.cycleMoveRate and other accents when their plan says `move`, plus
+ * the moves ensureSlotCoverage adds. Non-accented hits go to the pedal (plan.fillPedal) or stay on the
+ * current slot/dyad.
+ */
+const assignTargets = (groups: RhythmGroup[], plans: GroupPlan[], hits: RawHit[][], ctx: GenContext, rng: Rng): RhythmEvent[] => {
+ const { recipe, params } = ctx;
+ const lastCycle = groups[groups.length - 1].cycle;
+ const advance = Array.from({ length: lastCycle + 1 }, (_, k) => k > 0 && rng.fork(`cycle-${k}`).chance(recipe.cycleMoveRate));
+ const ordered = hits.flat().sort((a, b) => a.tick - b.tick);
+ const accentOf = (hit: RawHit): 0 | 1 | 2 => (hit.role === 'head' ? headAccent(plans[hit.group], groups[hit.group], ctx) : 0);
+ const accented = ordered.filter((hit) => accentOf(hit) > 0);
+ const strong = accented.map((hit) => accentOf(hit) === 2);
+ const moves = ensureSlotCoverage(
+ accented.map((hit, i) => i > 0 && (strong[i] ? advance[groups[hit.group].cycle] : plans[hit.group].move)),
+ strong,
+ params.slotCount
+ );
+
+ const events: RhythmEvent[] = [];
+ let cursor = 0;
+ let accentIndex = 0;
+ for (const hit of ordered) {
+ const plan = plans[hit.group];
+ const prev = events[events.length - 1];
+ if (hit.role === 'tie') {
+ if (prev) events.push(makeEvent(hit.tick, hit.dur, prev.target, prev.palmMute, 0, 'down', true));
+ continue;
+ }
+ const accent = accentOf(hit);
+ let target: HitTarget;
+ let palmMute = plan.palmMute;
+ if (accent > 0) {
+ if (moves[accentIndex++]) cursor = (cursor + 1) % params.slotCount;
+ target = slotTarget(hit.tremolo ? 'dyad' : recipe.headTarget, cursor);
+ palmMute = false;
+ } else if (hit.dead) {
+ target = { kind: 'dead' };
+ palmMute = false;
+ } else if (hit.tremolo) {
+ target = slotTarget('dyad', cursor);
+ } else if (plan.fillPedal) {
+ target = { kind: 'pedal' };
+ } else {
+ target = slotTarget(recipe.headTarget, cursor);
+ }
+ events.push(makeEvent(hit.tick, hit.dur, target, palmMute, accent));
+ }
+ return events;
+};
+
+export const seedTag = (seed: string): string => seedFromString(seed).toString(36).toUpperCase().padStart(4, '0').slice(-4);
+
+const patternTags = (params: RhythmParams, ctx: GenContext, plans: GroupPlan[], hits: RawHit[][]): string[] => {
+ const tags = [...ctx.recipe.tags];
+ if (plans.some((p) => p.kind === 'gallop' || p.kind === 'reverseGallop')) tags.push('gallop');
+ if (plans.some((p) => p.kind === 'halftime')) tags.push('halftime');
+ if (hits.some((list) => list.some((h) => h.tremolo))) tags.push('tremolo');
+ if (params.grid === '16th-triplet') tags.push('triplet');
+ if (isOddMeter(params.meter)) tags.push('odd-meter');
+ if (isPolymeter(params)) tags.push('polymeter');
+ if (params.syncopation >= 0.5) tags.push('syncopated');
+ return [...new Set(tags)];
+};
+
+export const generateRhythm = (input: RhythmParams, seed: string): RhythmPattern => {
+ const params = sanitizeRhythmParams(input);
+ const ctx = makeContext(params);
+ const rng = createRng(seed);
+ const groups = buildGroups(params, seed);
+ const plans = planGroups(groups, ctx, rng.fork('plan'));
+ const hits = groups.map((g) => renderGroup(plans[g.index], g, ctx));
+ applyPushes(groups, plans, hits, ctx);
+ applyTieOvers(groups, plans, hits, ctx);
+ const events = finalizeEvents(assignTargets(groups, plans, hits, ctx, rng.fork('harmony')), {
+ lengthTicks: ctx.lengthTicks,
+ barTicks: ctx.barTicks,
+ picking: params.picking,
+ slotCount: params.slotCount
+ });
+ const pattern: RhythmPattern = {
+ id: `${params.style}-${seed}`,
+ name: `${RHYTHM_STYLES[params.style].label} ${seedTag(seed)}`,
+ seed,
+ meter: { ...params.meter },
+ bars: params.bars,
+ ppq: PPQ,
+ events,
+ tags: patternTags(params, ctx, plans, hits),
+ params
+ };
+ if (params.cycleSixteenths !== undefined) pattern.cycleTicks = params.cycleSixteenths * ctx.unitTicks;
+ return pattern;
+};
diff --git a/engine/rhythm/grid.test.ts b/engine/rhythm/grid.test.ts
new file mode 100644
index 0000000..c0b30bb
--- /dev/null
+++ b/engine/rhythm/grid.test.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect } from 'vitest';
+import {
+ PPQ,
+ barTicks,
+ barUnits,
+ buildGroups,
+ cycleGrouping,
+ firstBeatTicks,
+ gridUnitTicks,
+ isOddMeter,
+ isPolymeter,
+ patternLengthTicks,
+ patternUnitCount,
+ resolveGrouping
+} from './grid';
+import { defaultRhythmParams } from './styles';
+import * as rhythm from './index';
+import { createRng } from '../random';
+import type { Meter } from '../types';
+
+const m = (numerator: number, denominator: 4 | 8): Meter => ({ numerator, denominator });
+const rng = () => createRng('grid-test');
+
+describe('tick math', () => {
+ it('uses 480 ticks per quarter note', () => {
+ expect(PPQ).toBe(480);
+ });
+
+ it('computes bar lengths as numerator * (4 / denominator) quarters', () => {
+ expect(barTicks(m(4, 4))).toBe(1920);
+ expect(barTicks(m(7, 8))).toBe(1680);
+ expect(barTicks(m(3, 4))).toBe(1440);
+ expect(barTicks(m(6, 8))).toBe(1440);
+ expect(barTicks(m(5, 4))).toBe(2400);
+ });
+
+ it('maps grids to unit ticks and counts units per bar', () => {
+ expect(gridUnitTicks('16th')).toBe(120);
+ expect(gridUnitTicks('16th-triplet')).toBe(80);
+ expect(barUnits(m(4, 4), '16th')).toBe(16);
+ expect(barUnits(m(4, 4), '16th-triplet')).toBe(24);
+ expect(barUnits(m(7, 8), '16th')).toBe(14);
+ expect(barUnits(m(7, 8), '16th-triplet')).toBe(21);
+ });
+
+ it('derives pattern length and unit count from bars and meter', () => {
+ const params = { ...defaultRhythmParams('sevenEight'), bars: 2 };
+ const pattern = { bars: 2, meter: m(7, 8), params };
+ expect(patternLengthTicks(pattern)).toBe(3360);
+ expect(patternUnitCount(pattern)).toBe(28);
+ expect(patternUnitCount({ ...pattern, params: { ...params, grid: '16th-triplet' as const } })).toBe(42);
+ });
+
+ it('treats one quarter (or a whole short bar) as the first beat', () => {
+ expect(firstBeatTicks(m(4, 4))).toBe(480);
+ expect(firstBeatTicks(m(7, 8))).toBe(480);
+ expect(firstBeatTicks(m(1, 8))).toBe(240);
+ });
+
+ it('classifies odd meters and polymeter cycles', () => {
+ expect(isOddMeter(m(7, 8))).toBe(true);
+ expect(isOddMeter(m(5, 4))).toBe(true);
+ expect(isOddMeter(m(4, 4))).toBe(false);
+ expect(isOddMeter(m(3, 4))).toBe(false);
+ expect(isOddMeter(m(9, 8))).toBe(false);
+ expect(isPolymeter(defaultRhythmParams('fiveOverFour'))).toBe(true);
+ expect(isPolymeter(defaultRhythmParams('chugEngine'))).toBe(false);
+ expect(isPolymeter({ ...defaultRhythmParams('chugEngine'), cycleSixteenths: 8 })).toBe(false);
+ expect(isPolymeter({ ...defaultRhythmParams('chugEngine'), cycleSixteenths: 32 })).toBe(false);
+ });
+});
+
+describe('resolveGrouping', () => {
+ it("'even' follows the beat: quarters in x/4, 2+2+3 eighths in 7/8", () => {
+ expect(resolveGrouping('even', 16, m(4, 4), '16th', rng())).toEqual([4, 4, 4, 4]);
+ expect(resolveGrouping('even', 14, m(7, 8), '16th', rng())).toEqual([4, 4, 6]);
+ expect(resolveGrouping('even', 24, m(4, 4), '16th-triplet', rng())).toEqual([6, 6, 6, 6]);
+ expect(resolveGrouping('even', 21, m(7, 8), '16th-triplet', rng())).toEqual([6, 6, 9]);
+ expect(resolveGrouping('even', 10, m(5, 8), '16th', rng())).toEqual([4, 6]);
+ expect(resolveGrouping('even', 12, m(6, 8), '16th', rng())).toEqual([6, 6]);
+ });
+
+ it("'even' splits a polymeter cycle without leaving one-unit groups", () => {
+ expect(resolveGrouping('even', 5, m(4, 4), '16th', rng())).toEqual([3, 2]);
+ expect(resolveGrouping('even', 9, m(4, 4), '16th', rng())).toEqual([4, 3, 2]);
+ expect(resolveGrouping('even', 6, m(4, 4), '16th', rng())).toEqual([4, 2]);
+ expect(resolveGrouping('even', 3, m(4, 4), '16th', rng())).toEqual([3]);
+ });
+
+ it('repeats the named groupings to fill the cycle', () => {
+ expect(resolveGrouping('3-3-2', 16, m(4, 4), '16th', rng())).toEqual([3, 3, 2, 3, 3, 2]);
+ expect(resolveGrouping('3-3-3-3-4', 16, m(4, 4), '16th', rng())).toEqual([3, 3, 3, 3, 4]);
+ expect(resolveGrouping('3-3-2', 14, m(7, 8), '16th', rng())).toEqual([3, 3, 2, 3, 3]);
+ });
+
+ it('uses explicit groups as-is, repeated, merging a truncated single unit', () => {
+ expect(resolveGrouping([3, 2], 5, m(4, 4), '16th', rng())).toEqual([3, 2]);
+ expect(resolveGrouping([3, 2], 16, m(4, 4), '16th', rng())).toEqual([3, 2, 3, 2, 3, 3]);
+ expect(resolveGrouping([1, 2], 4, m(4, 4), '16th', rng())).toEqual([1, 2, 1]);
+ expect(resolveGrouping([0, -2], 16, m(4, 4), '16th', rng())).toEqual([4, 4, 4, 4]);
+ });
+
+ it("'random-odd' mixes 3s with 2s and 4s, sums to the cycle and is seeded", () => {
+ for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) {
+ for (const cycle of [5, 7, 11, 16, 21]) {
+ const groups = resolveGrouping('random-odd', cycle, m(4, 4), '16th', createRng(seed));
+ expect(groups.reduce((s, g) => s + g, 0)).toBe(cycle);
+ expect(groups.every((g) => g >= 2 && g <= 4)).toBe(true);
+ expect(groups).toEqual(resolveGrouping('random-odd', cycle, m(4, 4), '16th', createRng(seed)));
+ }
+ }
+ const withThrees = ['a', 'b', 'c', 'd', 'e', 'f'].filter((seed) =>
+ resolveGrouping('random-odd', 16, m(4, 4), '16th', createRng(seed)).includes(3)
+ );
+ expect(withThrees.length).toBeGreaterThanOrEqual(4);
+ });
+});
+
+describe('buildGroups', () => {
+ it('keeps one-bar cycles aligned with the bar (7/8 as 2+2+3)', () => {
+ const params = defaultRhythmParams('sevenEight');
+ expect(cycleGrouping(params, 'x')).toEqual([4, 4, 6]);
+ const groups = buildGroups(params, 'x');
+ expect(groups.map((g) => g.startUnit)).toEqual([0, 4, 8, 14, 18, 22]);
+ expect(groups.map((g) => g.length)).toEqual([4, 4, 6, 4, 4, 6]);
+ expect(groups.map((g) => g.cycle)).toEqual([0, 0, 0, 1, 1, 1]);
+ });
+
+ it('repeats a 5-unit cycle across the barline until the bars are full', () => {
+ const params = defaultRhythmParams('fiveOverFour');
+ const groups = buildGroups(params, 'x');
+ const cycleStarts = groups.filter((g) => g.indexInCycle === 0).map((g) => g.startUnit);
+ expect(cycleStarts).toEqual([0, 5, 10, 15, 20, 25, 30]);
+ expect(groups.reduce((s, g) => s + g.length, 0)).toBe(32);
+ const last = groups[groups.length - 1];
+ expect(last).toMatchObject({ startUnit: 30, length: 2, truncated: true, indexInCycle: 0 });
+ });
+
+ it('covers every unit exactly once for any grouping', () => {
+ for (const grouping of ['even', '3-3-2', '3-3-3-3-4', 'random-odd', [5, 3]] as const) {
+ const params = { ...defaultRhythmParams('chugEngine'), grouping, bars: 3 };
+ const groups = buildGroups(params, 'cover');
+ let expected = 0;
+ for (const g of groups) {
+ expect(g.startUnit).toBe(expected);
+ expected += g.length;
+ }
+ expect(expected).toBe(48);
+ }
+ });
+});
+
+describe('public rhythm API', () => {
+ it('exposes the accent groups and the cell and slot-count helpers a grid UI needs', () => {
+ expect(rhythm.buildGroups).toBe(buildGroups);
+ for (const name of ['eventsInUnit', 'setSlotCount', 'eventAtUnit']) expect(rhythm).toHaveProperty(name);
+ const group: rhythm.RhythmGroup = buildGroups(defaultRhythmParams('gallop'), 'api')[0];
+ expect(group).toMatchObject({ startUnit: 0, cycle: 0, indexInCycle: 0 });
+ });
+});
diff --git a/engine/rhythm/grid.ts b/engine/rhythm/grid.ts
new file mode 100644
index 0000000..2b7a163
--- /dev/null
+++ b/engine/rhythm/grid.ts
@@ -0,0 +1,164 @@
+// Tick math, grid units and accent grouping. 480 PPQ, matching utils/midi.ts.
+import type { GroupingSpec, Meter, RhythmGrid, RhythmParams, RhythmPattern } from '../types';
+import { createRng, type Rng } from '../random';
+
+export const PPQ = 480;
+export const SIXTEENTH_TICKS = 120;
+export const TRIPLET_UNIT_TICKS = 80;
+export const THIRTY_SECOND_TICKS = 60;
+
+/** numerator * (4 / denominator) quarter notes: 4/4 = 1920, 7/8 = 1680. */
+export const barTicks = (meter: Meter): number => (meter.numerator * 4 * PPQ) / meter.denominator;
+
+export const gridUnitTicks = (grid: RhythmGrid): number => (grid === '16th-triplet' ? TRIPLET_UNIT_TICKS : SIXTEENTH_TICKS);
+
+export const barUnits = (meter: Meter, grid: RhythmGrid): number => barTicks(meter) / gridUnitTicks(grid);
+
+export const patternLengthTicks = (pattern: Pick): number =>
+ pattern.bars * barTicks(pattern.meter);
+
+/** Grid cells in the whole pattern; edit helpers address events by this unit index. */
+export const patternUnitCount = (pattern: Pick): number =>
+ patternLengthTicks(pattern) / gridUnitTicks(pattern.params.grid);
+
+/** Window that must contain an attack unless params.anticipation: one quarter (two eighths in x/8). */
+export const firstBeatTicks = (meter: Meter): number => Math.min(PPQ, barTicks(meter));
+
+type CycleParams = Pick;
+
+/** Accent-cycle length in grid units: params.cycleSixteenths, or one bar. */
+export const cycleUnits = (params: CycleParams): number => params.cycleSixteenths ?? barUnits(params.meter, params.grid);
+
+/** The accent cycle and the bar drift against each other (neither divides the other). */
+export const isPolymeter = (params: CycleParams): boolean => {
+ const cycle = cycleUnits(params);
+ const bar = barUnits(params.meter, params.grid);
+ return cycle % bar !== 0 && bar % cycle !== 0;
+};
+
+/** 5/4, 7/8, 11/8 ... Simple triple (3/4) and compound meters (6/8, 9/8, 12/8) are not odd. */
+export const isOddMeter = (meter: Meter): boolean =>
+ meter.numerator % 2 === 1 && meter.numerator >= 5 && !(meter.denominator === 8 && meter.numerator % 3 === 0);
+
+// ---------------------------------------------------------------------------
+// Grouping
+// ---------------------------------------------------------------------------
+
+/** Beat groups of one bar: quarters in x/4; compound x/8 in dotted quarters; other x/8 as 2+2+...+3 eighths. */
+const meterGroups = (meter: Meter, grid: RhythmGrid): number[] => {
+ const unit = gridUnitTicks(grid);
+ if (meter.denominator === 4) return Array.from({ length: meter.numerator }, () => PPQ / unit);
+ const eighth = PPQ / 2 / unit;
+ const n = meter.numerator;
+ let eighths: number[];
+ if (n % 3 === 0 && n > 3) eighths = Array.from({ length: n / 3 }, () => 3);
+ else if (n === 1) eighths = [1];
+ else if (n % 2 === 0) eighths = Array.from({ length: n / 2 }, () => 2);
+ else eighths = [...Array.from({ length: (n - 3) / 2 }, () => 2), 3];
+ return eighths.map((e) => e * eighth);
+};
+
+/** Beat-sized groups; a one-unit remainder is folded into the last two groups (5 -> 3 + 2). */
+const splitEvenly = (total: number, size: number): number[] => {
+ if (total <= size) return [total];
+ const groups = Array.from({ length: Math.floor(total / size) }, () => size);
+ const rest = total - groups.length * size;
+ if (rest >= 2) groups.push(rest);
+ else if (rest === 1) {
+ const merged = (groups.pop() as number) + 1;
+ groups.push(Math.ceil(merged / 2), Math.floor(merged / 2));
+ }
+ return groups;
+};
+
+/** Repeats `base` until `total` units are filled; a one-unit leftover made by truncation joins the previous group. */
+const repeatToFill = (base: readonly number[], total: number): number[] => {
+ const groups: number[] = [];
+ let sum = 0;
+ for (let i = 0; sum < total; i++) {
+ const size = Math.min(base[i % base.length], total - sum);
+ groups.push(size);
+ sum += size;
+ }
+ const last = groups.length - 1;
+ if (last > 0 && groups[last] === 1 && base[last % base.length] !== 1) {
+ groups.pop();
+ groups[last - 1] += 1;
+ }
+ return groups;
+};
+
+/** Seeded mix of 3s with 2s and 4s; never leaves a single unit. */
+const randomOddGroups = (total: number, rng: Rng): number[] => {
+ const groups: number[] = [];
+ let rest = total;
+ while (rest > 0) {
+ if (rest <= 3) {
+ groups.push(rest);
+ break;
+ }
+ const options = [3, 2, 4].filter((g) => g <= rest && rest - g !== 1);
+ const size = rng.weighted(options, options.map((g) => (g === 3 ? 0.55 : g === 2 ? 0.25 : 0.2)));
+ groups.push(size);
+ rest -= size;
+ }
+ return groups;
+};
+
+/** Partition one accent cycle of `total` grid units into accent groups. */
+export const resolveGrouping = (
+ spec: GroupingSpec,
+ total: number,
+ meter: Meter,
+ grid: RhythmGrid,
+ rng: Rng
+): number[] => {
+ if (typeof spec !== 'string') {
+ const base = spec.filter((g) => Number.isInteger(g) && g > 0);
+ return base.length > 0 ? repeatToFill(base, total) : resolveGrouping('even', total, meter, grid, rng);
+ }
+ switch (spec) {
+ case '3-3-2':
+ return repeatToFill([3, 3, 2], total);
+ case '3-3-3-3-4':
+ return repeatToFill([3, 3, 3, 3, 4], total);
+ case 'random-odd':
+ return randomOddGroups(total, rng);
+ default:
+ return total === barUnits(meter, grid) ? meterGroups(meter, grid) : splitEvenly(total, PPQ / gridUnitTicks(grid));
+ }
+};
+
+type GroupingParams = Pick;
+
+/** Group lengths of one accent cycle for these params ('random-odd' is drawn from the seed). */
+export const cycleGrouping = (params: GroupingParams, seed: string): number[] =>
+ resolveGrouping(params.grouping, cycleUnits(params), params.meter, params.grid, createRng(seed).fork('grouping'));
+
+export interface RhythmGroup {
+ index: number;
+ startUnit: number;
+ length: number;
+ /** Which repetition of the accent cycle this group belongs to. */
+ cycle: number;
+ indexInCycle: number;
+ /** Cut short by the end of the pattern. */
+ truncated: boolean;
+}
+
+/** Lays the cycle's groups end to end, across barlines, until bars * barUnits are filled. */
+export const buildGroups = (params: GroupingParams & Pick, seed: string): RhythmGroup[] => {
+ const total = params.bars * barUnits(params.meter, params.grid);
+ const cycle = cycleUnits(params);
+ const sizes = cycleGrouping(params, seed);
+ const groups: RhythmGroup[] = [];
+ for (let c = 0, start = 0; start < total; c++, start += cycle) {
+ let offset = 0;
+ for (let i = 0; i < sizes.length && start + offset < total; i++) {
+ const length = Math.min(sizes[i], total - start - offset);
+ groups.push({ index: groups.length, startUnit: start + offset, length, cycle: c, indexInCycle: i, truncated: length < sizes[i] });
+ offset += sizes[i];
+ }
+ }
+ return groups;
+};
diff --git a/engine/rhythm/index.ts b/engine/rhythm/index.ts
new file mode 100644
index 0000000..90b6ccc
--- /dev/null
+++ b/engine/rhythm/index.ts
@@ -0,0 +1,22 @@
+// Public API of the rhythm engine.
+export {
+ PPQ,
+ barTicks,
+ barUnits,
+ gridUnitTicks,
+ patternLengthTicks,
+ patternUnitCount,
+ firstBeatTicks,
+ cycleGrouping,
+ buildGroups,
+ isPolymeter,
+ isOddMeter,
+ type RhythmGroup
+} from './grid';
+export { RHYTHM_STYLES, RHYTHM_STYLE_IDS, defaultRhythmParams, type RhythmStyleInfo } from './styles';
+export { generateRhythm, sanitizeRhythmParams } from './generate';
+export { mutateRhythm, regenerateRhythm } from './variation';
+export { validateRhythm, type RhythmValidateOptions } from './validate';
+export { assignPicks } from './picking';
+export { toggleHit, cycleAccent, togglePalmMute, clearHit, eventAtUnit, eventsInUnit, setSlotCount } from './edit';
+export { rhythmToPlaybackEvents, rhythmToMidiNotes, hitVelocity } from './playback';
diff --git a/engine/rhythm/picking.test.ts b/engine/rhythm/picking.test.ts
new file mode 100644
index 0000000..5b3c488
--- /dev/null
+++ b/engine/rhythm/picking.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect } from 'vitest';
+import { assignPicks } from './picking';
+import { generateRhythm } from './generate';
+import { clearHit } from './edit';
+import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles';
+import { barTicks, patternUnitCount } from './grid';
+import type { RhythmPattern } from '../types';
+
+const at = (...ticks: number[]) => ticks.map((tick) => ({ tick }));
+const BAR = 1920;
+
+describe('assignPicks: alternate', () => {
+ it('keys picks to the sixteenth grid: even sixteenths down, odd up', () => {
+ expect(assignPicks(at(0, 120, 240, 360, 480), 'alternate', BAR)).toEqual(['down', 'up', 'down', 'up', 'down']);
+ });
+
+ it('picks a gallop down, down, up and keeps the pendulum through rests', () => {
+ expect(assignPicks(at(0, 240, 360), 'alternate', BAR)).toEqual(['down', 'down', 'up']);
+ expect(assignPicks(at(0, 360, 600), 'alternate', BAR)).toEqual(['down', 'up', 'up']);
+ });
+
+ it('alternates 32nds strictly, including the on-grid ones between them', () => {
+ expect(assignPicks(at(0, 60, 120, 180, 240, 300), 'alternate', BAR)).toEqual([
+ 'down',
+ 'up',
+ 'down',
+ 'up',
+ 'down',
+ 'up'
+ ]);
+ });
+
+ it('alternates sixteenth triplets by event order', () => {
+ expect(assignPicks(at(0, 80, 160, 240, 320, 400, 480), 'alternate', BAR)).toEqual([
+ 'down',
+ 'up',
+ 'down',
+ 'up',
+ 'down',
+ 'up',
+ 'down'
+ ]);
+ });
+
+ it('restarts eighth-note triplets on each beat (on-grid pick wins after a 16th or longer gap)', () => {
+ expect(assignPicks(at(0, 160, 320, 480), 'alternate', BAR)).toEqual(['down', 'up', 'down', 'down']);
+ });
+
+ it('places a leading off-grid event by its own subdivision', () => {
+ expect(assignPicks(at(60), 'alternate', BAR)).toEqual(['up']);
+ expect(assignPicks(at(80), 'alternate', BAR)).toEqual(['up']);
+ expect(assignPicks(at(160), 'alternate', BAR)).toEqual(['down']);
+ expect(assignPicks(at(1860), 'alternate', BAR)).toEqual(['up']);
+ });
+
+ it('starts each bar fresh so a bar never depends on the previous one', () => {
+ // 7/8 on the triplet grid: last triplet of bar 1 is a downstroke, the next downbeat is too.
+ expect(assignPicks(at(1600, 1680), 'alternate', 1680)).toEqual(['down', 'down']);
+ });
+});
+
+describe('assignPicks: runs faster than sixteenths', () => {
+ it('keys a sixteenth-triplet run with a gap to its own pendulum, so the next beat is still down', () => {
+ expect(assignPicks(at(0, 160, 240, 320, 400, 480), 'alternate', BAR)).toEqual(['down', 'down', 'up', 'down', 'up', 'down']);
+ });
+
+ it('keys an offbeat 32nd run to the 32nd pendulum, so the beat it reaches is down', () => {
+ expect(assignPicks(at(480, 600, 660, 720, 780, 840, 900, 960), 'alternate', BAR)).toEqual([
+ 'down',
+ 'down',
+ 'up',
+ 'down',
+ 'up',
+ 'down',
+ 'up',
+ 'down'
+ ]);
+ });
+
+ it('keeps the 32nd pendulum through a missing 32nd', () => {
+ expect(assignPicks(at(0, 60, 180, 240), 'alternate', BAR)).toEqual(['down', 'up', 'up', 'down']);
+ });
+});
+
+/** Every event on a quarter-note boundary of its bar is a downstroke; notes closer than a 16th alternate. */
+const expectBeatStartsDown = (p: RhythmPattern) => {
+ const bar = barTicks(p.meter);
+ p.events.forEach((e, i) => {
+ if ((e.tick % bar) % 480 === 0) expect(e.pick, `${p.id} tick ${e.tick}`).toBe('down');
+ const prev = p.events[i - 1];
+ if (prev && !e.tie && !prev.tie && e.tick - prev.tick < 120 && Math.floor(prev.tick / bar) === Math.floor(e.tick / bar)) {
+ expect(e.pick, `${p.id} run at ${e.tick}`).not.toBe(prev.pick);
+ }
+ });
+};
+
+describe('assignPicks on generated and edited patterns', () => {
+ it('puts every beat start on a downstroke, also after a note is removed from a fast run', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const grid of ['16th', '16th-triplet'] as const) {
+ for (const seed of ['b0', 'b54', 'xyz', 'riff-1']) {
+ const p = generateRhythm({ ...defaultRhythmParams(style), grid, picking: 'alternate', bars: 1 }, seed);
+ expectBeatStartsDown(p);
+ for (let unit = 0; unit < patternUnitCount(p); unit++) expectBeatStartsDown(clearHit(p, unit));
+ }
+ }
+ }
+ });
+});
+
+describe('assignPicks: downstrokes', () => {
+ it('picks everything down', () => {
+ expect(assignPicks(at(0, 120, 240, 300, 360), 'downstrokes', BAR)).toEqual(['down', 'down', 'down', 'down', 'down']);
+ });
+});
diff --git a/engine/rhythm/picking.ts b/engine/rhythm/picking.ts
new file mode 100644
index 0000000..078e8e2
--- /dev/null
+++ b/engine/rhythm/picking.ts
@@ -0,0 +1,62 @@
+// Picking guidance. Validation recomputes picks with this same function, so it must stay pure and positional.
+import type { PickingMode } from '../types';
+import { SIXTEENTH_TICKS, THIRTY_SECOND_TICKS, TRIPLET_UNIT_TICKS } from './grid';
+
+type Pick = 'down' | 'up';
+
+const flip = (pick: Pick): Pick => (pick === 'down' ? 'up' : 'down');
+const byParity = (index: number): Pick => (index % 2 === 0 ? 'down' : 'up');
+
+/**
+ * 'downstrokes': every pick down.
+ * 'alternate' (pendulum keyed to the grid; state resets at every barline, so a bar's picks never depend on
+ * its neighbours and bar-relative positions are used throughout):
+ * - a note less than a sixteenth from a neighbour in its bar belongs to a fast run and takes the parity of
+ * its position on that run's subdivision (sixteenth triplets when the gap is a triplet unit, else 32nds).
+ * Runs therefore alternate strictly, a missing note leaves a ghost stroke instead of inverting the rest,
+ * and every beat start (a multiple of both subdivisions) stays a downstroke;
+ * - any other on-grid note (tick % 120 === 0) is down on even sixteenths and up on odd ones;
+ * - any other off-grid note (eighth-note triplets) alternates from the previous note's pick, or, first in
+ * its bar, takes the parity of its own 32nd or triplet position.
+ */
+export const assignPicks = (events: readonly { tick: number }[], mode: PickingMode, barLengthTicks: number): Pick[] => {
+ if (mode === 'downstrokes') return events.map(() => 'down');
+ const barOf = (tick: number) => Math.floor(tick / barLengthTicks);
+ const gapTo = (i: number, j: number): number =>
+ j >= 0 && j < events.length && barOf(events[j].tick) === barOf(events[i].tick)
+ ? Math.abs(events[j].tick - events[i].tick)
+ : Infinity;
+ const picks: Pick[] = [];
+ let prevPick: Pick | null = null;
+ let prevBar = -1;
+ events.forEach(({ tick }, i) => {
+ const bar = barOf(tick);
+ if (bar !== prevBar) {
+ prevPick = null;
+ prevBar = bar;
+ }
+ const rel = tick - bar * barLengthTicks;
+ const gaps = [gapTo(i, i - 1), gapTo(i, i + 1)].filter((g) => g > 0);
+ const fastGap = Math.min(...gaps);
+ let pick: Pick;
+ if (fastGap < SIXTEENTH_TICKS) {
+ if (fastGap % TRIPLET_UNIT_TICKS === 0 && rel % TRIPLET_UNIT_TICKS === 0) pick = byParity(rel / TRIPLET_UNIT_TICKS);
+ else if (rel % THIRTY_SECOND_TICKS === 0) pick = byParity(rel / THIRTY_SECOND_TICKS);
+ else if (rel % TRIPLET_UNIT_TICKS === 0) pick = byParity(rel / TRIPLET_UNIT_TICKS);
+ else pick = prevPick === null ? 'down' : flip(prevPick);
+ } else if (rel % SIXTEENTH_TICKS === 0) {
+ pick = byParity(rel / SIXTEENTH_TICKS);
+ } else if (prevPick !== null) {
+ pick = flip(prevPick);
+ } else if (rel % THIRTY_SECOND_TICKS === 0) {
+ pick = byParity(rel / THIRTY_SECOND_TICKS);
+ } else if (rel % TRIPLET_UNIT_TICKS === 0) {
+ pick = byParity(rel / TRIPLET_UNIT_TICKS);
+ } else {
+ pick = flip(byParity(Math.floor(rel / SIXTEENTH_TICKS)));
+ }
+ picks.push(pick);
+ prevPick = pick;
+ });
+ return picks;
+};
diff --git a/engine/rhythm/playback.test.ts b/engine/rhythm/playback.test.ts
new file mode 100644
index 0000000..f339be7
--- /dev/null
+++ b/engine/rhythm/playback.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect } from 'vitest';
+import { rhythmToMidiNotes, rhythmToPlaybackEvents } from './playback';
+import { generateRhythm } from './generate';
+import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles';
+import { patternLengthTicks } from './grid';
+import { E_STANDARD } from '../tuning';
+import type { HarmonySlot, HitTarget, RhythmEvent, RhythmPattern } from '../types';
+
+// E5 (0 2 2 x x x) and C5 on the A string (x 3 5 5 x x) with an explicit A2 pedal.
+const SLOTS: HarmonySlot[] = [
+ { shape: [0, 2, 2, null, null, null], pedalMidi: null },
+ { shape: [null, 3, 5, 5, null, null], pedalMidi: 45 }
+];
+
+const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({
+ tick,
+ durationTicks,
+ target,
+ palmMute: false,
+ accent: 0,
+ pick: 'down',
+ ...extra
+});
+
+const PATTERN: RhythmPattern = {
+ id: 'p',
+ name: 'P',
+ seed: 'p',
+ meter: { numerator: 4, denominator: 4 },
+ bars: 1,
+ ppq: 480,
+ tags: [],
+ params: { ...defaultRhythmParams('chugEngine'), bars: 1 },
+ events: [
+ ev(0, 240, { kind: 'slot', slot: 0 }, { accent: 2 }),
+ ev(240, 120, { kind: 'pedal' }, { palmMute: true }),
+ ev(360, 120, { kind: 'dead' }),
+ ev(480, 240, { kind: 'slot', slot: 1 }, { accent: 1 }),
+ ev(720, 240, { kind: 'slot', slot: 1 }, { tie: true }),
+ ev(960, 120, { kind: 'pedal' }, { palmMute: true, accent: 1 }),
+ ev(1080, 120, { kind: 'dyad', slot: 0 }),
+ ev(1200, 120, { kind: 'slot', slot: 3 }),
+ ev(1320, 120, { kind: 'slot', slot: 3 }, { tie: true }),
+ ev(1440, 480, { kind: 'pedal' })
+ ]
+};
+
+describe('rhythmToPlaybackEvents', () => {
+ const out = rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 120);
+
+ it('skips ties and events whose slot is missing', () => {
+ expect(out.map((e) => e.tick)).toEqual([0, 240, 360, 480, 960, 1080, 1440]);
+ });
+
+ it('times events as tick / 480 * 60 / bpm', () => {
+ expect(out.map((e) => e.timeSec)).toEqual([0, 0.25, 0.375, 0.5, 1, 1.125, 1.5]);
+ const slow = rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 60);
+ expect(slow[3].timeSec).toBeCloseTo(1, 9);
+ expect(slow[3].durationSec).toBeCloseTo(1, 9);
+ });
+
+ it('plays slots through shapeToMidi, dyads as the top two notes, pedals from the active slot', () => {
+ expect(out[0].midi).toEqual([40, 47, 52]);
+ expect(out[1].midi).toEqual([40]);
+ expect(out[3].midi).toEqual([48, 55, 60]);
+ expect(out[4].midi).toEqual([45]);
+ expect(out[5].midi).toEqual([47, 52]);
+ expect(out[6].midi).toEqual([40]);
+ });
+
+ it('maps accents, palm mute and dead notes to velocity', () => {
+ expect(out[0].velocity).toBeCloseTo(0.92, 9);
+ expect(out[1].velocity).toBeCloseTo(0.525, 9);
+ expect(out[2].velocity).toBeCloseTo(0.3, 9);
+ expect(out[3].velocity).toBeCloseTo(0.82, 9);
+ expect(out[4].velocity).toBeCloseTo(0.615, 9);
+ expect(out[5].velocity).toBeCloseTo(0.7, 9);
+ });
+
+ it('shortens palm mutes to 35 percent, dead notes to 30 ms and extends ties', () => {
+ expect(out[0].durationSec).toBeCloseTo(0.25, 9);
+ expect(out[1].durationSec).toBeCloseTo(0.125 * 0.35, 9);
+ expect(out[2].durationSec).toBeCloseTo(0.03, 9);
+ expect(out[3].durationSec).toBeCloseTo(0.5, 9);
+ expect(out[5].durationSec).toBeCloseTo(0.125, 9);
+ expect(out[6].durationSec).toBeCloseTo(0.5, 9);
+ });
+
+ it('reports kind, palm mute and accent for the UI', () => {
+ expect(out.map((e) => e.kind)).toEqual(['slot', 'pedal', 'dead', 'slot', 'pedal', 'dyad', 'pedal']);
+ expect(out[1].palmMute).toBe(true);
+ expect(out[3].accent).toBe(1);
+ });
+
+ it('skips slots whose shape does not fit the tuning and returns nothing for a bad bpm', () => {
+ const broken: HarmonySlot[] = [{ shape: [0, 2, 2], pedalMidi: null }, SLOTS[1]];
+ expect(rhythmToPlaybackEvents(PATTERN, broken, E_STANDARD, 120).map((e) => e.tick)).toEqual([240, 360, 480, 960, 1440]);
+ expect(rhythmToPlaybackEvents(PATTERN, SLOTS, E_STANDARD, 0)).toEqual([]);
+ });
+});
+
+describe('rhythmToMidiNotes', () => {
+ const notes = rhythmToMidiNotes(PATTERN, SLOTS, E_STANDARD);
+
+ it('emits one note per pitch with tick durations following the same rules', () => {
+ expect(notes).toEqual([
+ { tick: 0, durationTicks: 240, pitch: 40, velocity: 117 },
+ { tick: 0, durationTicks: 240, pitch: 47, velocity: 117 },
+ { tick: 0, durationTicks: 240, pitch: 52, velocity: 117 },
+ { tick: 240, durationTicks: 42, pitch: 40, velocity: 67 },
+ { tick: 360, durationTicks: 30, pitch: 40, velocity: 38 },
+ { tick: 480, durationTicks: 480, pitch: 48, velocity: 104 },
+ { tick: 480, durationTicks: 480, pitch: 55, velocity: 104 },
+ { tick: 480, durationTicks: 480, pitch: 60, velocity: 104 },
+ { tick: 960, durationTicks: 42, pitch: 45, velocity: 78 },
+ { tick: 1080, durationTicks: 120, pitch: 47, velocity: 89 },
+ { tick: 1080, durationTicks: 120, pitch: 52, velocity: 89 },
+ { tick: 1440, durationTicks: 480, pitch: 40, velocity: 89 }
+ ]);
+ });
+
+ it('keeps every velocity in 1..127 and every note inside the pattern', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const pattern = generateRhythm(defaultRhythmParams(style), `midi-${style}`);
+ const all = rhythmToMidiNotes(pattern, SLOTS, E_STANDARD);
+ expect(all.length).toBeGreaterThan(0);
+ for (const n of all) {
+ expect(n.velocity).toBeGreaterThanOrEqual(1);
+ expect(n.velocity).toBeLessThanOrEqual(127);
+ expect(n.durationTicks).toBeGreaterThan(0);
+ expect(n.tick + n.durationTicks).toBeLessThanOrEqual(patternLengthTicks(pattern));
+ }
+ }
+ });
+});
+
+describe('generated patterns', () => {
+ it('produce sorted playback with sound for every attack when all slots exist', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const pattern = generateRhythm(defaultRhythmParams(style), `play-${style}`);
+ const out = rhythmToPlaybackEvents(pattern, SLOTS, E_STANDARD, 140);
+ expect(out).toHaveLength(pattern.events.filter((e) => !e.tie).length);
+ for (let i = 1; i < out.length; i++) expect(out[i].timeSec).toBeGreaterThan(out[i - 1].timeSec);
+ expect(out.every((e) => e.midi.length > 0 && e.velocity > 0 && e.velocity <= 1)).toBe(true);
+ }
+ });
+});
diff --git a/engine/rhythm/playback.ts b/engine/rhythm/playback.ts
new file mode 100644
index 0000000..11ca100
--- /dev/null
+++ b/engine/rhythm/playback.ts
@@ -0,0 +1,133 @@
+// Pure conversion of a rhythm pattern plus harmony slots into timed notes. No Tone.js, no clock.
+import type { HarmonySlot, HitTarget, MidiNoteEvent, PlaybackEvent, RhythmEvent, RhythmPattern, Tuning } from '../types';
+import { isValidShape, shapeToMidi } from '../shape';
+import { PPQ } from './grid';
+
+export const BASE_VELOCITY = 0.7;
+/** Added to the base velocity for accent 0, 1, 2. */
+export const ACCENT_BOOST: readonly number[] = [0, 0.12, 0.22];
+export const PALM_MUTE_VELOCITY_FACTOR = 0.75;
+export const PALM_MUTE_DURATION_FACTOR = 0.35;
+export const DEAD_VELOCITY = 0.3;
+export const DEAD_NOTE_SEC = 0.03;
+/** Dead-note length for MIDI export, where no tempo is known: a 64th, about 0.03 s at 120 BPM. */
+export const DEAD_NOTE_TICKS = 30;
+
+const slotNotes = (slot: HarmonySlot | undefined, tuning: Tuning): number[] | null =>
+ slot && isValidShape(slot.shape, tuning.openMidi.length) ? shapeToMidi(slot.shape, tuning) : null;
+
+/** slot.pedalMidi, else the slot's lowest sounding note, else the tuning's lowest open string. */
+const pedalNote = (slot: HarmonySlot | undefined, tuning: Tuning): number | null => {
+ if (!slot) return null;
+ if (typeof slot.pedalMidi === 'number') return slot.pedalMidi;
+ const notes = slotNotes(slot, tuning);
+ return notes && notes.length > 0 ? Math.min(...notes) : (tuning.openMidi[0] ?? null);
+};
+
+const targetNotes = (target: HitTarget, slots: readonly HarmonySlot[], tuning: Tuning, activeSlot: number): number[] | null => {
+ switch (target.kind) {
+ case 'slot':
+ return slotNotes(slots[target.slot], tuning);
+ case 'dyad': {
+ const notes = slotNotes(slots[target.slot], tuning);
+ return notes ? [...notes].sort((a, b) => a - b).slice(-2) : null;
+ }
+ default: {
+ const pedal = pedalNote(slots[activeSlot], tuning);
+ return pedal === null ? null : [pedal];
+ }
+ }
+};
+
+interface SoundingNote {
+ event: RhythmEvent;
+ midi: number[];
+ /** Grid duration including any ties that sustain it. */
+ gridTicks: number;
+}
+
+/**
+ * Pedal and dead hits use the active slot: the slot of the most recent slot/dyad hit that sounded (slot 0
+ * before any). Hits whose slot is missing (or whose shape does not fit the tuning) are skipped, and a tie
+ * after a skipped hit extends nothing.
+ */
+const resolveNotes = (pattern: RhythmPattern, slots: readonly HarmonySlot[], tuning: Tuning): SoundingNote[] => {
+ const notes: SoundingNote[] = [];
+ let activeSlot = 0;
+ let current: SoundingNote | null = null;
+ for (const event of pattern.events) {
+ if (event.tie) {
+ if (current) current.gridTicks += event.durationTicks;
+ continue;
+ }
+ const midi = targetNotes(event.target, slots, tuning, activeSlot);
+ if (!midi || midi.length === 0) {
+ current = null;
+ continue;
+ }
+ if (event.target.kind === 'slot' || event.target.kind === 'dyad') activeSlot = event.target.slot;
+ current = { event, midi, gridTicks: event.durationTicks };
+ notes.push(current);
+ }
+ return notes;
+};
+
+const round4 = (x: number): number => Math.round(x * 10000) / 10000;
+
+/** Base 0.7, accents +0.12 / +0.22, palm mute x0.75, dead notes fixed at 0.3; clamped to 0..1. */
+export const hitVelocity = (event: RhythmEvent): number => {
+ if (event.target.kind === 'dead') return DEAD_VELOCITY;
+ const boosted = BASE_VELOCITY + (ACCENT_BOOST[event.accent] ?? 0);
+ return round4(Math.min(1, Math.max(0, event.palmMute ? boosted * PALM_MUTE_VELOCITY_FACTOR : boosted)));
+};
+
+/**
+ * timeSec = tick / 480 * 60 / bpm. Duration = grid duration (plus ties), x0.35 when palm-muted,
+ * min(0.03 s, grid) for dead notes. Returns [] for a non-positive bpm.
+ */
+export const rhythmToPlaybackEvents = (
+ pattern: RhythmPattern,
+ slots: readonly HarmonySlot[],
+ tuning: Tuning,
+ bpm: number
+): PlaybackEvent[] => {
+ if (!(bpm > 0)) return [];
+ const secPerTick = 60 / (PPQ * bpm);
+ return resolveNotes(pattern, slots, tuning).map(({ event, midi, gridTicks }) => {
+ const gridSec = gridTicks * secPerTick;
+ const durationSec =
+ event.target.kind === 'dead'
+ ? Math.min(DEAD_NOTE_SEC, gridSec)
+ : event.palmMute
+ ? gridSec * PALM_MUTE_DURATION_FACTOR
+ : gridSec;
+ return {
+ tick: event.tick,
+ timeSec: event.tick * secPerTick,
+ midi: [...midi],
+ velocity: hitVelocity(event),
+ durationSec,
+ kind: event.target.kind,
+ palmMute: event.palmMute,
+ accent: event.accent
+ };
+ });
+};
+
+/**
+ * One note per distinct pitch, sorted by tick then pitch. Velocity = round(playback velocity * 127) in 1..127;
+ * duration in ticks follows the playback rules (palm mute max(1, round(0.35 * d)), dead notes min(30, d)).
+ */
+export const rhythmToMidiNotes = (pattern: RhythmPattern, slots: readonly HarmonySlot[], tuning: Tuning): MidiNoteEvent[] =>
+ resolveNotes(pattern, slots, tuning).flatMap(({ event, midi, gridTicks }) => {
+ const durationTicks =
+ event.target.kind === 'dead'
+ ? Math.min(DEAD_NOTE_TICKS, gridTicks)
+ : event.palmMute
+ ? Math.max(1, Math.round(PALM_MUTE_DURATION_FACTOR * gridTicks))
+ : gridTicks;
+ const velocity = Math.min(127, Math.max(1, Math.round(hitVelocity(event) * 127)));
+ return [...new Set(midi)]
+ .sort((a, b) => a - b)
+ .map((pitch) => ({ tick: event.tick, durationTicks, pitch, velocity }));
+ });
diff --git a/engine/rhythm/styles.test.ts b/engine/rhythm/styles.test.ts
new file mode 100644
index 0000000..3616bb3
--- /dev/null
+++ b/engine/rhythm/styles.test.ts
@@ -0,0 +1,124 @@
+// Each style must carry its characteristic figure for every seed, not just on average.
+import { describe, it, expect } from 'vitest';
+import { generateRhythm } from './generate';
+import { defaultRhythmParams } from './styles';
+import { cycleGrouping } from './grid';
+import type { RhythmEvent, RhythmPattern, RhythmStyleId } from '../types';
+
+const SEEDS = ['a', 'b', 'c', 'riff-1', 'riff-2', 'djent', 'x9', '42', 'night', 'sludge', 'z', 'Q7', 'grind', 'doom'];
+
+const gen = (style: RhythmStyleId, seed: string) => generateRhythm(defaultRhythmParams(style), seed);
+const attacks = (p: RhythmPattern): RhythmEvent[] => p.events.filter((e) => !e.tie);
+const isSlotHit = (e: RhythmEvent) => e.target.kind === 'slot' || e.target.kind === 'dyad';
+
+describe.each(SEEDS)('style characteristics (seed %s)', (seed) => {
+ it('chugEngine: a run of palm-muted pedal 16ths and accented chord stabs', () => {
+ const p = gen('chugEngine', seed);
+ const ev = p.events;
+ const run = ev.some(
+ (e, i) =>
+ i + 2 < ev.length &&
+ [e, ev[i + 1], ev[i + 2]].every((x) => !x.tie && x.target.kind === 'pedal' && x.palmMute) &&
+ ev[i + 1].tick - e.tick === 120 &&
+ ev[i + 2].tick - ev[i + 1].tick === 120
+ );
+ expect(run).toBe(true);
+ expect(attacks(p).some((e) => e.accent > 0 && isSlotHit(e) && !e.palmMute)).toBe(true);
+ });
+
+ it('gallop: an 8th + 16th + 16th figure on the beat', () => {
+ const ev = gen('gallop', seed).events;
+ const found = ev.some(
+ (e, i) =>
+ i + 2 < ev.length &&
+ !e.tie &&
+ !ev[i + 1].tie &&
+ !ev[i + 2].tie &&
+ e.tick % 480 === 0 &&
+ e.durationTicks === 240 &&
+ ev[i + 1].tick - e.tick === 240 &&
+ ev[i + 2].tick - ev[i + 1].tick === 120
+ );
+ expect(found).toBe(true);
+ });
+
+ it('reverseGallop: a 16th + 16th + 8th figure on the beat', () => {
+ const ev = gen('reverseGallop', seed).events;
+ const found = ev.some(
+ (e, i) =>
+ i + 2 < ev.length &&
+ !e.tie &&
+ !ev[i + 1].tie &&
+ !ev[i + 2].tie &&
+ e.tick % 480 === 0 &&
+ ev[i + 1].tick - e.tick === 120 &&
+ ev[i + 2].tick - ev[i + 1].tick === 120 &&
+ ev[i + 2].durationTicks === 240
+ );
+ expect(found).toBe(true);
+ });
+
+ it('displacedThrees: accented chords on units 0, 3, 6, 9 and 12 of every bar', () => {
+ const p = gen('displacedThrees', seed);
+ for (let bar = 0; bar < p.bars; bar++) {
+ const accented = attacks(p)
+ .filter((e) => e.accent > 0 && Math.floor(e.tick / 1920) === bar)
+ .map((e) => (e.tick % 1920) / 120);
+ expect(accented).toEqual([0, 3, 6, 9, 12]);
+ }
+ });
+
+ it('halftimeStomp: accented chords on beats 1 and 3, sparse, all downstrokes', () => {
+ const p = gen('halftimeStomp', seed);
+ for (let bar = 0; bar < p.bars; bar++) {
+ for (const offset of [0, 960]) {
+ const e = p.events.find((x) => x.tick === bar * 1920 + offset);
+ expect(e && !e.tie && e.accent > 0 && isSlotHit(e)).toBe(true);
+ }
+ expect(attacks(p).filter((e) => Math.floor(e.tick / 1920) === bar).length).toBeLessThanOrEqual(8);
+ }
+ expect(p.events.every((e) => e.pick === 'down')).toBe(true);
+ });
+
+ it('tremoloWall: 32nd (60-tick) onsets on dyads', () => {
+ const p = gen('tremoloWall', seed);
+ expect(p.events.some((e) => !e.tie && e.target.kind === 'dyad' && e.tick % 120 === 60 && e.durationTicks === 60)).toBe(true);
+ expect(p.tags).toContain('tremolo');
+ });
+
+ it('syncopatedStabs: off-beat accented stabs and rests', () => {
+ const p = gen('syncopatedStabs', seed);
+ expect(attacks(p).some((e) => e.accent > 0 && isSlotHit(e) && e.tick % 480 !== 0)).toBe(true);
+ const rest = p.events.some((e, i) => i + 1 < p.events.length && e.tick + e.durationTicks < p.events[i + 1].tick);
+ expect(rest).toBe(true);
+ expect(p.tags).toContain('syncopated');
+ });
+
+ it('sevenEight: 1680-tick bars in 2+2+3 with a strong accent on each downbeat only', () => {
+ const p = gen('sevenEight', seed);
+ expect(cycleGrouping(p.params, p.seed)).toEqual([4, 4, 6]);
+ expect(p.events.filter((e) => e.accent === 2).map((e) => e.tick)).toEqual([0, 1680]);
+ expect(p.events.every((e) => e.tick < 3360)).toBe(true);
+ });
+
+ it('fiveOverFour: strong accents every 5 sixteenths, crossing the barline', () => {
+ const p = gen('fiveOverFour', seed);
+ expect(p.cycleTicks).toBe(600);
+ const strong = p.events.filter((e) => e.accent === 2).map((e) => e.tick / 120);
+ expect(strong).toEqual([0, 5, 10, 15, 20, 25, 30]);
+ });
+});
+
+describe('style tags', () => {
+ it('tags patterns with their style vocabulary', () => {
+ expect(gen('gallop', 't').tags).toContain('gallop');
+ expect(gen('reverseGallop', 't').tags).toContain('gallop');
+ expect(gen('halftimeStomp', 't').tags).toContain('halftime');
+ expect(gen('sevenEight', 't').tags).toContain('odd-meter');
+ expect(gen('fiveOverFour', 't').tags).toContain('polymeter');
+ expect(gen('tremoloWall', 't').tags).toContain('tremolo');
+ expect(gen('syncopatedStabs', 't').tags).toContain('syncopated');
+ const tags = gen('chugEngine', 't').tags;
+ expect(new Set(tags).size).toBe(tags.length);
+ });
+});
diff --git a/engine/rhythm/styles.ts b/engine/rhythm/styles.ts
new file mode 100644
index 0000000..d574238
--- /dev/null
+++ b/engine/rhythm/styles.ts
@@ -0,0 +1,291 @@
+// Style presets: user-facing defaults (RhythmParams) plus the internal cell recipe each style generates from.
+// All figures are generic metal rhythm vocabulary, written from scratch; nothing is transcribed.
+// Every rate below is a hand-tuned heuristic, not a research-backed value.
+import type { RhythmParams, RhythmStyleId } from '../types';
+
+export type CellKind =
+ | 'chug16'
+ | 'chug8'
+ | 'gallop'
+ | 'reverseGallop'
+ | 'stab'
+ | 'rest'
+ | 'tripletChug'
+ | 'tremolo'
+ | 'halftime';
+
+export interface StyleRecipe {
+ /** Base weights of the cells this style draws from (modulated by density and syncopation). */
+ cells: Partial>;
+ /** Chance a non-cycle-start group head is accented. */
+ accentRate: number;
+ /** Times params.syncopation: chance a group starts with a rest so its head lands off the beat. */
+ offbeatRate: number;
+ /** Times params.syncopation: chance an accented head is anticipated and tied over the group start. */
+ pushRate: number;
+ /** Chance an accented (non-cycle-start) head moves the harmony to the next slot. */
+ harmonyMoveRate: number;
+ /** Chance the harmony advances at each new accent cycle. */
+ cycleMoveRate: number;
+ /** Chance a repeated group is redrawn instead of copying the first cycle. */
+ variation: number;
+ /** Chance the last full group of the last cycle is redrawn (turnaround). */
+ turnaround: number;
+ /** Chance a non-head hit becomes a dead (muted scratch) note. */
+ deadRate: number;
+ /** Chance a stab is choked after at most two units instead of ringing through its group. */
+ stabChoke: number;
+ /** Chance a ringing stab sustains into a silent next group through a tie. */
+ tieOverRate: number;
+ /** Target kind of accented heads and non-pedal fills. */
+ headTarget: 'slot' | 'dyad';
+ /** Groups on beats 1 and 3 always get a sustained, accented halftime chord. */
+ halftimeStrong: boolean;
+ /** The figure every pattern of the style must contain at least once. */
+ signature: CellKind | null;
+ tags: readonly string[];
+}
+
+export interface RhythmStyleInfo {
+ label: string;
+ description: string;
+ params: RhythmParams;
+}
+
+export const RHYTHM_STYLE_IDS: readonly RhythmStyleId[] = [
+ 'chugEngine',
+ 'gallop',
+ 'reverseGallop',
+ 'displacedThrees',
+ 'halftimeStomp',
+ 'tremoloWall',
+ 'syncopatedStabs',
+ 'sevenEight',
+ 'fiveOverFour'
+];
+
+const params = (style: RhythmStyleId, overrides: Partial): RhythmParams => ({
+ style,
+ meter: { numerator: 4, denominator: 4 },
+ bars: 2,
+ grid: '16th',
+ grouping: 'even',
+ density: 0.7,
+ syncopation: 0.2,
+ pedalRatio: 0.75,
+ palmMuteRatio: 0.75,
+ slotCount: 2,
+ picking: 'alternate',
+ anticipation: false,
+ ...overrides
+});
+
+const freezeInfo = (info: RhythmStyleInfo): RhythmStyleInfo => {
+ Object.freeze(info.params.meter);
+ if (typeof info.params.grouping !== 'string') Object.freeze(info.params.grouping);
+ Object.freeze(info.params);
+ return Object.freeze(info);
+};
+
+export const RHYTHM_STYLES: Readonly> = Object.freeze({
+ chugEngine: freezeInfo({
+ label: 'Chug Engine',
+ description: 'Dense palm-muted 16th chugs on the pedal; accented open chord stabs move the harmony.',
+ params: params('chugEngine', { density: 0.8, syncopation: 0.25, pedalRatio: 0.8, palmMuteRatio: 0.85 })
+ }),
+ gallop: freezeInfo({
+ label: 'Gallop',
+ description: 'An 8th and two 16ths on a muted pedal, chords on the accents. Picked down, down-up.',
+ params: params('gallop', { density: 0.7, syncopation: 0.15, pedalRatio: 0.8, palmMuteRatio: 0.8 })
+ }),
+ reverseGallop: freezeInfo({
+ label: 'Reverse Gallop',
+ description: 'Two 16ths and an 8th: the gallop turned around so each figure lands on the longer note.',
+ params: params('reverseGallop', { density: 0.7, syncopation: 0.15, pedalRatio: 0.8, palmMuteRatio: 0.8 })
+ }),
+ displacedThrees: freezeInfo({
+ label: 'Displaced Threes',
+ description: 'Accents grouped 3-3-3-3-4 sixteenths: they slide against the beat and realign every bar.',
+ params: params('displacedThrees', {
+ grouping: '3-3-3-3-4',
+ density: 0.75,
+ syncopation: 0,
+ pedalRatio: 0.85,
+ palmMuteRatio: 0.85
+ })
+ }),
+ halftimeStomp: freezeInfo({
+ label: 'Halftime Stomp',
+ description: 'Half-speed feel: big ringing chords on beats 1 and 3, sparse pedal pickups between. All downstrokes.',
+ params: params('halftimeStomp', {
+ density: 0.35,
+ syncopation: 0.15,
+ pedalRatio: 0.75,
+ palmMuteRatio: 0.6,
+ picking: 'downstrokes'
+ })
+ }),
+ tremoloWall: freezeInfo({
+ label: 'Tremolo Wall',
+ description: 'Continuous tremolo-picked dyads in 32nds; the harmony shifts on the accents.',
+ params: params('tremoloWall', { density: 0.9, syncopation: 0.1, pedalRatio: 0.1, palmMuteRatio: 0.15 })
+ }),
+ syncopatedStabs: freezeInfo({
+ label: 'Syncopated Stabs',
+ description: 'Short chord stabs on a 3-3-2 pulse, with rests and pushes tied over the beat.',
+ params: params('syncopatedStabs', {
+ grouping: '3-3-2',
+ density: 0.45,
+ syncopation: 0.75,
+ pedalRatio: 0.6,
+ palmMuteRatio: 0.6
+ })
+ }),
+ sevenEight: freezeInfo({
+ label: 'Seven Eight',
+ description: '7/8 felt as 2+2+3 eighths: two short beats and a long one, accented every group.',
+ params: params('sevenEight', {
+ meter: { numerator: 7, denominator: 8 },
+ density: 0.7,
+ syncopation: 0.2,
+ pedalRatio: 0.75,
+ palmMuteRatio: 0.8
+ })
+ }),
+ fiveOverFour: freezeInfo({
+ label: 'Five Over Four',
+ description: 'A 5-sixteenth figure looping over 4/4: its accent drifts across the barline and realigns after 5 bars.',
+ params: params('fiveOverFour', {
+ grouping: [3, 2],
+ cycleSixteenths: 5,
+ density: 0.7,
+ syncopation: 0.1,
+ pedalRatio: 0.85,
+ palmMuteRatio: 0.85
+ })
+ })
+});
+
+const recipe = (overrides: Partial & Pick): StyleRecipe => ({
+ accentRate: 0.4,
+ offbeatRate: 0.2,
+ pushRate: 0.3,
+ harmonyMoveRate: 0.5,
+ cycleMoveRate: 0.6,
+ variation: 0.12,
+ turnaround: 0.6,
+ deadRate: 0,
+ stabChoke: 0.4,
+ tieOverRate: 0.3,
+ headTarget: 'slot',
+ halftimeStrong: false,
+ signature: null,
+ ...overrides
+});
+
+export const STYLE_RECIPES: Readonly> = {
+ chugEngine: recipe({
+ cells: { chug16: 1, gallop: 0.3, chug8: 0.35, stab: 0.25, rest: 0.1 },
+ accentRate: 0.45,
+ offbeatRate: 0.3,
+ pushRate: 0.35,
+ variation: 0.15,
+ deadRate: 0.04,
+ signature: 'chug16',
+ tags: ['chug', 'palm-mute']
+ }),
+ gallop: recipe({
+ cells: { gallop: 1, chug8: 0.25, chug16: 0.2, stab: 0.2, rest: 0.05 },
+ stabChoke: 0.3,
+ signature: 'gallop',
+ tags: ['gallop']
+ }),
+ reverseGallop: recipe({
+ cells: { reverseGallop: 1, chug8: 0.25, chug16: 0.2, stab: 0.2, rest: 0.05 },
+ stabChoke: 0.3,
+ signature: 'reverseGallop',
+ tags: ['gallop', 'reverse-gallop']
+ }),
+ // No rests, offbeats or pushes: the 3-3-3-3-4 accent grid itself is the point.
+ displacedThrees: recipe({
+ cells: { chug16: 1, gallop: 0.3, chug8: 0.2, stab: 0.2 },
+ accentRate: 1,
+ offbeatRate: 0,
+ pushRate: 0,
+ variation: 0.12,
+ turnaround: 0.5,
+ stabChoke: 0.5,
+ tieOverRate: 0,
+ tags: ['displaced', 'syncopated']
+ }),
+ halftimeStomp: recipe({
+ cells: { halftime: 1, chug8: 0.2, rest: 0.15 },
+ accentRate: 0.25,
+ offbeatRate: 0.3,
+ pushRate: 0,
+ harmonyMoveRate: 0.4,
+ variation: 0.2,
+ deadRate: 0.05,
+ stabChoke: 0,
+ tieOverRate: 0.6,
+ halftimeStrong: true,
+ tags: ['halftime', 'breakdown']
+ }),
+ tremoloWall: recipe({
+ cells: { tremolo: 1, stab: 0.12, chug16: 0.1 },
+ accentRate: 0.3,
+ pushRate: 0.25,
+ harmonyMoveRate: 0.6,
+ cycleMoveRate: 0.7,
+ variation: 0.1,
+ turnaround: 0.5,
+ stabChoke: 0,
+ tieOverRate: 0.2,
+ headTarget: 'dyad',
+ signature: 'tremolo',
+ tags: ['tremolo']
+ }),
+ syncopatedStabs: recipe({
+ cells: { stab: 1, chug8: 0.35, chug16: 0.3, gallop: 0.15, rest: 0.45 },
+ accentRate: 0.85,
+ offbeatRate: 0.5,
+ pushRate: 0.45,
+ variation: 0.15,
+ deadRate: 0.12,
+ stabChoke: 0.75,
+ signature: 'stab',
+ tags: ['syncopated']
+ }),
+ // Every 2+2+3 group accented and no pushes, so the meter stays audible.
+ sevenEight: recipe({
+ cells: { chug16: 0.8, gallop: 0.6, reverseGallop: 0.25, chug8: 0.4, stab: 0.25, rest: 0.05 },
+ accentRate: 0.9,
+ offbeatRate: 0.15,
+ pushRate: 0,
+ tieOverRate: 0.2,
+ tags: ['odd-meter']
+ }),
+ // The 5-unit figure must repeat almost unchanged for the polymeter to be heard.
+ fiveOverFour: recipe({
+ cells: { chug16: 0.6, chug8: 0.6, gallop: 0.4, stab: 0.3, rest: 0.25 },
+ accentRate: 0.15,
+ offbeatRate: 0,
+ pushRate: 0,
+ harmonyMoveRate: 0.4,
+ cycleMoveRate: 0.35,
+ variation: 0.05,
+ turnaround: 0.2,
+ stabChoke: 0.5,
+ tieOverRate: 0,
+ tags: ['polymeter']
+ })
+};
+
+export const defaultRhythmParams = (style: RhythmStyleId): RhythmParams => {
+ const base = (RHYTHM_STYLES[style] ?? RHYTHM_STYLES.chugEngine).params;
+ return {
+ ...base,
+ meter: { ...base.meter },
+ grouping: typeof base.grouping === 'string' ? base.grouping : [...base.grouping]
+ };
+};
diff --git a/engine/rhythm/validate.test.ts b/engine/rhythm/validate.test.ts
new file mode 100644
index 0000000..e061713
--- /dev/null
+++ b/engine/rhythm/validate.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from 'vitest';
+import { validateRhythm } from './validate';
+import { defaultRhythmParams } from './styles';
+import type { HitTarget, RhythmEvent, RhythmParams, RhythmPattern } from '../types';
+
+const PEDAL: HitTarget = { kind: 'pedal' };
+
+const hit = (tick: number, durationTicks: number, extra: Partial = {}): RhythmEvent => ({
+ tick,
+ durationTicks,
+ target: PEDAL,
+ palmMute: true,
+ accent: 0,
+ pick: (tick / 120) % 2 === 0 ? 'down' : 'up',
+ ...extra
+});
+
+const pattern = (events: RhythmEvent[], params: Partial = {}): RhythmPattern => ({
+ id: 'test',
+ name: 'Test',
+ seed: 'test',
+ meter: { numerator: 4, denominator: 4 },
+ bars: 1,
+ ppq: 480,
+ events,
+ tags: [],
+ params: { ...defaultRhythmParams('chugEngine'), bars: 1, ...params }
+});
+
+const sixteenths = (count: number) => Array.from({ length: count }, (_, i) => hit(i * 120, 120));
+
+describe('validateRhythm errors', () => {
+ it('accepts a clean pattern', () => {
+ expect(validateRhythm(pattern(sixteenths(16)))).toEqual({ ok: true, errors: [], warnings: [] });
+ });
+
+ it('flags unsorted ticks, non-positive durations, overlaps and events past the end', () => {
+ expect(validateRhythm(pattern([hit(0, 120), hit(240, 120), hit(120, 120)])).errors.join(' ')).toMatch(/sorted/);
+ expect(validateRhythm(pattern([hit(0, 0)])).errors.join(' ')).toMatch(/duration/);
+ expect(validateRhythm(pattern([hit(0, 240), hit(120, 120)])).errors.join(' ')).toMatch(/overlap/);
+ expect(validateRhythm(pattern([hit(0, 120), hit(1800, 240)])).errors.join(' ')).toMatch(/pattern length/);
+ expect(validateRhythm(pattern([hit(0, 120), hit(1920, 120)])).errors.join(' ')).toMatch(/pattern length/);
+ });
+
+ it('flags a bar count or meter that disagrees with the params', () => {
+ expect(validateRhythm({ ...pattern(sixteenths(4)), bars: 2 }).ok).toBe(false);
+ expect(validateRhythm({ ...pattern(sixteenths(4)), meter: { numerator: 0, denominator: 4 } }).ok).toBe(false);
+ });
+
+ it('flags picks that disagree with the picking mode', () => {
+ expect(validateRhythm(pattern([hit(0, 120), hit(120, 120, { pick: 'down' })])).errors.join(' ')).toMatch(/pick/);
+ const allDown = sixteenths(4).map((e) => ({ ...e, pick: 'down' as const }));
+ expect(validateRhythm(pattern(allDown, { picking: 'downstrokes' })).ok).toBe(true);
+ expect(validateRhythm(pattern(sixteenths(4), { picking: 'downstrokes' })).ok).toBe(false);
+ });
+
+ it('requires an attack in the first beat unless anticipation is allowed', () => {
+ const late = [hit(480, 120)];
+ expect(validateRhythm(pattern(late)).errors.join(' ')).toMatch(/first beat/);
+ expect(validateRhythm(pattern(late, { anticipation: true })).ok).toBe(true);
+ const tieOnly = [hit(0, 480, { target: { kind: 'slot', slot: 0 }, palmMute: false }), hit(480, 120)];
+ expect(validateRhythm(pattern(tieOnly)).ok).toBe(true);
+ });
+
+ it('flags slot indices outside slotCount', () => {
+ const events = [hit(0, 120, { target: { kind: 'slot', slot: 2 } })];
+ expect(validateRhythm(pattern(events, { slotCount: 2 })).errors.join(' ')).toMatch(/slot/);
+ const dyad = [hit(0, 120, { target: { kind: 'dyad', slot: 1 } })];
+ expect(validateRhythm(pattern(dyad, { slotCount: 1 })).ok).toBe(false);
+ expect(validateRhythm(pattern(dyad, { slotCount: 2 })).ok).toBe(true);
+ });
+
+ it('flags a tie as first event and ties that do not continue the previous sound', () => {
+ expect(validateRhythm(pattern([hit(0, 120, { tie: true })], { anticipation: true })).errors.join(' ')).toMatch(/tie/);
+ const slot0: HitTarget = { kind: 'slot', slot: 0 };
+ const good = [hit(0, 240, { target: slot0, palmMute: false }), hit(240, 240, { target: slot0, palmMute: false, tie: true })];
+ expect(validateRhythm(pattern(good)).ok).toBe(true);
+ const gap = [hit(0, 120, { target: slot0 }), hit(240, 240, { target: slot0, tie: true })];
+ expect(validateRhythm(pattern(gap)).errors.join(' ')).toMatch(/tie/);
+ const otherTarget = [hit(0, 240, { target: slot0 }), hit(240, 240, { tie: true })];
+ expect(validateRhythm(pattern(otherTarget)).errors.join(' ')).toMatch(/tie/);
+ });
+});
+
+describe('validateRhythm tooFastForProfile', () => {
+ it('warns above the tempo where the fastest notes exceed the comfortable sixteenth tempo', () => {
+ const p = pattern(sixteenths(16));
+ expect(validateRhythm(p, { bpm: 110, comfortableSixteenthBpm: 110 }).warnings).toEqual([]);
+ const fast = validateRhythm(p, { bpm: 111, comfortableSixteenthBpm: 110 });
+ expect(fast.ok).toBe(true);
+ expect(fast.warnings).toHaveLength(1);
+ expect(fast.warnings[0]).toMatchObject({ code: 'tooFastForProfile', minIoiTicks: 120, bpmLimit: 110 });
+ });
+
+ it('scales the limit with the smallest inter-onset interval', () => {
+ const eighths = pattern(Array.from({ length: 8 }, (_, i) => hit(i * 240, 240)));
+ expect(validateRhythm(eighths, { bpm: 220, comfortableSixteenthBpm: 110 }).warnings).toEqual([]);
+ expect(validateRhythm(eighths, { bpm: 221, comfortableSixteenthBpm: 110 }).warnings[0].bpmLimit).toBe(220);
+ const thirtySeconds = pattern(Array.from({ length: 4 }, (_, i) => hit(i * 60, 60, { pick: i % 2 === 0 ? 'down' : 'up' })));
+ const w = validateRhythm(thirtySeconds, { bpm: 60, comfortableSixteenthBpm: 110 }).warnings[0];
+ expect(w).toMatchObject({ minIoiTicks: 60, bpmLimit: 55 });
+ const triplets = pattern(Array.from({ length: 6 }, (_, i) => hit(i * 80, 80)).map((e, i) => ({ ...e, pick: i % 2 === 0 ? 'down' : 'up' })));
+ expect(validateRhythm(triplets, { bpm: 74, comfortableSixteenthBpm: 110 }).warnings[0].bpmLimit).toBe(73);
+ });
+
+ it('ignores ties when measuring the inter-onset interval', () => {
+ const slot0: HitTarget = { kind: 'slot', slot: 0 };
+ const events = [
+ hit(0, 120, { target: slot0, palmMute: false }),
+ hit(120, 120, { target: slot0, palmMute: false, tie: true }),
+ hit(240, 240)
+ ];
+ const w = validateRhythm(pattern(events), { bpm: 300, comfortableSixteenthBpm: 110 }).warnings[0];
+ expect(w.minIoiTicks).toBe(240);
+ });
+
+ it('defaults to the default hand profile tempo and needs a bpm to warn', () => {
+ const p = pattern(sixteenths(16));
+ expect(validateRhythm(p, { bpm: 115 }).warnings).toHaveLength(1);
+ expect(validateRhythm(p).warnings).toEqual([]);
+ });
+});
diff --git a/engine/rhythm/validate.ts b/engine/rhythm/validate.ts
new file mode 100644
index 0000000..141f0e6
--- /dev/null
+++ b/engine/rhythm/validate.ts
@@ -0,0 +1,93 @@
+import type { RhythmPattern, RhythmValidation, RhythmWarning } from '../types';
+import { DEFAULT_HAND_PROFILE } from '../handProfile';
+import { PPQ, barTicks, firstBeatTicks } from './grid';
+import { assignPicks } from './picking';
+import { isSlotTarget, sameTarget } from './events';
+
+export interface RhythmValidateOptions {
+ /** Playback tempo; without it the tooFastForProfile check is skipped. */
+ bpm?: number;
+ /** Defaults to the default hand profile's value. */
+ comfortableSixteenthBpm?: number;
+}
+
+const MAX_PICK_ERRORS = 3;
+
+export const validateRhythm = (pattern: RhythmPattern, opts: RhythmValidateOptions = {}): RhythmValidation => {
+ const errors: string[] = [];
+ const { meter, bars, params, events } = pattern;
+
+ if (!Number.isInteger(meter.numerator) || meter.numerator < 1 || (meter.denominator !== 4 && meter.denominator !== 8)) {
+ return { ok: false, errors: [`invalid meter ${meter.numerator}/${meter.denominator}`], warnings: [] };
+ }
+ if (!Number.isInteger(bars) || bars < 1) return { ok: false, errors: [`invalid bar count ${bars}`], warnings: [] };
+ if (pattern.ppq !== PPQ) errors.push(`ppq must be ${PPQ}`);
+ if (params.bars !== bars || params.meter.numerator !== meter.numerator || params.meter.denominator !== meter.denominator) {
+ errors.push('pattern length differs from params: bars and meter must match params');
+ }
+
+ const bar = barTicks(meter);
+ const length = bars * bar;
+ events.forEach((e, i) => {
+ const at = `event ${i} (tick ${e.tick})`;
+ if (!Number.isInteger(e.tick) || e.tick < 0) errors.push(`${at}: tick must be a non-negative integer`);
+ if (!(e.durationTicks > 0)) errors.push(`${at}: duration must be > 0`);
+ if (e.tick + e.durationTicks > length) errors.push(`${at}: ends beyond the pattern length ${length}`);
+ if (e.accent !== 0 && e.accent !== 1 && e.accent !== 2) errors.push(`${at}: accent must be 0, 1 or 2`);
+ if (isSlotTarget(e.target)) {
+ const { slot } = e.target;
+ if (!Number.isInteger(slot) || slot < 0 || slot >= params.slotCount) {
+ errors.push(`${at}: slot ${slot} outside slotCount ${params.slotCount}`);
+ }
+ }
+ if (i > 0) {
+ const prev = events[i - 1];
+ if (e.tick <= prev.tick) errors.push(`${at}: ticks not sorted (previous ${prev.tick})`);
+ else if (prev.tick + prev.durationTicks > e.tick) errors.push(`${at}: overlaps the previous event`);
+ }
+ if (e.tie) {
+ const prev = events[i - 1];
+ if (!prev) errors.push(`${at}: tie as first event`);
+ else if (prev.tick + prev.durationTicks !== e.tick) errors.push(`${at}: tie does not continue the previous event`);
+ else if (!sameTarget(prev.target, e.target)) errors.push(`${at}: tie target differs from the previous event`);
+ }
+ });
+
+ const expected = assignPicks(events, params.picking, bar);
+ const wrongPicks = events.flatMap((e, i) => (e.pick === expected[i] ? [] : [i]));
+ wrongPicks.slice(0, MAX_PICK_ERRORS).forEach((i) => {
+ errors.push(`event ${i} (tick ${events[i].tick}): pick ${events[i].pick} inconsistent with ${params.picking} picking (expected ${expected[i]})`);
+ });
+ if (wrongPicks.length > MAX_PICK_ERRORS) errors.push(`${wrongPicks.length - MAX_PICK_ERRORS} more pick errors`);
+
+ if (!params.anticipation && !events.some((e) => !e.tie && e.tick < firstBeatTicks(meter))) {
+ errors.push('no attack in the first beat (set anticipation to allow a pickup feel)');
+ }
+
+ return { ok: errors.length === 0, errors, warnings: speedWarnings(pattern, opts) };
+};
+
+/**
+ * notesPerBeat = 480 / minIoi is compared with the comfortable rate 4 * comfortableSixteenthBpm / bpm.
+ * Compared without division to avoid float edges: warn when 480 * bpm > 4 * comfortable * minIoi.
+ */
+const speedWarnings = (pattern: RhythmPattern, opts: RhythmValidateOptions): RhythmWarning[] => {
+ const { bpm } = opts;
+ if (bpm === undefined || !(bpm > 0)) return [];
+ const comfortable = opts.comfortableSixteenthBpm ?? DEFAULT_HAND_PROFILE.comfortableSixteenthBpm;
+ const onsets = pattern.events.filter((e) => !e.tie).map((e) => e.tick);
+ if (onsets.length < 2) return [];
+ let minIoi = Infinity;
+ for (let i = 1; i < onsets.length; i++) minIoi = Math.min(minIoi, onsets[i] - onsets[i - 1]);
+ if (!(minIoi > 0) || PPQ * bpm <= 4 * comfortable * minIoi) return [];
+ const bpmLimit = Math.floor((4 * comfortable * minIoi) / PPQ);
+ const notesPerBeat = PPQ / minIoi;
+ return [
+ {
+ code: 'tooFastForProfile',
+ message: `The fastest notes come ${Number(notesPerBeat.toFixed(2))} per beat; above ${bpmLimit} BPM that is faster than your comfortable 16th tempo of ${comfortable} BPM.`,
+ minIoiTicks: minIoi,
+ bpmLimit
+ }
+ ];
+};
diff --git a/engine/rhythm/variation.test.ts b/engine/rhythm/variation.test.ts
new file mode 100644
index 0000000..d0a25d7
--- /dev/null
+++ b/engine/rhythm/variation.test.ts
@@ -0,0 +1,205 @@
+import { describe, it, expect } from 'vitest';
+import { mutateRhythm, regenerateRhythm } from './variation';
+import { generateRhythm } from './generate';
+import { RHYTHM_STYLE_IDS, defaultRhythmParams } from './styles';
+import { validateRhythm } from './validate';
+import { barTicks } from './grid';
+import { rhythmToPlaybackEvents } from './playback';
+import { E_STANDARD } from '../tuning';
+import type { HarmonySlot, HitTarget, RhythmEvent, RhythmPattern } from '../types';
+
+const SEEDS = ['a', 'b', 'c', 'riff-1', 'djent', '42'];
+
+const expectValid = (p: RhythmPattern) => expect(validateRhythm(p).errors).toEqual([]);
+const eventsInBar = (p: RhythmPattern, bar: number) =>
+ p.events.filter((e) => Math.floor(e.tick / barTicks(p.meter)) === bar);
+
+/** Ticks where the harmony slot changes on something other than an accented attack. */
+const unaccentedSlotChanges = (p: RhythmPattern): number[] => {
+ let current: number | null = null;
+ const bad: number[] = [];
+ for (const e of p.events) {
+ if (e.target.kind !== 'slot' && e.target.kind !== 'dyad') continue;
+ if (current !== null && e.target.slot !== current && (e.tie || e.accent === 0)) bad.push(e.tick);
+ current = e.target.slot;
+ }
+ return bad;
+};
+
+const SLOTS: HarmonySlot[] = [
+ { shape: [0, 2, 2, null, null, null], pedalMidi: 40 },
+ { shape: [1, 3, 3, null, null, null], pedalMidi: 41 },
+ { shape: [3, 5, 5, null, null, null], pedalMidi: 43 },
+ { shape: [5, 7, 7, null, null, null], pedalMidi: 45 }
+];
+
+/** What the notes that start in these bars sound like: pitches, timing, length and velocity. */
+const soundOfBars = (p: RhythmPattern, bars: readonly number[]) =>
+ rhythmToPlaybackEvents(p, SLOTS, E_STANDARD, 120).filter((e) => bars.includes(Math.floor(e.tick / barTicks(p.meter))));
+
+const LOCK_SETS = [[0], [1], [2], [1, 2]];
+
+describe('mutateRhythm', () => {
+ it('keeps validity and changes something for every style', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const seed of SEEDS) {
+ const base = generateRhythm(defaultRhythmParams(style), seed);
+ for (const m of ['m1', 'm2', 'm3', 'm4']) {
+ const mutated = mutateRhythm(base, m);
+ expectValid(mutated);
+ expect(mutated.events).not.toEqual(base.events);
+ expect(mutated.params).toEqual(base.params);
+ }
+ }
+ }
+ });
+
+ it('is deterministic per seed, varies across seeds and leaves the input alone', () => {
+ const base = generateRhythm(defaultRhythmParams('gallop'), 'mut');
+ const snapshot = JSON.stringify(base);
+ expect(mutateRhythm(base, 'x')).toEqual(mutateRhythm(base, 'x'));
+ const distinct = new Set(['1', '2', '3', '4', '5', '6'].map((s) => JSON.stringify(mutateRhythm(base, s).events)));
+ expect(distinct.size).toBeGreaterThanOrEqual(3);
+ expect(JSON.stringify(base)).toBe(snapshot);
+ });
+
+ it('is only a small change', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, 'small');
+ const mutated = mutateRhythm(base, 'small');
+ const key = (e: RhythmEvent) => JSON.stringify([e.tick, e.durationTicks, e.target, e.palmMute, e.accent, !!e.tie]);
+ const before = new Set(base.events.map(key));
+ const changed = mutated.events.filter((e) => !before.has(key(e))).length;
+ expect(changed).toBeLessThanOrEqual(Math.ceil(base.events.length / 3));
+ }
+ });
+
+ it('never makes the harmony change on an unaccented hit, also when pushes are re-rendered', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const bars of [2, 4]) {
+ for (const slotCount of [2, 3, 4]) {
+ for (let i = 0; i < 40; i++) {
+ const base = generateRhythm({ ...defaultRhythmParams(style), bars, slotCount, syncopation: 1 }, `s${i}`);
+ for (const m of ['m1', 'm6']) {
+ expect(unaccentedSlotChanges(mutateRhythm(base, m)), `${style} ${bars} ${slotCount} s${i} ${m}`).toEqual([]);
+ }
+ }
+ }
+ }
+ }
+ });
+
+ it('can still mutate a hand-edited pattern with a single slot', () => {
+ const base = generateRhythm({ ...defaultRhythmParams('chugEngine'), slotCount: 1 }, 'one');
+ const mutated = mutateRhythm(base, 'one');
+ expectValid(mutated);
+ expect(mutated.events).not.toEqual(base.events);
+ });
+});
+
+describe('regenerateRhythm', () => {
+ it('keeps every event of the locked bars exactly and stays valid', () => {
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const seed of SEEDS) {
+ const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4 }, seed);
+ for (const locked of [[0], [1], [3], [0, 2], [1, 2]]) {
+ const next = regenerateRhythm(base, `${seed}-new`, locked);
+ expectValid(next);
+ for (const bar of locked) expect(eventsInBar(next, bar)).toEqual(eventsInBar(base, bar));
+ expect(next.seed).toBe(`${seed}-new`);
+ }
+ }
+ }
+ });
+
+ it('changes the unlocked bars for most seeds', () => {
+ let changed = 0;
+ for (const seed of SEEDS) {
+ const base = generateRhythm(defaultRhythmParams('chugEngine'), seed);
+ const next = regenerateRhythm(base, `${seed}-other`, [0]);
+ if (JSON.stringify(eventsInBar(next, 1)) !== JSON.stringify(eventsInBar(base, 1))) changed++;
+ }
+ expect(changed).toBeGreaterThanOrEqual(SEEDS.length - 1);
+ });
+
+ it('equals a fresh generation when nothing is locked and the original when everything is', () => {
+ const base = generateRhythm(defaultRhythmParams('gallop'), 'base');
+ expect(regenerateRhythm(base, 'fresh', [])).toEqual(generateRhythm(defaultRhythmParams('gallop'), 'fresh'));
+ expect(regenerateRhythm(base, 'fresh', [0, 1]).events).toEqual(base.events);
+ expect(regenerateRhythm(base, 'fresh', [7, -1, 0.5])).toEqual(generateRhythm(defaultRhythmParams('gallop'), 'fresh'));
+ });
+
+ it('changes the harmony only on accented attacks, also at the edges of locked bars', () => {
+ const five = regenerateRhythm(generateRhythm(defaultRhythmParams('fiveOverFour'), 'd8'), 'e8', [0]);
+ expect(unaccentedSlotChanges(five)).toEqual([]);
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const slotCount of [2, 3, 4]) {
+ for (const i of [0, 1, 2, 3, 4, 5, 6, 7]) {
+ const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4, slotCount }, `h${i}`);
+ for (const locked of LOCK_SETS) {
+ const next = regenerateRhythm(base, `h${i}-new`, locked);
+ expect(unaccentedSlotChanges(next), `${style} h${i} slots ${slotCount} locked ${locked}`).toEqual([]);
+ }
+ }
+ }
+ }
+ });
+
+ it('turns a fresh push whose head fell in a locked bar back into an accented downbeat', () => {
+ const base = generateRhythm({ ...defaultRhythmParams('gallop'), bars: 4, slotCount: 3 }, 'q21');
+ const next = regenerateRhythm(base, 'w21', [0]);
+ expectValid(next);
+ const downbeat = next.events.find((e) => e.tick === 1920);
+ expect(downbeat?.tie).toBeUndefined();
+ expect(downbeat?.accent).toBe(2);
+ expect(next.events.some((e) => e.tie && e.palmMute)).toBe(false);
+ });
+
+ it('keeps the sound of the locked bars: pedal pitch, pushes and their ties', () => {
+ const push = generateRhythm(defaultRhythmParams('chugEngine'), 'orig2');
+ const kept = regenerateRhythm(push, 'new2', [0]);
+ expect(kept.events.find((e) => e.tick === 1920)).toMatchObject({ tie: true });
+ expect(soundOfBars(kept, [0])).toEqual(soundOfBars(push, [0]));
+ for (const style of RHYTHM_STYLE_IDS) {
+ for (const i of [0, 1, 2, 3, 4, 5]) {
+ const base = generateRhythm({ ...defaultRhythmParams(style), bars: 4, slotCount: 4 }, `orig${i}`);
+ for (const locked of LOCK_SETS) {
+ const next = regenerateRhythm(base, `new${i}`, locked);
+ expect(soundOfBars(next, locked), `${style} orig${i} locked ${locked}`).toEqual(soundOfBars(base, locked));
+ }
+ }
+ }
+ });
+
+ it('recovers a pattern whose slotCount was lowered by hand', () => {
+ const base = generateRhythm({ ...defaultRhythmParams('syncopatedStabs'), slotCount: 4 }, 'k1');
+ const stale: RhythmPattern = { ...base, params: { ...base.params, slotCount: 2 } };
+ expect(validateRhythm(stale).ok).toBe(false);
+ expectValid(regenerateRhythm(stale, 'z', [0]));
+ const folded = regenerateRhythm(stale, 'z', [0, 1]);
+ expectValid(folded);
+ const mutated = mutateRhythm(stale, 'z');
+ expectValid(mutated);
+ expect(mutated.events).not.toEqual(folded.events);
+ });
+
+ it('keeps the source of a tie that sustains into a locked bar', () => {
+ const slot1: HitTarget = { kind: 'slot', slot: 1 };
+ const base: RhythmPattern = {
+ ...generateRhythm(defaultRhythmParams('chugEngine'), 'tie'),
+ events: [
+ { tick: 0, durationTicks: 1800, target: { kind: 'slot', slot: 0 }, palmMute: false, accent: 2, pick: 'down' },
+ { tick: 1800, durationTicks: 120, target: slot1, palmMute: false, accent: 1, pick: 'up' },
+ { tick: 1920, durationTicks: 240, target: slot1, palmMute: false, accent: 0, pick: 'down', tie: true },
+ { tick: 2160, durationTicks: 1680, target: { kind: 'pedal' }, palmMute: true, accent: 0, pick: 'down' }
+ ]
+ };
+ expectValid(base);
+ for (const seed of SEEDS) {
+ const next = regenerateRhythm(base, seed, [1]);
+ expectValid(next);
+ expect(eventsInBar(next, 1)).toEqual(eventsInBar(base, 1));
+ expect(next.events.find((e) => e.tick === 1800)).toMatchObject({ target: slot1, accent: 1 });
+ }
+ });
+});
diff --git a/engine/rhythm/variation.ts b/engine/rhythm/variation.ts
new file mode 100644
index 0000000..13b1bc7
--- /dev/null
+++ b/engine/rhythm/variation.ts
@@ -0,0 +1,285 @@
+// Seeded variations of an existing pattern: small mutations and regeneration around locked bars.
+import type { HitTarget, RhythmEvent, RhythmPattern } from '../types';
+import { createRng, type Rng } from '../random';
+import { barTicks, buildGroups, type RhythmGroup } from './grid';
+import { drawPlan, generateRhythm, headAccent, makeContext, renderGroup, seedTag } from './generate';
+import {
+ copyEvent,
+ finalizeEvents,
+ finalizeOptions,
+ isSlotTarget,
+ makeEvent,
+ sameEvents,
+ slotTarget,
+ unaccentedSlotChangeCount,
+ withSlotsInRange
+} from './events';
+import { validateRhythm } from './validate';
+
+type Mutation = 'cell' | 'accent' | 'palmMute' | 'harmony';
+
+const MUTATIONS: readonly Mutation[] = ['cell', 'accent', 'palmMute', 'harmony'];
+const ATTEMPTS_PER_MUTATION = 6;
+
+const slotOf = (target: HitTarget): number => (isSlotTarget(target) ? target.slot : 0);
+
+const groupTicks = (group: RhythmGroup, unitTicks: number): [number, number] => [
+ group.startUnit * unitTicks,
+ (group.startUnit + group.length) * unitTicks
+];
+
+/** Re-renders one accent group with a newly drawn cell; an accented chord there keeps its target and accent. */
+const mutateCell = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] => {
+ const ctx = makeContext(pattern.params);
+ const group = rng.pick(buildGroups(pattern.params, pattern.seed));
+ const [start, end] = groupTicks(group, ctx.unitTicks);
+ const inside = pattern.events.filter((e) => e.tick >= start && e.tick < end);
+ const outside = pattern.events.filter((e) => e.tick < start || e.tick >= end);
+ const accented = inside.find((e) => !e.tie && e.accent > 0 && isSlotTarget(e.target));
+ const before = pattern.events.filter((e) => e.tick < start && isSlotTarget(e.target)).pop();
+ const slot = accented ? slotOf(accented.target) : before ? slotOf(before.target) : 0;
+
+ let plan = drawPlan(rng, group, ctx);
+ if (accented) plan = { ...plan, kind: plan.kind === 'rest' ? 'stab' : plan.kind, accent: true };
+ const { recipe } = ctx;
+ const fresh = renderGroup(plan, group, ctx).map((hit) => {
+ const accent = hit.role === 'head' ? (accented ? accented.accent : headAccent(plan, group, ctx)) : 0;
+ if (accent > 0) {
+ const target = accented && !hit.tremolo ? accented.target : slotTarget(hit.tremolo ? 'dyad' : recipe.headTarget, slot);
+ return makeEvent(hit.tick, hit.dur, target, false, accent);
+ }
+ const target: HitTarget = hit.dead
+ ? { kind: 'dead' }
+ : hit.tremolo
+ ? slotTarget('dyad', slot)
+ : plan.fillPedal
+ ? { kind: 'pedal' }
+ : slotTarget(recipe.headTarget, slot);
+ return makeEvent(hit.tick, hit.dur, target, !hit.dead && plan.palmMute, 0);
+ });
+ return [...outside, ...fresh];
+};
+
+/** Moves one accented chord hit onto an adjacent pedal or dead hit (target travels with the accent). */
+const mutateAccent = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => {
+ const ev = pattern.events;
+ const isPlainFill = (e: RhythmEvent | undefined) =>
+ !!e && !e.tie && e.accent === 0 && (e.target.kind === 'pedal' || e.target.kind === 'dead');
+ const options: [number, number][] = [];
+ ev.forEach((e, i) => {
+ if (e.tie || e.accent === 0 || e.tick === 0) return;
+ for (const j of [i - 1, i + 1]) if (isPlainFill(ev[j])) options.push([i, j]);
+ });
+ if (options.length === 0) return null;
+ const [i, j] = rng.pick(options);
+ const out = ev.map(copyEvent);
+ out[i] = { ...out[i], target: { ...ev[j].target }, palmMute: ev[j].palmMute, accent: 0 };
+ out[j] = { ...out[j], target: { ...ev[i].target }, palmMute: false, accent: ev[i].accent };
+ // The chord moved away, so ties that sustained it go too.
+ let k = i + 1;
+ while (k < out.length && out[k].tie) k++;
+ return [...out.slice(0, i + 1), ...out.slice(k)];
+};
+
+/** Flips palm mute on the unaccented hits of one group, toward whichever state is rarer there. */
+const mutatePalmMute = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => {
+ const { unitTicks } = makeContext(pattern.params);
+ const ev = pattern.events;
+ const eligible = buildGroups(pattern.params, pattern.seed)
+ .map((g) => {
+ const [start, end] = groupTicks(g, unitTicks);
+ return ev.flatMap((e, i) => (e.tick >= start && e.tick < end && !e.tie && e.accent === 0 && e.target.kind !== 'dead' ? [i] : []));
+ })
+ .filter((list) => list.length > 0);
+ if (eligible.length === 0) return null;
+ const indices = rng.pick(eligible);
+ const muted = indices.filter((i) => ev[i].palmMute).length;
+ const value = muted * 2 < indices.length;
+ return ev.map((e, i) => (indices.includes(i) ? { ...copyEvent(e), palmMute: value } : copyEvent(e)));
+};
+
+/** Re-harmonises one accent (not the first) and the unaccented chord hits that follow it. */
+const mutateHarmony = (pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => {
+ const { slotCount } = pattern.params;
+ const ev = pattern.events;
+ const accents = ev.flatMap((e, i) => (!e.tie && e.accent > 0 && isSlotTarget(e.target) ? [i] : []));
+ if (slotCount < 2 || accents.length < 2) return null;
+ const start = rng.pick(accents.slice(1));
+ const next = (slotOf(ev[start].target) + 1 + rng.int(0, slotCount - 2)) % slotCount;
+ const out = ev.map(copyEvent);
+ for (let k = start; k < out.length; k++) {
+ const e = out[k];
+ if (k > start && !e.tie && e.accent > 0 && isSlotTarget(e.target)) break;
+ if (isSlotTarget(e.target)) out[k] = { ...e, target: slotTarget(e.target.kind, next) };
+ }
+ return out;
+};
+
+const applyMutation = (kind: Mutation, pattern: RhythmPattern, rng: Rng): RhythmEvent[] | null => {
+ switch (kind) {
+ case 'cell':
+ return mutateCell(pattern, rng);
+ case 'accent':
+ return mutateAccent(pattern, rng);
+ case 'palmMute':
+ return mutatePalmMute(pattern, rng);
+ default:
+ return mutateHarmony(pattern, rng);
+ }
+};
+
+/**
+ * Small seeded change: re-render one group, move one accent, flip palm mute on one group, or re-harmonise one
+ * accent. Mutations are tried in a seeded order; the first that changes the events, stays valid and adds no
+ * slot change on an unaccented hit wins (re-rendering a group can drop the next group's pushed accent).
+ * Slots outside params.slotCount are folded back first.
+ */
+export const mutateRhythm = (input: RhythmPattern, seed: string): RhythmPattern => {
+ const pattern = withSlotsInRange(input);
+ const rng = createRng(`mutate:${seed}`);
+ const opts = finalizeOptions(pattern);
+ const base = { ...pattern, tags: [...pattern.tags], id: `${pattern.id.split('~')[0]}~${seedTag(seed)}` };
+ const harmonyBreaks = unaccentedSlotChangeCount(pattern.events);
+ for (const kind of rng.shuffle(MUTATIONS)) {
+ for (let attempt = 0; attempt < ATTEMPTS_PER_MUTATION; attempt++) {
+ const events = applyMutation(kind, pattern, rng.fork(`${kind}-${attempt}`));
+ if (!events) break;
+ const candidate: RhythmPattern = { ...base, events: finalizeEvents(events, opts) };
+ if (
+ !sameEvents(candidate.events, pattern.events) &&
+ validateRhythm(candidate).ok &&
+ unaccentedSlotChangeCount(candidate.events) <= harmonyBreaks
+ ) {
+ return candidate;
+ }
+ }
+ }
+ return { ...base, events: pattern.events.map(copyEvent) };
+};
+
+/** Slot of the last slot/dyad event before `tick`, or 0 (playback's active slot before any chord). */
+const activeSlotBefore = (events: readonly RhythmEvent[], tick: number): number => {
+ let slot = 0;
+ for (const e of events) {
+ if (e.tick >= tick) break;
+ if (isSlotTarget(e.target)) slot = e.target.slot;
+ }
+ return slot;
+};
+
+interface MergedEvent {
+ e: RhythmEvent;
+ kept: boolean;
+}
+
+/**
+ * Re-harmonises the regenerated stretches between kept ones so that the slot changes only on accented attacks
+ * and each kept stretch is entered on the slot it had in the old pattern (its pedal and dead hits take their
+ * pitch from that slot). Inside a regenerated stretch, unaccented chord hits follow the current slot; the last
+ * accent before a kept stretch that depends on it is moved to the slot it needs, or, without one, the first
+ * chord hit of the stretch is accented to make the change (a stretch with no chord hit at all is left as is).
+ */
+const isChordAttack = (e: RhythmEvent): boolean => isSlotTarget(e.target) && !e.tie;
+
+const withSlot = (e: RhythmEvent, slot: number): RhythmEvent =>
+ isSlotTarget(e.target) ? { ...e, target: slotTarget(e.target.kind, slot) } : e;
+
+const reharmonise = (items: MergedEvent[], old: readonly RhythmEvent[]): void => {
+ let current: number | null = null;
+ let i = 0;
+ while (i < items.length) {
+ if (items[i].kept) {
+ const { target } = items[i].e;
+ if (isSlotTarget(target)) current = target.slot;
+ i++;
+ continue;
+ }
+ const start = i;
+ while (i < items.length && !items[i].kept) i++;
+ const run = items.slice(start, i);
+ for (const item of run) {
+ const { e } = item;
+ if (!isChordAttack(e) || !isSlotTarget(e.target)) continue;
+ if (e.accent > 0 || current === null) current = e.target.slot;
+ else if (e.target.slot !== current) item.e = withSlot(e, current);
+ }
+ if (i >= items.length) break;
+
+ // The kept stretch depends on the entry slot unless it opens with an accented chord attack.
+ const opener = items[i].e;
+ if (isSlotTarget(opener.target) && !opener.tie && opener.accent > 0) continue;
+ const need = activeSlotBefore(old, opener.tick);
+ if ((current ?? 0) === need) continue;
+ let from = run.map(({ e }) => isChordAttack(e) && e.accent > 0).lastIndexOf(true);
+ if (from < 0) from = run.findIndex(({ e }) => isChordAttack(e));
+ if (from < 0) continue;
+ if (run[from].e.accent === 0) run[from].e = { ...run[from].e, accent: 1, palmMute: false };
+ for (const item of run.slice(from)) if (isChordAttack(item.e)) item.e = withSlot(item.e, need);
+ current = need;
+ }
+};
+
+/**
+ * Generates with a new seed but keeps every event whose onset lies in a locked bar, unchanged. A locked tie
+ * also keeps the chain back to the attack it sustains, and a kept attack keeps the ties that sustain it past
+ * the bar (a push into the next bar stays tied over its downbeat); new events that would start inside a kept
+ * event's sound are dropped. A new tie whose attack was dropped becomes an attack with that attack's sound,
+ * so a pushed accent that fell into a locked bar still lands on its downbeat. Harmony is then re-planned
+ * around the kept bars (see reharmonise). Picks reset at each barline, so locked bars keep their picks too.
+ * Slots outside params.slotCount are folded back first.
+ */
+export const regenerateRhythm = (input: RhythmPattern, seed: string, lockedBars: readonly number[]): RhythmPattern => {
+ const pattern = withSlotsInRange(input);
+ const fresh = generateRhythm(pattern.params, seed);
+ const locked = [...new Set(lockedBars.filter((b) => Number.isInteger(b) && b >= 0 && b < pattern.bars))].sort((a, b) => a - b);
+ if (locked.length === 0) return fresh;
+
+ const bar = barTicks(pattern.meter);
+ const inLocked = (tick: number) => locked.includes(Math.floor(tick / bar));
+ const old = pattern.events;
+ const keep = new Set(old.flatMap((e, i) => (inLocked(e.tick) ? [i] : [])));
+ for (const i of [...keep]) {
+ if (!old[i].tie) continue;
+ for (let j = i - 1; j >= 0 && !keep.has(j); j--) {
+ keep.add(j);
+ if (!old[j].tie) break;
+ }
+ }
+ for (const i of [...keep].sort((a, b) => a - b)) {
+ for (let j = i + 1; j < old.length && old[j].tie && !keep.has(j); j++) keep.add(j);
+ }
+ const kept = old.filter((_, i) => keep.has(i)).map(copyEvent);
+ const covered = (tick: number) => kept.some((k) => tick >= k.tick && tick < k.tick + k.durationTicks);
+
+ const added: RhythmEvent[] = [];
+ let source: RhythmEvent | null = null;
+ let previousAdded = false;
+ for (const e of fresh.events) {
+ if (!e.tie) source = e;
+ const dropped = inLocked(e.tick) || covered(e.tick);
+ if (!dropped) {
+ added.push(
+ !e.tie || previousAdded || !source
+ ? copyEvent(e)
+ : makeEvent(e.tick, e.durationTicks, source.target, source.palmMute, source.accent)
+ );
+ }
+ previousAdded = !dropped;
+ }
+
+ const items: MergedEvent[] = [
+ ...kept.map((e) => ({ e, kept: true })),
+ ...added.map((e) => ({ e, kept: false }))
+ ].sort((a, b) => a.e.tick - b.e.tick);
+ reharmonise(items, old);
+
+ return {
+ ...fresh,
+ id: `${fresh.id}-keep-${locked.join('.')}`,
+ events: finalizeEvents(
+ items.map((item) => item.e),
+ finalizeOptions(fresh)
+ ),
+ tags: [...new Set([...fresh.tags, ...pattern.tags])]
+ };
+};
diff --git a/engine/shape.test.ts b/engine/shape.test.ts
new file mode 100644
index 0000000..56d0998
--- /dev/null
+++ b/engine/shape.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest';
+import {
+ shapeToMidi,
+ shapeToMidiByString,
+ bassMidi,
+ shapePitchClasses,
+ soundingStrings,
+ frettedStrings,
+ openStrings,
+ interiorMutes,
+ lowestFret,
+ highestFret,
+ isValidShape,
+ toRiffForgeTab,
+ fromRiffForgeTab,
+ toChordExplorerFrets,
+ fromChordExplorerFrets
+} from './shape';
+import { E_STANDARD, DROP_D } from './tuning';
+import { midiToName } from './pitch';
+
+describe('shapeToMidi', () => {
+ it('derives pitches as openMidi + fret', () => {
+ expect(shapeToMidi([0, 2, 2, 1, 0, 0], E_STANDARD).map(midiToName)).toEqual(['E2', 'B2', 'E3', 'G#3', 'B3', 'E4']);
+ });
+
+ it('sounds D2 A2 D3 for 0 0 0 x x x in Drop D', () => {
+ expect(shapeToMidi([0, 0, 0, null, null, null], DROP_D).map(midiToName)).toEqual(['D2', 'A2', 'D3']);
+ });
+
+ it('keeps muted strings as null per string', () => {
+ expect(shapeToMidiByString([null, 3, 2, 0, 1, 0], E_STANDARD)).toEqual([null, 48, 52, 55, 60, 64]);
+ });
+
+ it('throws when the shape does not fit the tuning', () => {
+ expect(() => shapeToMidi([0, 2, 2], E_STANDARD)).toThrow();
+ expect(() => shapeToMidi([0, -1, 2, 2, 0, 0], E_STANDARD)).toThrow();
+ });
+
+ it('finds the bass by pitch, not by string', () => {
+ // String 0 at fret 7 (B2) sounds above open string 1 (A2)
+ expect(bassMidi([7, 0, null, null, null, null], E_STANDARD)).toBe(45);
+ expect(bassMidi([null, null, null, null, null, null], E_STANDARD)).toBeNull();
+ });
+
+ it('collects sorted unique pitch classes', () => {
+ expect(shapePitchClasses([0, 2, 2, null, null, null], E_STANDARD)).toEqual([4, 11]);
+ });
+});
+
+describe('shape helpers', () => {
+ const shape = [0, null, 2, 4, null, 0];
+
+ it('lists sounding, fretted and open strings', () => {
+ expect(soundingStrings(shape)).toEqual([0, 2, 3, 5]);
+ expect(frettedStrings(shape)).toEqual([2, 3]);
+ expect(openStrings(shape)).toEqual([0, 5]);
+ });
+
+ it('counts interior mutes only between sounding strings', () => {
+ expect(interiorMutes(shape)).toBe(2);
+ expect(interiorMutes([null, null, 2, 2, null, null])).toBe(0);
+ expect(interiorMutes([0, null, null, null, null, null])).toBe(0);
+ });
+
+ it('finds the fretted range ignoring opens', () => {
+ expect(lowestFret(shape)).toBe(2);
+ expect(highestFret(shape)).toBe(4);
+ expect(lowestFret([0, 0, 0, null, null, null])).toBeNull();
+ });
+
+ it('validates shapes', () => {
+ expect(isValidShape([0, 2, 2, 1, 0, 0], 6)).toBe(true);
+ expect(isValidShape([0, 2, 2, 1, 0], 6)).toBe(false);
+ expect(isValidShape([0, 2.5, 2, 1, 0, 0], 6)).toBe(false);
+ });
+});
+
+describe('adapters', () => {
+ it('round-trips RiffForge tabs low -> high', () => {
+ expect(toRiffForgeTab([0, 2, 2, null, null, null])).toBe('0 2 2 x x x');
+ expect(fromRiffForgeTab('0 2 2 x x x')).toEqual([0, 2, 2, null, null, null]);
+ expect(fromRiffForgeTab(' 10 12 X x x x ')).toEqual([10, 12, null, null, null, null]);
+ expect(fromRiffForgeTab('0 2 a')).toBeNull();
+ expect(fromRiffForgeTab('')).toBeNull();
+ });
+
+ it('round-trips Chord Explorer frets high -> low with -1 muted', () => {
+ // C major open: x 3 2 0 1 0 low -> high
+ const cOpen = [null, 3, 2, 0, 1, 0];
+ expect(toChordExplorerFrets(cOpen)).toEqual([0, 1, 0, 2, 3, -1]);
+ expect(fromChordExplorerFrets([0, 1, 0, 2, 3, -1])).toEqual(cOpen);
+ });
+});
diff --git a/engine/shape.ts b/engine/shape.ts
new file mode 100644
index 0000000..343e89f
--- /dev/null
+++ b/engine/shape.ts
@@ -0,0 +1,97 @@
+// Shapes and the single shape -> sounding MIDI conversion, plus app adapters.
+import type { Shape, Tuning } from './types';
+import { pitchClass } from './pitch';
+
+const isFret = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0;
+
+export const isValidShape = (shape: Shape, strings: number): boolean =>
+ Array.isArray(shape) && shape.length === strings && shape.every((f) => f === null || isFret(f));
+
+const assertShape = (shape: Shape, tuning: Tuning): void => {
+ if (!isValidShape(shape, tuning.openMidi.length)) {
+ throw new Error(`Shape ${JSON.stringify(shape)} does not fit a ${tuning.openMidi.length}-string tuning`);
+ }
+};
+
+/** MIDI per string, low -> high; null for muted strings. */
+export const shapeToMidiByString = (shape: Shape, tuning: Tuning): (number | null)[] => {
+ assertShape(shape, tuning);
+ return shape.map((fret, s) => (fret === null ? null : tuning.openMidi[s] + fret));
+};
+
+/**
+ * The only shape -> pitch conversion in the engine. Tabs and audio must both go through it.
+ * Returns sounding notes in string order (lowest string first), muted strings skipped.
+ */
+export const shapeToMidi = (shape: Shape, tuning: Tuning): number[] =>
+ shapeToMidiByString(shape, tuning).filter((m): m is number => m !== null);
+
+export const bassMidi = (shape: Shape, tuning: Tuning): number | null => {
+ const notes = shapeToMidi(shape, tuning);
+ return notes.length === 0 ? null : Math.min(...notes);
+};
+
+/** Sorted unique pitch classes sounding in the shape. */
+export const shapePitchClasses = (shape: Shape, tuning: Tuning): number[] =>
+ [...new Set(shapeToMidi(shape, tuning).map(pitchClass))].sort((a, b) => a - b);
+
+export const soundingStrings = (shape: Shape): number[] =>
+ shape.flatMap((fret, s) => (fret === null ? [] : [s]));
+
+export const frettedStrings = (shape: Shape): number[] =>
+ shape.flatMap((fret, s) => (fret !== null && fret > 0 ? [s] : []));
+
+export const openStrings = (shape: Shape): number[] => shape.flatMap((fret, s) => (fret === 0 ? [s] : []));
+
+/** Muted strings strictly between the lowest and the highest sounding string. */
+export const interiorMutes = (shape: Shape): number => {
+ const sounding = soundingStrings(shape);
+ if (sounding.length < 2) return 0;
+ const lo = sounding[0];
+ const hi = sounding[sounding.length - 1];
+ let count = 0;
+ for (let s = lo + 1; s < hi; s++) if (shape[s] === null) count++;
+ return count;
+};
+
+export const lowestFret = (shape: Shape): number | null => {
+ const frets = shape.filter((f): f is number => f !== null && f > 0);
+ return frets.length === 0 ? null : Math.min(...frets);
+};
+
+export const highestFret = (shape: Shape): number | null => {
+ const frets = shape.filter((f): f is number => f !== null && f > 0);
+ return frets.length === 0 ? null : Math.max(...frets);
+};
+
+// ---------------------------------------------------------------------------
+// Adapters
+// ---------------------------------------------------------------------------
+
+/** RiffForge tab: space-separated, low -> high, "x" = muted. Also the canonical shape key. */
+export const toRiffForgeTab = (shape: Shape): string => shape.map((f) => (f === null ? 'x' : String(f))).join(' ');
+
+export const shapeKey = toRiffForgeTab;
+
+/** Parses "0 2 2 x x x" (low -> high). Returns null on malformed input. */
+export const fromRiffForgeTab = (tab: string): Shape | null => {
+ const tokens = tab.trim().split(/\s+/);
+ if (tokens.length === 0 || tokens[0] === '') return null;
+ const shape: (number | null)[] = [];
+ for (const token of tokens) {
+ if (token === 'x' || token === 'X') {
+ shape.push(null);
+ } else if (/^\d{1,2}$/.test(token)) {
+ shape.push(parseInt(token, 10));
+ } else {
+ return null;
+ }
+ }
+ return shape;
+};
+
+/** Chord Explorer frets: high -> low string order, -1 = muted. */
+export const toChordExplorerFrets = (shape: Shape): number[] => [...shape].reverse().map((f) => (f === null ? -1 : f));
+
+export const fromChordExplorerFrets = (frets: readonly number[]): Shape =>
+ [...frets].reverse().map((f) => (f < 0 ? null : f));
diff --git a/engine/transition.test.ts b/engine/transition.test.ts
new file mode 100644
index 0000000..6f20713
--- /dev/null
+++ b/engine/transition.test.ts
@@ -0,0 +1,788 @@
+import { describe, it, expect } from 'vitest';
+import {
+ TRANSITION_WEIGHTS,
+ transitionCost,
+ minimaxViterbi,
+ optimizeSequence,
+ describeTransition
+} from './transition';
+import { DEFAULT_SCALE_LENGTH_MM, fingertipMm } from './geometry';
+import { fromRiffForgeTab } from './shape';
+import { createRng } from './random';
+import { enumerateFingerings, findBestFingering } from './fingering';
+import { DEFAULT_HAND_PROFILE } from './handProfile';
+import type {
+ Barre,
+ CostBreakdown,
+ FingeredShape,
+ FingerNumber,
+ FingeringMetrics,
+ SequenceTransition,
+ TransitionBreakdown
+} from './types';
+
+const L = DEFAULT_SCALE_LENGTH_MM;
+const W = TRANSITION_WEIGHTS;
+const tip = (fret: number) => fingertipMm(fret, L);
+
+const ZERO_METRICS: FingeringMetrics = {
+ frettedCount: 0,
+ fingerCount: 0,
+ spanMm: 0,
+ allowedSpanMm: 0,
+ reachRatio: 0,
+ pairs: [],
+ maxPairRatio: 0,
+ maxPairStretch: 0,
+ usesPinky: false,
+ usesThumb: false,
+ contortions: 0,
+ interiorMutes: 0,
+ lowestFret: null,
+ highestFret: null
+};
+
+const ZERO_BREAKDOWN: CostBreakdown = { total: 0, physical: 0, rightHand: 0, musical: 0, terms: [] };
+
+/** Hand-built fingered shape, independent of fingering.ts: fingered('x 3 5 5 x x', 'x 1 3 4 x x'). */
+const fingered = (tab: string, fingerTab: string, barres: Barre[] = []): FingeredShape => {
+ const shape = fromRiffForgeTab(tab);
+ if (shape === null) throw new Error(`bad tab ${tab}`);
+ const fingers = fingerTab
+ .trim()
+ .split(/\s+/)
+ .map((t) => (t === 'x' ? null : (Number(t) as FingerNumber)));
+ if (fingers.length !== shape.length) throw new Error(`finger tab ${fingerTab} does not match ${tab}`);
+ return { shape, fingering: { fingers, barres, metrics: { ...ZERO_METRICS }, costBreakdown: ZERO_BREAKDOWN } };
+};
+
+const cost = (a: FingeredShape, b: FingeredShape, timeSec = 1) => transitionCost(a, b, { scaleLengthMm: L, timeSec });
+
+const EPS = 1e-9;
+
+type EdgeFn = (layer: number, from: number, to: number) => number;
+type NodeFn = (layer: number, index: number) => number;
+
+/** Reference: enumerate every path in lexicographic order, keep the first one that is best by (max, sum). */
+const bruteForceMinimax = (sizes: readonly number[], edge: EdgeFn, cyclic: boolean, nodeCost?: NodeFn) => {
+ const n = sizes.length;
+ let best: { path: number[]; maxCost: number; sumCost: number } | null = null;
+ const visit = (path: number[]) => {
+ if (path.length < n) {
+ for (let k = 0; k < sizes[path.length]; k++) visit([...path, k]);
+ return;
+ }
+ const edges: number[] = [];
+ for (let i = 0; i + 1 < n; i++) edges.push(edge(i, path[i], path[i + 1]));
+ if (cyclic) edges.push(edge(n - 1, path[n - 1], path[0]));
+ const maxCost = edges.length === 0 ? 0 : Math.max(...edges);
+ let sumCost = edges.reduce((s, c) => s + c, 0);
+ if (nodeCost) path.forEach((k, i) => (sumCost += nodeCost(i, k)));
+ if (
+ best === null ||
+ maxCost < best.maxCost - EPS ||
+ (Math.abs(maxCost - best.maxCost) <= EPS && sumCost < best.sumCost - EPS)
+ ) {
+ best = { path, maxCost, sumCost };
+ }
+ };
+ visit([]);
+ return best!;
+};
+
+/** Reference: plain min-sum path (what an ordinary Viterbi would pick), lexicographically first on ties. */
+const bruteForceMinSum = (sizes: readonly number[], edge: EdgeFn) => {
+ let best: { path: number[]; sumCost: number } | null = null;
+ const visit = (path: number[]) => {
+ if (path.length < sizes.length) {
+ for (let k = 0; k < sizes[path.length]; k++) visit([...path, k]);
+ return;
+ }
+ let sumCost = 0;
+ for (let i = 0; i + 1 < path.length; i++) sumCost += edge(i, path[i], path[i + 1]);
+ if (best === null || sumCost < best.sumCost - EPS) best = { path, sumCost };
+ };
+ visit([]);
+ return best!;
+};
+
+/** m[layer][from][to] = edge cost from layer `layer` to the next layer (the last one closes the cycle). */
+const fromMatrices =
+ (m: number[][][]): EdgeFn =>
+ (layer, from, to) =>
+ m[layer][from][to];
+
+// ---------------------------------------------------------------------------
+// transitionCost
+// ---------------------------------------------------------------------------
+
+describe('TRANSITION_WEIGHTS', () => {
+ it('keeps every weight non-negative and the anchor discount below 1', () => {
+ for (const value of Object.values(W)) expect(value).toBeGreaterThanOrEqual(0);
+ expect(W.maxAnchorDiscount).toBeLessThan(1);
+ expect(W.slideTravelFactor).toBeLessThanOrEqual(1);
+ expect(W.minTimeSec).toBeCloseTo(0.05, 12);
+ });
+});
+
+describe('transitionCost', () => {
+ it('costs nothing between identical shapes and counts every held finger as an anchor', () => {
+ const eMajor = fingered('0 2 2 1 0 0', 'x 2 3 1 x x');
+ const t = cost(eMajor, eMajor);
+ expect(t).toEqual({
+ fingerTravelMm: 0,
+ stringChanges: 0,
+ lifted: 0,
+ placed: 0,
+ anchors: 3,
+ slides: 0,
+ positionShiftMm: 0,
+ raw: 0,
+ timeSec: 1,
+ cost: 0
+ });
+ });
+
+ it('counts a barre finger once (at its lowest string) when the shape is held', () => {
+ const fBarre = fingered('1 3 3 2 1 1', '1 3 4 2 1 1', [{ finger: 1, fret: 1, fromString: 0, toString: 5 }]);
+ const t = cost(fBarre, fBarre);
+ expect(t.anchors).toBe(4);
+ expect(t.raw).toBe(0);
+ });
+
+ it('places a barre finger at its lowest string', () => {
+ const barre = fingered('x x 3 3 3 x', 'x x 1 1 1 x', [{ finger: 1, fret: 3, fromString: 2, toString: 4 }]);
+ const single = fingered('x x 3 x x x', 'x x 1 x x x');
+ const highSingle = fingered('x x x x 3 x', 'x x x x 1 x');
+ expect(cost(barre, single).anchors).toBe(1);
+ const t = cost(barre, highSingle);
+ expect(t.anchors).toBe(0);
+ expect(t.stringChanges).toBe(2);
+ expect(t.fingerTravelMm).toBe(0);
+ });
+
+ it('counts lifted and placed fingers', () => {
+ const three = fingered('x 3 5 5 x x', 'x 1 3 4 x x');
+ const two = fingered('x 3 5 x x x', 'x 1 3 x x x');
+ const down = cost(three, two);
+ expect(down).toMatchObject({ lifted: 1, placed: 0, anchors: 2, slides: 0, stringChanges: 0 });
+ const up = cost(two, three);
+ expect(up).toMatchObject({ lifted: 0, placed: 1, anchors: 2 });
+ // Placing a finger is slower than lifting one.
+ expect(up.raw).toBeGreaterThan(down.raw);
+ });
+
+ it('counts string changes for fingers kept across the change', () => {
+ // Open E major -> open A minor: the same hand frame moved one string over.
+ const eMajor = fingered('0 2 2 1 0 0', 'x 2 3 1 x x');
+ const aMinor = fingered('x 0 2 2 1 0', 'x x 2 3 1 x');
+ const t = cost(eMajor, aMinor);
+ expect(t).toMatchObject({ stringChanges: 3, fingerTravelMm: 0, anchors: 0, slides: 0, lifted: 0, placed: 0, positionShiftMm: 0 });
+ expect(t.raw).toBeCloseTo(3 * W.stringChange, 12);
+ });
+
+ it('measures slides, finger travel and the hand position shift in mm', () => {
+ const at3 = fingered('x 3 5 5 x x', 'x 1 3 4 x x');
+ const at5 = fingered('x 5 7 7 x x', 'x 1 3 4 x x');
+ const t = cost(at3, at5);
+ const travel = tip(5) - tip(3) + 2 * (tip(7) - tip(5));
+ const shift = tip(5) - tip(3);
+ expect(t.slides).toBe(3);
+ expect(t.anchors).toBe(0);
+ expect(t.stringChanges).toBe(0);
+ expect(t.fingerTravelMm).toBeCloseTo(travel, 9);
+ expect(t.positionShiftMm).toBeCloseTo(shift, 9);
+ expect(t.raw).toBeCloseTo(W.fingerTravelPerMm * W.slideTravelFactor * travel + W.positionShiftPerMm * shift, 12);
+ // Moving down is the same distance.
+ expect(cost(at5, at3).raw).toBeCloseTo(t.raw, 12);
+ });
+
+ it('makes a slide cheaper than lifting and placing fingers for the same notes', () => {
+ const from = fingered('x 3 5 x x x', 'x 1 3 x x x');
+ const slide = cost(from, fingered('x 5 7 x x x', 'x 1 3 x x x'));
+ const lift = cost(from, fingered('x 5 7 x x x', 'x 2 4 x x x'));
+ expect(slide).toMatchObject({ slides: 2, lifted: 0, placed: 0 });
+ expect(lift).toMatchObject({ slides: 0, lifted: 2, placed: 2 });
+ expect(slide.positionShiftMm).toBeCloseTo(lift.positionShiftMm, 12);
+ expect(slide.raw).toBeLessThan(lift.raw);
+ });
+
+ it('discounts the cost of a change that keeps an anchor finger down', () => {
+ const from = fingered('x 3 5 x x x', 'x 1 3 x x x');
+ const to = fingered('x 3 x 6 x x', 'x 1 x 4 x x');
+ const t = cost(from, to);
+ expect(t).toMatchObject({ anchors: 1, lifted: 1, placed: 1, positionShiftMm: 0 });
+ expect(t.raw).toBeCloseTo((W.lifted + W.placed) * (1 - W.anchorDiscount), 12);
+ // Same lift and place without the anchor costs the full amount.
+ const noAnchor = cost(fingered('x x 5 x x x', 'x x 3 x x x'), fingered('x x x 6 x x', 'x x x 4 x x'));
+ expect(noAnchor.raw).toBeGreaterThan(t.raw);
+ });
+
+ it('caps the anchor discount', () => {
+ const from = fingered('3 5 5 4 3 3', '1 3 4 2 1 1', [{ finger: 1, fret: 3, fromString: 0, toString: 5 }]);
+ const to = fingered('3 5 5 4 3 x', '1 3 4 2 1 x', [{ finger: 1, fret: 3, fromString: 0, toString: 4 }]);
+ const moved = fingered('3 5 5 x x x', '1 3 4 x x x');
+ // Three anchors (fingers 1, 3, 4) and one finger lifted (finger 2); three anchors exceed the cap.
+ expect(3 * W.anchorDiscount).toBeGreaterThan(W.maxAnchorDiscount);
+ const t = cost(from, moved);
+ expect(t).toMatchObject({ anchors: 3, lifted: 1 });
+ expect(t.raw).toBeCloseTo(W.lifted * (1 - W.maxAnchorDiscount), 12);
+ expect(cost(from, to).raw).toBe(0);
+ });
+
+ it('ignores open strings and reports no position shift when a shape has no fretted note', () => {
+ const power = fingered('0 2 2 x x x', 'x 1 2 x x x');
+ const open = fingered('0 x x x x x', 'x x x x x x');
+ const t = cost(power, open);
+ expect(t).toMatchObject({ lifted: 2, placed: 0, positionShiftMm: 0, fingerTravelMm: 0 });
+ expect(t.raw).toBeCloseTo(2 * W.lifted, 12);
+ expect(cost(open, open).raw).toBe(0);
+ });
+
+ it('does not track the thumb as a finger', () => {
+ const withThumb = fingered('3 x 5 5 4 x', '0 x 3 4 2 x');
+ const without = fingered('x x 5 5 4 x', 'x x 3 4 2 x');
+ const t = cost(withThumb, without);
+ expect(t).toMatchObject({ lifted: 0, placed: 0, anchors: 3 });
+ });
+
+ it('keeps raw non-negative', () => {
+ const shapes = [
+ fingered('0 2 2 1 0 0', 'x 2 3 1 x x'),
+ fingered('x 3 5 5 x x', 'x 1 3 4 x x'),
+ fingered('x 0 2 2 1 0', 'x x 2 3 1 x'),
+ fingered('1 3 3 2 1 1', '1 3 4 2 1 1', [{ finger: 1, fret: 1, fromString: 0, toString: 5 }]),
+ fingered('0 0 0 x x x', 'x x x x x x')
+ ];
+ for (const a of shapes) for (const b of shapes) expect(cost(a, b).raw).toBeGreaterThanOrEqual(0);
+ });
+
+ it('scales by the available time: half the time doubles the cost', () => {
+ const a = fingered('x 3 5 5 x x', 'x 1 3 4 x x');
+ const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x');
+ const slow = cost(a, b, 1);
+ const fast = cost(a, b, 0.5);
+ expect(fast.raw).toBeCloseTo(slow.raw, 12);
+ expect(fast.cost).toBeCloseTo(2 * slow.cost, 12);
+ expect(slow.cost).toBeCloseTo(slow.raw, 12);
+ expect(fast.timeSec).toBe(0.5);
+ });
+
+ it('floors the available time at 0.05 s', () => {
+ const a = fingered('x 3 5 5 x x', 'x 1 3 4 x x');
+ const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x');
+ for (const timeSec of [0, 0.01, 0.05]) {
+ const t = cost(a, b, timeSec);
+ expect(t.timeSec).toBeCloseTo(0.05, 12);
+ expect(t.cost).toBeCloseTo(t.raw / 0.05, 9);
+ }
+ });
+
+ it('uses the scale length for distances', () => {
+ const a = fingered('x 3 x x x x', 'x 1 x x x x');
+ const b = fingered('x 8 x x x x', 'x 1 x x x x');
+ const short = transitionCost(a, b, { scaleLengthMm: 24.75 * 25.4, timeSec: 1 });
+ const long = transitionCost(a, b, { scaleLengthMm: 27 * 25.4, timeSec: 1 });
+ expect(long.positionShiftMm).toBeGreaterThan(short.positionShiftMm);
+ });
+
+ it('rejects invalid timing and scale length', () => {
+ const a = fingered('x 3 x x x x', 'x 1 x x x x');
+ expect(() => transitionCost(a, a, { scaleLengthMm: L, timeSec: Number.NaN })).toThrow(/timeSec/);
+ expect(() => transitionCost(a, a, { scaleLengthMm: 0, timeSec: 1 })).toThrow(/scaleLengthMm/);
+ });
+
+ it('is deterministic', () => {
+ const a = fingered('0 2 2 1 0 0', 'x 2 3 1 x x');
+ const b = fingered('x 5 7 7 x x', 'x 1 3 4 x x');
+ expect(cost(a, b, 0.4)).toEqual(cost(a, b, 0.4));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// minimaxViterbi
+// ---------------------------------------------------------------------------
+
+describe('minimaxViterbi', () => {
+ it('minimizes the hardest edge where the sum-optimal path differs (counterexample)', () => {
+ // Layer 1 has two candidates: an easy-then-brutal route (1, 10) and an even route (6, 6).
+ const sizes = [1, 2, 1];
+ const edge = fromMatrices([[[1, 6]], [[10], [6]]]);
+ const sumOptimal = bruteForceMinSum(sizes, edge);
+ expect(sumOptimal).toEqual({ path: [0, 0, 0], sumCost: 11 });
+ const result = minimaxViterbi(sizes, edge);
+ expect(result).toEqual({ path: [0, 1, 0], maxCost: 6, sumCost: 12 });
+ });
+
+ it('stays exact where keeping one (max, sum) prefix per node would fail', () => {
+ // At the third layer the prefix via [0, 0] has max 4 (sum 8), the one via [1, 1] max 5 (sum 6).
+ // The last edge (6) dominates both maxima, so the lower sum must win: [1, 1, 0, 0].
+ const sizes = [2, 2, 1, 1];
+ const edge = fromMatrices([
+ [
+ [4, 100],
+ [100, 1]
+ ],
+ [[4], [5]],
+ [[6]]
+ ]);
+ const result = minimaxViterbi(sizes, edge);
+ expect(result).toEqual({ path: [1, 1, 0, 0], maxCost: 6, sumCost: 12 });
+ });
+
+ it('breaks equal maxima by the sum, even toward a higher index', () => {
+ const sizes = [1, 3, 1];
+ const edge = fromMatrices([[[5, 2, 5]], [[4], [5], [1]]]);
+ expect(minimaxViterbi(sizes, edge)).toEqual({ path: [0, 2, 0], maxCost: 5, sumCost: 6 });
+ });
+
+ it('breaks equal maxima and sums by the lower index, with a 1e-9 tolerance', () => {
+ const sizes = [1, 3, 1];
+ expect(minimaxViterbi(sizes, fromMatrices([[[2, 2, 2]], [[2], [2], [2]]])).path).toEqual([0, 0, 0]);
+ const nearTie = fromMatrices([[[2, 2, 2]], [[2 + 5e-10], [2], [2]]]);
+ expect(minimaxViterbi(sizes, nearTie).path).toEqual([0, 0, 0]);
+ const realGap = fromMatrices([[[2, 2, 2]], [[2 + 1e-6], [2], [2]]]);
+ expect(minimaxViterbi(sizes, realGap).path).toEqual([0, 1, 0]);
+ });
+
+ it('lets node costs join only the sum tie-break', () => {
+ const sizes = [1, 2, 1];
+ const nodeCost: NodeFn = (layer, index) => (layer === 1 && index === 0 ? 100 : 0);
+ const maxWins = minimaxViterbi(sizes, fromMatrices([[[3, 4]], [[3], [1]]]), { nodeCost });
+ expect(maxWins).toEqual({ path: [0, 0, 0], maxCost: 3, sumCost: 106 });
+ const tie = minimaxViterbi(sizes, fromMatrices([[[3, 3]], [[3], [3]]]), {
+ nodeCost: (layer, index) => (layer === 1 && index === 0 ? 1 : 0)
+ });
+ expect(tie).toEqual({ path: [0, 1, 0], maxCost: 3, sumCost: 6 });
+ });
+
+ it('includes the closing edge in cyclic mode, which can change the path', () => {
+ const sizes = [2, 1];
+ // a0 -> b costs 1, a1 -> b costs 2; closing b -> a0 costs 10, b -> a1 costs 2.
+ const edge = fromMatrices([[[1], [2]], [[10, 2]]]);
+ expect(minimaxViterbi(sizes, edge)).toEqual({ path: [0, 0], maxCost: 1, sumCost: 1 });
+ expect(minimaxViterbi(sizes, edge, { cyclic: false })).toEqual({ path: [0, 0], maxCost: 1, sumCost: 1 });
+ expect(minimaxViterbi(sizes, edge, { cyclic: true })).toEqual({ path: [1, 0], maxCost: 2, sumCost: 4 });
+ });
+
+ it('uses the self edge for a single cyclic layer', () => {
+ const edge = fromMatrices([
+ [
+ [3, 0, 0],
+ [0, 1, 0],
+ [0, 0, 2]
+ ]
+ ]);
+ expect(minimaxViterbi([3], edge, { cyclic: true })).toEqual({ path: [1], maxCost: 1, sumCost: 1 });
+ });
+
+ it('handles a single acyclic layer and an empty graph', () => {
+ const never: EdgeFn = () => {
+ throw new Error('no edges expected');
+ };
+ expect(minimaxViterbi([3], never)).toEqual({ path: [0], maxCost: 0, sumCost: 0 });
+ expect(minimaxViterbi([3], never, { nodeCost: (_, i) => [2, 0, 1][i] })).toEqual({ path: [1], maxCost: 0, sumCost: 0 });
+ expect(minimaxViterbi([], never)).toEqual({ path: [], maxCost: 0, sumCost: 0 });
+ expect(minimaxViterbi([], never, { cyclic: true })).toEqual({ path: [], maxCost: 0, sumCost: 0 });
+ });
+
+ it('evaluates every edge exactly once', () => {
+ const sizes = [3, 2, 4];
+ for (const cyclic of [false, true]) {
+ const seen = new Map();
+ const edge: EdgeFn = (layer, from, to) => {
+ const key = `${layer}:${from}:${to}`;
+ seen.set(key, (seen.get(key) ?? 0) + 1);
+ return (layer * 7 + from * 3 + to * 5) % 4;
+ };
+ minimaxViterbi(sizes, edge, { cyclic });
+ const expected = 3 * 2 + 2 * 4 + (cyclic ? 4 * 3 : 0);
+ expect(seen.size).toBe(expected);
+ expect([...seen.values()].every((count) => count === 1)).toBe(true);
+ }
+ });
+
+ it('rejects empty layers and invalid costs', () => {
+ expect(() => minimaxViterbi([2, 0, 1], () => 0)).toThrow(/layer 1/);
+ expect(() => minimaxViterbi([2, 2], () => Number.NaN)).toThrow(/NaN/);
+ expect(() => minimaxViterbi([2, 2], () => -Infinity)).toThrow(/-Infinity/);
+ expect(() => minimaxViterbi([2], () => 0, { nodeCost: () => Number.NaN })).toThrow(/finite/);
+ expect(() => minimaxViterbi([2], () => 0, { nodeCost: () => Infinity })).toThrow(/finite/);
+ });
+
+ it('treats +Infinity edges as forbidden unless every path needs one', () => {
+ const avoidable = fromMatrices([[[Infinity, 3]], [[0], [3]]]);
+ expect(minimaxViterbi([1, 2, 1], avoidable)).toEqual({ path: [0, 1, 0], maxCost: 3, sumCost: 6 });
+ const unavoidable = fromMatrices([[[Infinity, Infinity]], [[1], [0]]]);
+ expect(minimaxViterbi([1, 2, 1], unavoidable)).toEqual({ path: [0, 0, 0], maxCost: Infinity, sumCost: Infinity });
+ expect(minimaxViterbi([2], () => Infinity, { cyclic: true })).toEqual({ path: [0], maxCost: Infinity, sumCost: Infinity });
+ });
+
+ it('matches brute force on seeded random graphs (acyclic and cyclic, with and without node costs)', () => {
+ for (let trial = 0; trial < 400; trial++) {
+ const rng = createRng(`minimax-${trial}`);
+ const n = rng.int(1, 5);
+ const sizes = Array.from({ length: n }, () => rng.int(1, 3));
+ const cyclic = trial % 2 === 0;
+ // Small integer costs force many exact ties, which exercises the tie-break rules.
+ const matrices = sizes.map((size, i) =>
+ Array.from({ length: size }, () => Array.from({ length: sizes[(i + 1) % n] }, () => rng.int(0, 4)))
+ );
+ const nodes = sizes.map((size) => Array.from({ length: size }, () => rng.int(0, 2)));
+ const nodeCost: NodeFn | undefined = trial % 4 < 2 ? (layer, index) => nodes[layer][index] : undefined;
+ const edge = fromMatrices(matrices);
+ const expected = bruteForceMinimax(sizes, edge, cyclic, nodeCost);
+ const actual = minimaxViterbi(sizes, edge, { cyclic, nodeCost });
+ expect(actual, `trial ${trial}`).toEqual(expected);
+ }
+ });
+
+ it('matches brute force on seeded random real-valued graphs', () => {
+ for (let trial = 0; trial < 200; trial++) {
+ const rng = createRng(`minimax-real-${trial}`);
+ const n = rng.int(2, 5);
+ const sizes = Array.from({ length: n }, () => rng.int(1, 4));
+ const cyclic = trial % 2 === 1;
+ const matrices = sizes.map((size, i) =>
+ Array.from({ length: size }, () => Array.from({ length: sizes[(i + 1) % n] }, () => rng.next() * 10))
+ );
+ const edge = fromMatrices(matrices);
+ const expected = bruteForceMinimax(sizes, edge, cyclic);
+ const actual = minimaxViterbi(sizes, edge, { cyclic });
+ expect(actual.path, `trial ${trial}`).toEqual(expected.path);
+ expect(actual.maxCost).toBeCloseTo(expected.maxCost, 9);
+ expect(actual.sumCost).toBeCloseTo(expected.sumCost, 9);
+ }
+ });
+
+ it('is deterministic', () => {
+ const edge = fromMatrices([
+ [
+ [1, 2],
+ [2, 1]
+ ],
+ [
+ [2, 1],
+ [1, 2]
+ ]
+ ]);
+ const first = minimaxViterbi([2, 2], edge, { cyclic: true });
+ expect(minimaxViterbi([2, 2], edge, { cyclic: true })).toEqual(first);
+ expect(first.path).toEqual([0, 0]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// optimizeSequence
+// ---------------------------------------------------------------------------
+
+const POWER = (fret: number) => fingered(`x ${fret} ${fret + 2} ${fret + 2} x x`, 'x 1 3 4 x x');
+
+describe('optimizeSequence', () => {
+ const pool = {
+ eMajor: fingered('0 2 2 1 0 0', 'x 2 3 1 x x'),
+ aMinor: fingered('x 0 2 2 1 0', 'x x 2 3 1 x'),
+ power3: POWER(3),
+ power5: POWER(5),
+ power8: POWER(8),
+ dyad7: fingered('x x 7 9 x x', 'x x 1 3 x x'),
+ cluster: fingered('x 2 4 5 x x', 'x 1 3 4 x x'),
+ em9: fingered('0 2 4 0 0 0', 'x 1 3 x x x')
+ };
+
+ it('matches a brute-force minimax over transitionCost (cyclic by default)', () => {
+ const slots = [
+ [pool.eMajor, pool.power5, pool.cluster],
+ [pool.aMinor, pool.power8, pool.dyad7],
+ [pool.em9, pool.power3, pool.power5]
+ ];
+ const timing = { bpm: 120, beatsPerSlot: [2, 1, 1] };
+ for (const cyclic of [true, false]) {
+ const edge: EdgeFn = (layer, from, to) =>
+ transitionCost(slots[layer][from], slots[(layer + 1) % slots.length][to], {
+ scaleLengthMm: L,
+ timeSec: (timing.beatsPerSlot[layer] * 60) / timing.bpm
+ }).cost;
+ const expected = bruteForceMinimax(
+ slots.map((s) => s.length),
+ edge,
+ cyclic
+ );
+ const result = cyclic ? optimizeSequence(slots, timing) : optimizeSequence(slots, { ...timing, cyclic: false });
+ expect(result.path).toEqual(expected.path);
+ expect(result.maxCost).toBeCloseTo(expected.maxCost, 12);
+ expect(result.sumCost).toBeCloseTo(expected.sumCost, 12);
+ }
+ });
+
+ it('picks different paths with and without the loop-back change', () => {
+ // Acyclic: 5 -> 7 is the shorter first move. Looping, 12 -> 5 is the worst jump, so start at 10.
+ const slots = [[POWER(5), POWER(10)], [POWER(7)], [POWER(12)]];
+ const timing = { bpm: 100, beatsPerSlot: [4, 4, 4] };
+ const open = optimizeSequence(slots, { ...timing, cyclic: false });
+ const loop = optimizeSequence(slots, timing);
+ expect(open.path).toEqual([0, 0, 0]);
+ expect(loop.path).toEqual([1, 0, 0]);
+ expect(open.transitions).toHaveLength(2);
+ expect(loop.transitions).toHaveLength(3);
+ expect(loop.transitions[2]).toMatchObject({ from: 2, to: 0 });
+ });
+
+ it('returns transitions in path order, timed by the slot being left, plus the hardest one', () => {
+ const slots = [[pool.power3], [pool.power8], [pool.eMajor]];
+ const result = optimizeSequence(slots, { bpm: 120, beatsPerSlot: [2, 1, 4] });
+ expect(result.path).toEqual([0, 0, 0]);
+ expect(result.transitions.map((t) => [t.from, t.to])).toEqual([
+ [0, 1],
+ [1, 2],
+ [2, 0]
+ ]);
+ expect(result.transitions.map((t) => t.breakdown.timeSec)).toEqual([1, 0.5, 2]);
+ for (const t of result.transitions) expect(t.cost).toBe(t.breakdown.cost);
+ const costs = result.transitions.map((t) => t.cost);
+ expect(result.maxCost).toBe(Math.max(...costs));
+ expect(result.sumCost).toBeCloseTo(costs.reduce((s, c) => s + c, 0), 12);
+ expect(result.hardest).toBe(result.transitions[costs.indexOf(Math.max(...costs))]);
+ expect(result.hardest).toMatchObject({ from: 1, to: 2 });
+ });
+
+ it('with one cyclic slot picks the cheapest self-transition (the first candidate)', () => {
+ const result = optimizeSequence([[pool.power5, pool.eMajor]], { bpm: 120, beatsPerSlot: [4] });
+ expect(result.path).toEqual([0]);
+ expect(result.maxCost).toBe(0);
+ expect(result.transitions).toHaveLength(1);
+ expect(result.transitions[0]).toMatchObject({ from: 0, to: 0, cost: 0 });
+ });
+
+ it('with one acyclic slot returns the first candidate and no transitions', () => {
+ const result = optimizeSequence([[pool.power5, pool.eMajor]], { bpm: 120, beatsPerSlot: [4], cyclic: false });
+ expect(result).toEqual({
+ path: [0],
+ maxCost: 0,
+ sumCost: 0,
+ transitions: [],
+ hardest: null,
+ fingerings: [pool.power5.fingering]
+ });
+ });
+
+ it('returns an empty result for no slots', () => {
+ expect(optimizeSequence([], { bpm: 120, beatsPerSlot: [] })).toEqual({
+ path: [],
+ maxCost: 0,
+ sumCost: 0,
+ transitions: [],
+ hardest: null,
+ fingerings: []
+ });
+ });
+
+ it('throws a clear error for a slot without candidates and for bad timing', () => {
+ expect(() => optimizeSequence([[pool.power3], []], { bpm: 120, beatsPerSlot: [1, 1] })).toThrow(/slot 2/);
+ expect(() => optimizeSequence([[pool.power3]], { bpm: 0, beatsPerSlot: [1] })).toThrow(/bpm/);
+ expect(() => optimizeSequence([[pool.power3], [pool.power5]], { bpm: 120, beatsPerSlot: [1] })).toThrow(/beatsPerSlot/);
+ expect(() => optimizeSequence([[pool.power3]], { bpm: 120, beatsPerSlot: [-1] })).toThrow(/beatsPerSlot/);
+ });
+
+ it('honours the scale length option', () => {
+ const slots = [[pool.power3], [pool.power8]];
+ const timing = { bpm: 120, beatsPerSlot: [1, 1], cyclic: false };
+ const short = optimizeSequence(slots, timing, { scaleLengthMm: 24.75 * 25.4 });
+ const long = optimizeSequence(slots, timing, { scaleLengthMm: 27 * 25.4 });
+ expect(long.maxCost).toBeGreaterThan(short.maxCost);
+ });
+
+ it('is deterministic', () => {
+ const slots = [
+ [pool.eMajor, pool.power5, pool.cluster, pool.power3],
+ [pool.aMinor, pool.power8, pool.dyad7],
+ [pool.em9, pool.power3]
+ ];
+ const timing = { bpm: 140, beatsPerSlot: [1, 2, 1] };
+ expect(optimizeSequence(slots, timing)).toEqual(optimizeSequence(slots, timing));
+ });
+
+ it('without a profile keeps every candidate fingering as given and reports it per slot', () => {
+ const slots = [[pool.eMajor, pool.power5], [pool.aMinor, pool.dyad7]];
+ const result = optimizeSequence(slots, { bpm: 120, beatsPerSlot: [1, 1] });
+ result.path.forEach((k, i) => expect(result.fingerings[i]).toBe(slots[i][k].fingering));
+ });
+});
+
+describe('optimizeSequence with a hand profile (fingerings chosen with the neighbours)', () => {
+ const P = DEFAULT_HAND_PROFILE;
+ /** What the generator attaches: the statically best fingering for the default profile. */
+ const best = (tab: string): FingeredShape => {
+ const shape = fromRiffForgeTab(tab);
+ if (shape === null) throw new Error(`bad tab ${tab}`);
+ const result = findBestFingering(shape, P);
+ if (!result.ok) throw new Error(`no fingering for ${tab}`);
+ return { shape, fingering: result.fingering };
+ };
+ const power = best('x 5 7 7 x x');
+ const dyad = best('x x 7 7 x x');
+ const dyadUp = best('x x 12 12 x x');
+
+ it('starts from the fingerings the generator attaches (index+middle dyad, index/ring/pinky power chord)', () => {
+ expect(power.fingering.fingers).toEqual([null, 1, 3, 4, null, null]);
+ expect(dyad.fingering.fingers).toEqual([null, null, 1, 2, null, null]);
+ });
+
+ it('makes adding the root under a held dyad cheaper than sliding the dyad 5 frets', () => {
+ const timing = { bpm: 60, beatsPerSlot: [1, 1], cyclic: false };
+ const addRoot = optimizeSequence([[dyad], [power]], timing, { profile: P });
+ const liftRoot = optimizeSequence([[power], [dyad]], timing, { profile: P });
+ const jump = optimizeSequence([[dyad], [dyadUp]], timing, { profile: P });
+ expect(addRoot.maxCost).toBeLessThan(jump.maxCost);
+ expect(liftRoot.maxCost).toBeLessThan(jump.maxCost);
+ // Two fingers stay down; only the index comes and goes (index-middle may span two frets by default).
+ expect(addRoot.fingerings[0].fingers).toEqual([null, null, 2, 3, null, null]);
+ expect(addRoot.fingerings[1].fingers).toEqual([null, 1, 2, 3, null, null]);
+ expect(addRoot.transitions[0].breakdown).toMatchObject({ anchors: 2, placed: 1, lifted: 0, stringChanges: 0 });
+ expect(liftRoot.fingerings[1].fingers).toEqual([null, null, 2, 3, null, null]);
+ expect(liftRoot.transitions[0].breakdown).toMatchObject({ anchors: 2, placed: 0, lifted: 1, stringChanges: 0 });
+ // The jump keeps the index+middle grip and slides it.
+ expect(jump.fingerings.map((f) => f.fingers)).toEqual([dyad.fingering.fingers, dyadUp.fingering.fingers]);
+ });
+
+ it('reports the real move as the hardest change of a looping riff', () => {
+ const result = optimizeSequence([[power], [dyad, best('x 12 12 x x x')]], { bpm: 120, beatsPerSlot: [1, 1] }, { profile: P });
+ expect(result.path).toEqual([0, 0]);
+ expect(result.fingerings.map((f) => f.fingers)).toEqual([
+ [null, 1, 2, 3, null, null],
+ [null, null, 2, 3, null, null]
+ ]);
+ const shift = tip(7) - tip(5);
+ const addIndex = ((W.placed + W.positionShiftPerMm * shift) * (1 - 2 * W.anchorDiscount)) / 0.5;
+ expect(result.maxCost).toBeCloseTo(addIndex, 9);
+ expect(describeTransition(result.hardest!)).toBe(
+ 'Hardest change: slot 2 -> 1, hand shifts 54 mm, 1 finger placed in 0.5 s, 2 fingers stay down'
+ );
+ });
+
+ it('keeps the attached fingering (same object) when no alternative makes the changes easier', () => {
+ // Dyad slides: index+pinky (or middle+pinky) on both dyads moves exactly like index+ring, so nothing is gained.
+ const slots = [[best('x x 7 9 x x')], [best('x x 9 11 x x')], [best('x x 5 7 x x')]];
+ const timing = { bpm: 120, beatsPerSlot: [1, 1, 2] };
+ const plain = optimizeSequence(slots, timing);
+ const result = optimizeSequence(slots, timing, { profile: P });
+ result.fingerings.forEach((f, i) => expect(f).toBe(slots[i][0].fingering));
+ expect(result).toEqual(plain);
+ });
+
+ it('never does worse than the attached fingerings and stays consistent with what it reports', () => {
+ const slots = [
+ [best('0 2 2 1 0 0'), best('x 5 7 7 x x'), best('x x 7 7 x x')],
+ [best('x 0 2 2 1 0'), best('x x 7 9 x x'), best('x 3 5 x x x')],
+ [best('x x 5 x x x'), best('x 2 4 5 x x'), best('x x 12 12 x x')]
+ ];
+ for (const cyclic of [true, false]) {
+ const timing = { bpm: 150, beatsPerSlot: [1, 2, 1], cyclic };
+ const plain = optimizeSequence(slots, timing);
+ const result = optimizeSequence(slots, timing, { profile: P });
+ expect(result.maxCost).toBeLessThanOrEqual(plain.maxCost + EPS);
+ result.path.forEach((k, i) => {
+ const chosen = result.fingerings[i];
+ const own = slots[i][k];
+ const valid = enumerateFingerings(own.shape, P).some((f) => JSON.stringify(f.fingers) === JSON.stringify(chosen.fingers));
+ expect(chosen === own.fingering || valid).toBe(true);
+ });
+ for (const t of result.transitions) {
+ const a = { shape: slots[t.from][result.path[t.from]].shape, fingering: result.fingerings[t.from] };
+ const b = { shape: slots[t.to][result.path[t.to]].shape, fingering: result.fingerings[t.to] };
+ expect(t.breakdown).toEqual(transitionCost(a, b, { scaleLengthMm: P.scaleLengthMm, timeSec: (timing.beatsPerSlot[t.from] * 60) / 150 }));
+ }
+ const costs = result.transitions.map((t) => t.cost);
+ expect(result.maxCost).toBe(Math.max(...costs));
+ expect(result.sumCost).toBeCloseTo(costs.reduce((s, c) => s + c, 0), 12);
+ expect(optimizeSequence(slots, timing, { profile: P })).toEqual(result);
+ }
+ });
+
+ it('takes the scale length from the profile unless one is given', () => {
+ const slots = [[dyad], [dyadUp]];
+ const timing = { bpm: 120, beatsPerSlot: [1, 1], cyclic: false };
+ const longNeck = { ...P, scaleLengthMm: 27 * 25.4 };
+ const fromProfile = optimizeSequence(slots, timing, { profile: longNeck });
+ expect(fromProfile.maxCost).toBeCloseTo(optimizeSequence(slots, timing, { scaleLengthMm: 27 * 25.4 }).maxCost, 12);
+ expect(optimizeSequence(slots, timing, { profile: longNeck, scaleLengthMm: L }).maxCost).toBeCloseTo(
+ optimizeSequence(slots, timing).maxCost,
+ 12
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// describeTransition
+// ---------------------------------------------------------------------------
+
+const breakdown = (overrides: Partial): TransitionBreakdown => ({
+ fingerTravelMm: 0,
+ stringChanges: 0,
+ lifted: 0,
+ placed: 0,
+ anchors: 0,
+ slides: 0,
+ positionShiftMm: 0,
+ raw: 0,
+ timeSec: 0.5,
+ cost: 0,
+ ...overrides
+});
+
+const transition = (from: number, to: number, overrides: Partial): SequenceTransition => ({
+ from,
+ to,
+ cost: 0,
+ breakdown: breakdown(overrides)
+});
+
+describe('describeTransition', () => {
+ it('names the slots 1-based with the hand shift and the time', () => {
+ expect(describeTransition(transition(1, 2, { positionShiftMm: 41.3 }))).toBe(
+ 'Hardest change: slot 2 -> 3, hand shifts 41 mm in 0.5 s'
+ );
+ });
+
+ it('lists placed and lifted fingers, string changes, slides and held fingers', () => {
+ const t = transition(0, 1, {
+ positionShiftMm: 12.6,
+ placed: 2,
+ lifted: 1,
+ stringChanges: 1,
+ slides: 1,
+ anchors: 2,
+ timeSec: 0.428571
+ });
+ expect(describeTransition(t)).toBe(
+ 'Hardest change: slot 1 -> 2, hand shifts 13 mm, 2 fingers placed, 1 finger lifted, 1 string change, 1 slide in 0.43 s, 2 fingers stay down'
+ );
+ });
+
+ it('says when nothing moves', () => {
+ expect(describeTransition(transition(0, 0, { anchors: 1, timeSec: 2 }))).toBe(
+ 'Hardest change: slot 1 -> 1, no movement in 2 s, 1 finger stays down'
+ );
+ expect(describeTransition(transition(2, 0, { positionShiftMm: 0.2, timeSec: 1 }))).toBe(
+ 'Hardest change: slot 3 -> 1, no movement in 1 s'
+ );
+ });
+
+ it('accepts a custom label', () => {
+ expect(describeTransition(transition(0, 1, { placed: 1, timeSec: 1 }), 'Change')).toBe(
+ 'Change: slot 1 -> 2, 1 finger placed in 1 s'
+ );
+ });
+
+ it('describes a real optimized sequence', () => {
+ const result = optimizeSequence([[POWER(3)], [POWER(8)]], { bpm: 120, beatsPerSlot: [1, 1], cyclic: false });
+ const shift = Math.round(tip(8) - tip(3));
+ expect(describeTransition(result.hardest!)).toBe(`Hardest change: slot 1 -> 2, hand shifts ${shift} mm, 3 slides in 0.5 s`);
+ });
+});
+
+describe('describeTransition without a change', () => {
+ it('handles a single-chord sequence that has no hardest change', () => {
+ expect(describeTransition(null)).toBe('Hardest change: none (one chord)');
+ });
+});
diff --git a/engine/transition.ts b/engine/transition.ts
new file mode 100644
index 0000000..954d9bc
--- /dev/null
+++ b/engine/transition.ts
@@ -0,0 +1,490 @@
+// Chord-change cost between fingered shapes, and a minimax Viterbi that picks one voicing per slot so the
+// hardest change of the riff is as easy as possible (idea after Hori and Sagayama, ISMIR 2016), with the
+// total as tie-break. The change-cost model and its weights are heuristic, not research-backed.
+import type {
+ Fingering,
+ FingeredShape,
+ FingerNumber,
+ HandProfile,
+ SequenceResult,
+ SequenceTiming,
+ SequenceTransition,
+ TransitionBreakdown
+} from './types';
+import { DEFAULT_SCALE_LENGTH_MM, fingertipMm } from './geometry';
+import { enumerateFingerings } from './fingering';
+import { lowestFret } from './shape';
+
+export interface TransitionWeights {
+ fingerTravelPerMm: number;
+ slideTravelFactor: number;
+ stringChange: number;
+ lifted: number;
+ placed: number;
+ positionShiftPerMm: number;
+ anchorDiscount: number;
+ maxAnchorDiscount: number;
+ minTimeSec: number;
+}
+
+/**
+ * raw = (fingerTravelPerMm * (moveTravel + slideTravelFactor * slideTravel)
+ * + stringChange * stringChanges + lifted * lifted + placed * placed
+ * + positionShiftPerMm * positionShiftMm) * (1 - min(maxAnchorDiscount, anchorDiscount * anchors))
+ * cost = raw / max(minTimeSec, timeSec)
+ * Every term is non-negative and the anchor factor stays positive, so raw >= 0 and identical shapes cost 0.
+ */
+export const TRANSITION_WEIGHTS: Readonly = {
+ // Per mm of along-neck fingertip travel, summed over fingers used in both shapes (200 mm total = 1).
+ // Lower than the hand shift because the fingers mostly ride along with the hand.
+ fingerTravelPerMm: 0.005,
+ // Travel of a finger that stays on its string (a guide-finger slide) counts at this fraction: it keeps
+ // contact and leads the hand, so a short slide stays cheaper than a lift plus a place.
+ slideTravelFactor: 0.5,
+ // Per string a kept finger moves across; it has to come off the string and land again.
+ stringChange: 0.25,
+ // Per finger released; letting go is quick.
+ lifted: 0.15,
+ // Per finger newly placed; aiming a fresh finger is the slow part of most chord changes.
+ placed: 0.5,
+ // Per mm the hand position (lowest fretted fingertip) moves (100 mm = 1). The main cost of position jumps.
+ positionShiftPerMm: 0.01,
+ // Each anchor (same finger, string and fret held through the change) steadies the hand and cuts the
+ // whole change cost by this fraction ...
+ anchorDiscount: 0.2,
+ // ... up to this total, so a change never becomes free while something still moves.
+ maxAnchorDiscount: 0.5,
+ // Available time is floored here (about a 16th note at 300 bpm) so near-zero gaps do not explode the cost.
+ minTimeSec: 0.05
+};
+
+/** The thumb wraps the neck and follows the hand; its movement is covered by positionShiftMm. */
+const TRACKED_FINGERS: readonly FingerNumber[] = [1, 2, 3, 4];
+
+interface Placement {
+ string: number;
+ fret: number;
+}
+
+/** Where each finger 1..4 presses; a multi-string finger (barre) is placed at its lowest string. */
+const placements = ({ shape, fingering }: FingeredShape): (Placement | null)[] => {
+ const out: (Placement | null)[] = [null, null, null, null, null];
+ const count = Math.min(shape.length, fingering.fingers.length);
+ for (let s = 0; s < count; s++) {
+ const finger = fingering.fingers[s];
+ const fret = shape[s];
+ if (finger === null || finger === 0 || fret === null || fret <= 0) continue;
+ if (out[finger] === null) out[finger] = { string: s, fret };
+ }
+ return out;
+};
+
+export const transitionCost = (
+ a: FingeredShape,
+ b: FingeredShape,
+ opts: { scaleLengthMm: number; timeSec: number }
+): TransitionBreakdown => {
+ const { scaleLengthMm, timeSec } = opts;
+ if (!(scaleLengthMm > 0) || !Number.isFinite(scaleLengthMm)) {
+ throw new Error(`transitionCost: scaleLengthMm must be a positive number, got ${scaleLengthMm}`);
+ }
+ if (Number.isNaN(timeSec)) throw new Error('transitionCost: timeSec must be a number, got NaN');
+
+ const w = TRANSITION_WEIGHTS;
+ const tip = (fret: number) => fingertipMm(fret, scaleLengthMm);
+ const from = placements(a);
+ const to = placements(b);
+
+ let moveTravelMm = 0;
+ let slideTravelMm = 0;
+ let stringChanges = 0;
+ let lifted = 0;
+ let placed = 0;
+ let anchors = 0;
+ let slides = 0;
+ for (const finger of TRACKED_FINGERS) {
+ const p = from[finger];
+ const q = to[finger];
+ if (p === null && q === null) continue;
+ if (q === null) {
+ lifted++;
+ continue;
+ }
+ if (p === null) {
+ placed++;
+ continue;
+ }
+ const travel = Math.abs(tip(q.fret) - tip(p.fret));
+ stringChanges += Math.abs(q.string - p.string);
+ if (p.string !== q.string) {
+ moveTravelMm += travel;
+ } else if (p.fret === q.fret) {
+ anchors++;
+ } else {
+ slides++;
+ slideTravelMm += travel;
+ }
+ }
+
+ const lowA = lowestFret(a.shape);
+ const lowB = lowestFret(b.shape);
+ const positionShiftMm = lowA === null || lowB === null ? 0 : Math.abs(tip(lowB) - tip(lowA));
+
+ const base =
+ w.fingerTravelPerMm * (moveTravelMm + w.slideTravelFactor * slideTravelMm) +
+ w.stringChange * stringChanges +
+ w.lifted * lifted +
+ w.placed * placed +
+ w.positionShiftPerMm * positionShiftMm;
+ const raw = base * (1 - Math.min(w.maxAnchorDiscount, w.anchorDiscount * anchors));
+ // Reported time is the effective (floored) time, so cost === raw / timeSec always holds.
+ const effectiveTimeSec = Math.max(w.minTimeSec, timeSec);
+
+ return {
+ fingerTravelMm: moveTravelMm + slideTravelMm,
+ stringChanges,
+ lifted,
+ placed,
+ anchors,
+ slides,
+ positionShiftMm,
+ raw,
+ timeSec: effectiveTimeSec,
+ cost: raw / effectiveTimeSec
+ };
+};
+
+// ---------------------------------------------------------------------------
+// Minimax Viterbi
+// ---------------------------------------------------------------------------
+
+const EPS = 1e-9;
+
+/** Index of the first value within EPS of the minimum (ties -> lower index); -1 when all are +Infinity. */
+const argminFirst = (values: readonly number[]): number => {
+ let min = Infinity;
+ for (const v of values) if (v < min) min = v;
+ if (min === Infinity) return -1;
+ for (let i = 0; i < values.length; i++) if (values[i] <= min + EPS) return i;
+ return -1;
+};
+
+/**
+ * Generic minimax Viterbi: minimize the max edge cost along the path, tie-break by the sum (epsilon 1e-9).
+ * sizes[i] = number of candidates in layer i. edgeCost(layer, from, to) is the edge from candidate `from` of
+ * layer `layer` to candidate `to` of layer (layer + 1) % n; it is called exactly once per edge, so it may be
+ * expensive. nodeCost (optional) only joins the SUM tie-break, and sumCost includes it.
+ * cyclic: also include the edge from the last layer back to the first. A single cyclic layer uses its self edge.
+ * Deterministic: remaining ties -> the lexicographically smallest path (lower index first).
+ * maxCost is 0 for a path without edges. Edge costs may be +Infinity (forbidden unless unavoidable);
+ * NaN or -Infinity edges and non-finite node costs throw.
+ *
+ * Exact in two phases, because (max, sum) is not decomposable per node (a prefix with a smaller max can
+ * lose once a later edge dominates both maxima):
+ * 1. bottleneck DP: M* = the smallest achievable max edge;
+ * 2. min-sum DP restricted to edges <= M* + epsilon; any such complete path has max == M*.
+ * Cyclic mode fixes the first-layer candidate s, closes the loop back to s, and keeps the best s.
+ */
+export const minimaxViterbi = (
+ sizes: readonly number[],
+ edgeCost: (layer: number, from: number, to: number) => number,
+ opts: { cyclic?: boolean; nodeCost?: (layer: number, index: number) => number } = {}
+): { path: number[]; maxCost: number; sumCost: number } => {
+ const n = sizes.length;
+ const cyclic = opts.cyclic ?? false;
+ if (n === 0) return { path: [], maxCost: 0, sumCost: 0 };
+ sizes.forEach((size, i) => {
+ if (!Number.isInteger(size) || size < 1) {
+ throw new Error(`minimaxViterbi: layer ${i} needs at least one candidate, got size ${size}`);
+ }
+ });
+
+ // edges[i][from][to]: layer i -> layer (i + 1) % n. The closing edge (i = n - 1) exists only when cyclic.
+ const edgeLayers = cyclic ? n : n - 1;
+ const edges: number[][][] = [];
+ for (let i = 0; i < edgeLayers; i++) {
+ const next = (i + 1) % n;
+ const matrix: number[][] = [];
+ for (let from = 0; from < sizes[i]; from++) {
+ const row: number[] = [];
+ for (let to = 0; to < sizes[next]; to++) {
+ const c = edgeCost(i, from, to);
+ if (Number.isNaN(c) || c === -Infinity) {
+ throw new Error(`minimaxViterbi: edge cost must be a number or +Infinity, got ${c} (layer ${i}, ${from} -> ${to})`);
+ }
+ row.push(c);
+ }
+ matrix.push(row);
+ }
+ edges.push(matrix);
+ }
+
+ const nodes: number[][] = sizes.map((size, i) =>
+ Array.from({ length: size }, (_, k) => {
+ const c = opts.nodeCost ? opts.nodeCost(i, k) : 0;
+ if (!Number.isFinite(c)) throw new Error(`minimaxViterbi: node cost must be finite, got ${c} (layer ${i}, candidate ${k})`);
+ return c;
+ })
+ );
+
+ /**
+ * Backward DP over layers n-1 .. 0 (cost-to-go per candidate). `terminal(v)` seeds the last layer,
+ * `combine` merges an edge cost with the cost-to-go of its head (Infinity when the edge is not allowed),
+ * and `withNode` adds the node term.
+ */
+ const backward = (
+ terminal: (v: number) => number,
+ combine: (edge: number, next: number) => number,
+ withNode: (layer: number, v: number, value: number) => number
+ ): number[][] => {
+ const go: number[][] = new Array(n);
+ go[n - 1] = Array.from({ length: sizes[n - 1] }, (_, v) => withNode(n - 1, v, terminal(v)));
+ for (let i = n - 2; i >= 0; i--) {
+ go[i] = Array.from({ length: sizes[i] }, (_, v) => {
+ let best = Infinity;
+ for (let w = 0; w < sizes[i + 1]; w++) {
+ const value = combine(edges[i][v][w], go[i + 1][w]);
+ if (value < best) best = value;
+ }
+ return withNode(i, v, best);
+ });
+ }
+ return go;
+ };
+
+ // Phase 1: the optimal bottleneck M*. No edges at all (single acyclic layer) => no restriction.
+ const bottleneckFrom = (start: number | null): number[][] =>
+ backward(
+ (v) => (start === null ? -Infinity : edges[n - 1][v][start]),
+ (edge, next) => Math.max(edge, next),
+ (_layer, _v, value) => value
+ );
+ const starts = cyclic ? Array.from({ length: sizes[0] }, (_, s) => s) : [null];
+ let threshold = Infinity;
+ if (edgeLayers > 0) {
+ for (const start of starts) {
+ const go = bottleneckFrom(start);
+ const value = start === null ? Math.min(...go[0]) : go[0][start];
+ if (value < threshold) threshold = value;
+ }
+ threshold += EPS;
+ }
+
+ // Phase 2: min-sum over the edges allowed by the bottleneck.
+ const allowed = (edge: number) => edge <= threshold;
+ const sumFrom = (start: number | null): number[][] =>
+ backward(
+ (v) => {
+ if (start === null) return 0;
+ const closing = edges[n - 1][v][start];
+ return allowed(closing) ? closing : Infinity;
+ },
+ (edge, next) => (allowed(edge) ? edge + next : Infinity),
+ (layer, v, value) => value + nodes[layer][v]
+ );
+
+ let bestStart = -1;
+ let bestGo: number[][] | null = null;
+ if (cyclic) {
+ const totals: number[] = [];
+ const gos: number[][][] = [];
+ for (let s = 0; s < sizes[0]; s++) {
+ const go = sumFrom(s);
+ gos.push(go);
+ totals.push(go[0][s]);
+ }
+ bestStart = argminFirst(totals);
+ if (bestStart >= 0) bestGo = gos[bestStart];
+ } else {
+ bestGo = sumFrom(null);
+ bestStart = argminFirst(bestGo[0]);
+ }
+ if (bestStart < 0) {
+ // Every path crosses a +Infinity edge (M* = Infinity), so all tie on max and sum: lexicographically first.
+ bestStart = 0;
+ bestGo = null;
+ }
+
+ const path: number[] = [bestStart];
+ for (let i = 0; i + 1 < n; i++) {
+ const v = path[i];
+ if (bestGo === null) {
+ path.push(0);
+ continue;
+ }
+ const go = bestGo;
+ const options = Array.from({ length: sizes[i + 1] }, (_, w) =>
+ allowed(edges[i][v][w]) ? edges[i][v][w] + go[i + 1][w] : Infinity
+ );
+ const next = argminFirst(options);
+ path.push(next < 0 ? 0 : next);
+ }
+
+ let maxCost = -Infinity;
+ let sumCost = 0;
+ for (let i = 0; i < edgeLayers; i++) {
+ const c = edges[i][path[i]][path[(i + 1) % n]];
+ if (c > maxCost) maxCost = c;
+ sumCost += c;
+ }
+ for (let i = 0; i < n; i++) sumCost += nodes[i][path[i]];
+ return { path, maxCost: edgeLayers === 0 ? 0 : maxCost, sumCost };
+};
+
+// ---------------------------------------------------------------------------
+// Sequence optimization
+// ---------------------------------------------------------------------------
+
+export interface OptimizedSequence extends SequenceResult {
+ /** Fingering used in each slot (the transitions are costed with it): the candidate's own unless a profile
+ * was given and another valid fingering of the same shape makes the changes easier. */
+ fingerings: Fingering[];
+}
+
+/** Which fingers a fingering uses, e.g. "3,4" for ring + pinky. */
+const fingerSetKey = (f: Fingering): string =>
+ [...new Set(f.fingers.filter((x): x is FingerNumber => x !== null))].sort((a, b) => a - b).join(',');
+
+/**
+ * Fingerings the optimizer may use for one candidate: its own first, then the cheapest valid fingering (under
+ * the profile) for every other set of fingers. Which fingers are busy decides what can stay down through a
+ * change (a dyad held with ring + pinky leaves the index free for the root below), while re-ordering the same
+ * fingers only adds static cost. So at most 1 + C(5, k) options, e.g. 7 for a dyad.
+ */
+const fingeringOptions = (candidate: FingeredShape, profile: HandProfile | undefined): Fingering[] => {
+ const own = candidate.fingering;
+ if (profile === undefined) return [own];
+ const options = [own];
+ const seen = new Set([fingerSetKey(own)]);
+ for (const alt of enumerateFingerings(candidate.shape, profile)) {
+ const key = fingerSetKey(alt);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ options.push(alt);
+ }
+ return options;
+};
+
+interface SequenceState {
+ candidate: number;
+ shape: FingeredShape;
+ /** Static physical cost above the candidate's own fingering; only joins the sum tie-break. */
+ extra: number;
+}
+
+/**
+ * Picks one candidate per slot minimizing the hardest change, then the total. The change leaving slot i
+ * gets timing.beatsPerSlot[i] * 60 / bpm seconds. Cyclic by default (riffs loop): the last slot changes
+ * back to the first. With one cyclic slot the self-transition is used (identical shapes cost 0).
+ *
+ * Without `opts.profile` each candidate keeps the fingering it carries. With a profile the fingering is
+ * chosen together with the neighbours: every candidate may also use the cheapest valid fingering for each
+ * other set of fingers (see fingeringOptions), so a dyad next to its power chord is held with ring + pinky
+ * instead of re-gripped. The hardest change still decides first; between equal changes the statically easier
+ * fingering wins (the candidate's own on a tie). `fingerings` reports the choice; `sumCost` is the sum of the
+ * transition costs only. scaleLengthMm defaults to the profile's, then to DEFAULT_SCALE_LENGTH_MM.
+ */
+export const optimizeSequence = (
+ candidatesPerSlot: readonly (readonly FingeredShape[])[],
+ timing: SequenceTiming,
+ opts: { scaleLengthMm?: number; profile?: HandProfile } = {}
+): OptimizedSequence => {
+ const n = candidatesPerSlot.length;
+ candidatesPerSlot.forEach((candidates, i) => {
+ if (candidates.length === 0) throw new Error(`optimizeSequence: slot ${i + 1} has no candidate voicings`);
+ });
+ if (n === 0) return { path: [], maxCost: 0, sumCost: 0, transitions: [], hardest: null, fingerings: [] };
+
+ const { bpm, beatsPerSlot } = timing;
+ if (!Number.isFinite(bpm) || bpm <= 0) throw new Error(`optimizeSequence: bpm must be a positive number, got ${bpm}`);
+ if (beatsPerSlot.length !== n) {
+ throw new Error(`optimizeSequence: beatsPerSlot has ${beatsPerSlot.length} entries for ${n} slots`);
+ }
+ beatsPerSlot.forEach((beats, i) => {
+ if (!Number.isFinite(beats) || beats < 0) {
+ throw new Error(`optimizeSequence: beatsPerSlot[${i}] must be a non-negative number, got ${beats}`);
+ }
+ });
+
+ const cyclic = timing.cyclic ?? true;
+ const { profile } = opts;
+ const scaleLengthMm = opts.scaleLengthMm ?? profile?.scaleLengthMm ?? DEFAULT_SCALE_LENGTH_MM;
+
+ // One state per (candidate, fingering option), candidates in order and each candidate's own fingering
+ // first, so lexicographic ties still prefer the lower candidate index and the attached fingering.
+ const states: SequenceState[][] = candidatesPerSlot.map((candidates) =>
+ candidates.flatMap((candidate, c) => {
+ const options = fingeringOptions(candidate, profile);
+ const ownCost = candidate.fingering.costBreakdown.physical;
+ return options.map((fingering, k) => {
+ const extra = fingering.costBreakdown.physical - ownCost;
+ return {
+ candidate: c,
+ shape: k === 0 ? candidate : { shape: candidate.shape, fingering },
+ extra: k > 0 && Number.isFinite(extra) ? Math.max(0, extra) : 0
+ };
+ });
+ })
+ );
+
+ const change = (slot: number, from: SequenceState, to: SequenceState): TransitionBreakdown =>
+ transitionCost(from.shape, to.shape, { scaleLengthMm, timeSec: (beatsPerSlot[slot] * 60) / bpm });
+
+ const { path: statePath } = minimaxViterbi(
+ states.map((layer) => layer.length),
+ (slot, from, to) => change(slot, states[slot][from], states[(slot + 1) % n][to]).cost,
+ { cyclic, nodeCost: (slot, index) => states[slot][index].extra }
+ );
+ const chosen = statePath.map((index, slot) => states[slot][index]);
+
+ const transitions: SequenceTransition[] = [];
+ const count = cyclic ? n : n - 1;
+ for (let i = 0; i < count; i++) {
+ const j = (i + 1) % n;
+ const breakdown = change(i, chosen[i], chosen[j]);
+ transitions.push({ from: i, to: j, cost: breakdown.cost, breakdown });
+ }
+ let hardest: SequenceTransition | null = null;
+ let sumCost = 0;
+ for (const t of transitions) {
+ if (hardest === null || t.cost > hardest.cost) hardest = t;
+ sumCost += t.cost;
+ }
+
+ return {
+ path: chosen.map((state) => state.candidate),
+ maxCost: hardest === null ? 0 : hardest.cost,
+ sumCost,
+ transitions,
+ hardest,
+ fingerings: chosen.map((state) => state.shape.fingering)
+ };
+};
+
+// ---------------------------------------------------------------------------
+// UI text
+// ---------------------------------------------------------------------------
+
+const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
+
+const formatSeconds = (sec: number): string => String(Number(sec.toFixed(2)));
+
+/**
+ * One-line UI text, e.g. "Hardest change: slot 2 -> 3, hand shifts 41 mm in 0.5 s". Slots 1-based in text.
+ * `label` defaults to "Hardest change" (the usual caller passes SequenceResult.hardest); pass another label
+ * when describing an arbitrary transition.
+ */
+export const describeTransition = (t: SequenceTransition | null, label = 'Hardest change'): string => {
+ if (t === null) return `${label}: none (one chord)`;
+ const b = t.breakdown;
+ const moves: string[] = [];
+ const shift = Math.round(b.positionShiftMm);
+ if (shift > 0) moves.push(`hand shifts ${shift} mm`);
+ if (b.placed > 0) moves.push(`${count(b.placed, 'finger')} placed`);
+ if (b.lifted > 0) moves.push(`${count(b.lifted, 'finger')} lifted`);
+ if (b.stringChanges > 0) moves.push(count(b.stringChanges, 'string change'));
+ if (b.slides > 0) moves.push(count(b.slides, 'slide'));
+ const what = moves.length > 0 ? moves.join(', ') : 'no movement';
+ const held = b.anchors > 0 ? `, ${count(b.anchors, 'finger')} stay${b.anchors === 1 ? 's' : ''} down` : '';
+ return `${label}: slot ${t.from + 1} -> ${t.to + 1}, ${what} in ${formatSeconds(b.timeSec)} s${held}`;
+};
diff --git a/engine/tuning.test.ts b/engine/tuning.test.ts
new file mode 100644
index 0000000..0ba826c
--- /dev/null
+++ b/engine/tuning.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { E_STANDARD, DROP_D, D_STANDARD, DROP_C, C_STANDARD, TUNING_PRESETS, getTuning, validateTuning } from './tuning';
+import { midiToName } from './pitch';
+
+describe('tuning presets', () => {
+ it('defines the documented open strings low -> high', () => {
+ expect(E_STANDARD.openMidi).toEqual([40, 45, 50, 55, 59, 64]);
+ expect(DROP_D.openMidi).toEqual([38, 45, 50, 55, 59, 64]);
+ expect(D_STANDARD.openMidi.map(midiToName)).toEqual(['D2', 'G2', 'C3', 'F3', 'A3', 'D4']);
+ expect(DROP_C.openMidi.map(midiToName)).toEqual(['C2', 'G2', 'C3', 'F3', 'A3', 'D4']);
+ expect(C_STANDARD.openMidi.map(midiToName)).toEqual(['C2', 'F2', 'A#2', 'D#3', 'G3', 'C4']);
+ });
+
+ it('validates every preset and resolves ids', () => {
+ for (const t of TUNING_PRESETS) {
+ expect(validateTuning(t)).toEqual({ ok: true });
+ expect(getTuning(t.id)).toBe(t);
+ }
+ expect(getTuning('nope')).toBeUndefined();
+ });
+});
+
+describe('validateTuning', () => {
+ it('rejects non-ascending, out-of-range and wrong-length tunings', () => {
+ expect(validateTuning({ id: 'a', name: 'a', openMidi: [40, 40, 50, 55, 59, 64] }).ok).toBe(false);
+ expect(validateTuning({ id: 'b', name: 'b', openMidi: [40, 45, 50, 55, 59, 128] }).ok).toBe(false);
+ expect(validateTuning({ id: 'c', name: 'c', openMidi: [40, 45, 50] }).ok).toBe(false);
+ expect(validateTuning({ id: 'd', name: 'd', openMidi: [40.5, 45, 50, 55, 59, 64] }).ok).toBe(false);
+ });
+
+ it('names the problem in the rejection, readable through the ok === false narrowing', () => {
+ const short = validateTuning({ id: 'c', name: 'c', openMidi: [40, 45, 50] });
+ const flat = validateTuning({ id: 'a', name: 'a', openMidi: [40, 40, 50, 55, 59, 64] });
+ expect(short.ok === false && short.reason).toBe('expected 4..8 strings, got 3');
+ expect(flat.ok === false && flat.reason).toBe('string 1 must be higher than string 0');
+ });
+
+ it('accepts other string counts within 4..8', () => {
+ expect(validateTuning({ id: 'bass', name: 'Bass', openMidi: [28, 33, 38, 43] }).ok).toBe(true);
+ expect(validateTuning({ id: '7', name: '7-string', openMidi: [35, 40, 45, 50, 55, 59, 64] }).ok).toBe(true);
+ });
+});
diff --git a/engine/tuning.ts b/engine/tuning.ts
new file mode 100644
index 0000000..361f2f4
--- /dev/null
+++ b/engine/tuning.ts
@@ -0,0 +1,36 @@
+import type { Tuning } from './types';
+
+export const E_STANDARD: Tuning = { id: 'e-standard', name: 'E Standard', openMidi: [40, 45, 50, 55, 59, 64] };
+export const DROP_D: Tuning = { id: 'drop-d', name: 'Drop D', openMidi: [38, 45, 50, 55, 59, 64] };
+export const D_STANDARD: Tuning = { id: 'd-standard', name: 'D Standard', openMidi: [38, 43, 48, 53, 57, 62] };
+export const DROP_C: Tuning = { id: 'drop-c', name: 'Drop C', openMidi: [36, 43, 48, 53, 57, 62] };
+export const C_STANDARD: Tuning = { id: 'c-standard', name: 'C Standard', openMidi: [36, 41, 46, 51, 55, 60] };
+
+export const TUNING_PRESETS: readonly Tuning[] = [E_STANDARD, DROP_D, D_STANDARD, DROP_C, C_STANDARD];
+
+export const MIN_STRINGS = 4;
+export const MAX_STRINGS = 8;
+
+export const getTuning = (id: string): Tuning | undefined => TUNING_PRESETS.find((t) => t.id === id);
+
+/** Narrow with `v.ok === false`: without `strictNullChecks` (the apps' setting) `!v.ok` does not narrow. */
+export type TuningValidation = { ok: true } | { ok: false; reason: string };
+
+/** Strings must be integer MIDI notes in 0..127, strictly ascending from string 0 (lowest). */
+export const validateTuning = (tuning: Tuning): TuningValidation => {
+ const { openMidi } = tuning;
+ if (!Array.isArray(openMidi)) return { ok: false, reason: 'openMidi must be an array' };
+ if (openMidi.length < MIN_STRINGS || openMidi.length > MAX_STRINGS) {
+ return { ok: false, reason: `expected ${MIN_STRINGS}..${MAX_STRINGS} strings, got ${openMidi.length}` };
+ }
+ for (let s = 0; s < openMidi.length; s++) {
+ const m = openMidi[s];
+ if (!Number.isInteger(m) || m < 0 || m > 127) return { ok: false, reason: `string ${s} is not a MIDI note` };
+ if (s > 0 && m <= openMidi[s - 1]) {
+ return { ok: false, reason: `string ${s} must be higher than string ${s - 1}` };
+ }
+ }
+ return { ok: true };
+};
+
+export const stringCount = (tuning: Tuning): number => tuning.openMidi.length;
diff --git a/engine/types.ts b/engine/types.ts
new file mode 100644
index 0000000..5a78ff1
--- /dev/null
+++ b/engine/types.ts
@@ -0,0 +1,504 @@
+// Shared types for the playability engine. Pure data, no runtime code.
+// Canonical string order everywhere in the engine: index 0 = lowest-pitched string.
+
+/** Fret per string, low -> high. `null` = muted, `0` = open. */
+export type Shape = readonly (number | null)[];
+
+export interface Tuning {
+ readonly id: string;
+ readonly name: string;
+ /** Open-string MIDI notes, index 0 = lowest-pitched string. */
+ readonly openMidi: readonly number[];
+}
+
+/** 0 = thumb (only when the profile allows it, lowest string only), 1 = index ... 4 = pinky. */
+export type FingerNumber = 0 | 1 | 2 | 3 | 4;
+
+/** One finger pressing two or more strings at the same fret. String range is inclusive, low -> high. */
+export interface Barre {
+ finger: FingerNumber;
+ fret: number;
+ fromString: number;
+ toString: number;
+}
+
+// ---------------------------------------------------------------------------
+// Hand profile
+// ---------------------------------------------------------------------------
+
+/** Adjacent finger-pair limits as fractions of the allowed total reach. Heuristic, not research-backed. */
+export interface PairLimits {
+ indexMiddle: number;
+ middleRing: number;
+ ringPinky: number;
+}
+
+export interface HandProfile {
+ version: 1;
+ scaleLengthMm: number;
+ /** Comfortable index -> pinky fingertip distance with the index at `lowRefFret`. */
+ reachAtLowMm: number;
+ /** Comfortable index -> pinky fingertip distance with the index at `highRefFret`. */
+ reachAtHighMm: number;
+ lowRefFret: number;
+ highRefFret: number;
+ pairLimits: PairLimits;
+ /** Multiplier on the allowed reach. 1.0 default, 1.1 when "allow stretches" is on. */
+ stretchTolerance: number;
+ noBarre: boolean;
+ allowTwoStringPartialBarre: boolean;
+ allowThumb: boolean;
+ maxFret: number;
+ allowOpenStrings: boolean;
+ maxInteriorMutes: number;
+ comfortableSixteenthBpm: number;
+}
+
+/** Answers from the in-app calibration flow. */
+export interface CalibrationAnswers {
+ scaleLengthMm: number;
+ /** Index on fret 1: highest fret the pinky reaches comfortably on the same string (3..7). */
+ lowPinkyFret: number;
+ /** Index on fret 7: highest fret the pinky reaches comfortably on the same string (9..13). */
+ highPinkyFret: number;
+ allowStretches: boolean;
+ noBarre: boolean;
+ allowOpenStrings: boolean;
+ maxFret: number;
+ comfortableSixteenthBpm: number;
+}
+
+// ---------------------------------------------------------------------------
+// Fingering and cost
+// ---------------------------------------------------------------------------
+
+export type CostBucket = 'physical' | 'rightHand' | 'musical';
+
+export type CostTermKey =
+ // physical
+ | 'reach'
+ | 'pairStretch'
+ | 'fingerCount'
+ | 'pinky'
+ | 'contortion'
+ | 'interiorMutes'
+ | 'position'
+ | 'thumb'
+ | 'barre'
+ // right hand
+ | 'stringSkips'
+ | 'chugStrings'
+ // musical
+ | 'lowMud'
+ | 'doubledThird'
+ | 'rootNotInBass';
+
+export interface CostTerm {
+ key: CostTermKey;
+ bucket: CostBucket;
+ /** Measured quantity in natural units (ratio, count, fret, ...). */
+ raw: number;
+ /** raw mapped to 0..1. */
+ normalized: number;
+ weight: number;
+ /** normalized * weight */
+ cost: number;
+}
+
+export interface CostBreakdown {
+ total: number;
+ physical: number;
+ rightHand: number;
+ musical: number;
+ terms: CostTerm[];
+}
+
+export interface PairStretch {
+ from: FingerNumber;
+ to: FingerNumber;
+ distanceMm: number;
+ limitMm: number;
+ /** distanceMm / limitMm; must stay <= 1 (hard constraint). */
+ ratio: number;
+ /** Relaxed spacing: one fret per finger-number step, measured from `from`'s fret. */
+ naturalMm: number;
+ /** 0..1 stretch beyond the relaxed spacing toward the limit (cost input). Cramped pairs are 0. */
+ stretch: number;
+}
+
+export interface FingeringMetrics {
+ /** Number of fretted (non-open, non-muted) strings. */
+ frettedCount: number;
+ /** Distinct fingers used, thumb included. */
+ fingerCount: number;
+ /** Fingertip distance from the lowest-fret to the highest-fret finger (thumb excluded). */
+ spanMm: number;
+ /** maxReachMm(lowestFret) * stretchTolerance */
+ allowedSpanMm: number;
+ /** spanMm / allowedSpanMm; 0 when fewer than two distinct fretted frets. */
+ reachRatio: number;
+ /** Consecutive used fingers (by finger number, thumb excluded) with their distance and limit. */
+ pairs: PairStretch[];
+ /** Worst pair ratio, 0 when fewer than two fingers. */
+ maxPairRatio: number;
+ /** Worst pair stretch beyond relaxed spacing, 0..1. */
+ maxPairStretch: number;
+ usesPinky: boolean;
+ usesThumb: boolean;
+ /** Same-fret pairs where the lower-numbered finger sits on the higher-pitched string. */
+ contortions: number;
+ /** Muted strings strictly between the lowest and highest sounding strings. */
+ interiorMutes: number;
+ lowestFret: number | null;
+ highestFret: number | null;
+}
+
+export interface Fingering {
+ /** Finger per string, low -> high. `null` for open or muted strings. */
+ fingers: (FingerNumber | null)[];
+ barres: Barre[];
+ metrics: FingeringMetrics;
+ /** Physical bucket only; playability adds right-hand and musical terms. */
+ costBreakdown: CostBreakdown;
+}
+
+export type FingeringRejectReason =
+ /** More fretted notes than available fingers without a barre, or no legal barre in barre mode. */
+ | 'NEEDS_BARRE'
+ /** Total span exceeds maxReachMm(lowestFret) * stretchTolerance. */
+ | 'REACH'
+ /** Every finger assignment breaks an adjacent-pair limit. */
+ | 'PAIR_STRETCH'
+ /** Every finger assignment needs crossed fingers. */
+ | 'FINGER_CROSSING'
+ /** A fret is negative, non-integer, or above profile.maxFret. */
+ | 'FRET_RANGE'
+ /** The shape uses open strings but the profile disallows them. */
+ | 'OPEN_STRINGS_DISALLOWED'
+ /** Wrong string count or no sounding string. */
+ | 'INVALID_SHAPE';
+
+/**
+ * Narrow with `result.ok === false` (or `=== true`). The consuming apps compile without `strictNullChecks`, and
+ * there `!result.ok` and the else branch of `if (result.ok)` do not narrow, so `result.reason` fails to compile.
+ */
+export type FingeringResult =
+ | { ok: true; fingering: Fingering }
+ | { ok: false; reason: FingeringRejectReason; detail: string };
+
+// ---------------------------------------------------------------------------
+// Voicing families and generation
+// ---------------------------------------------------------------------------
+
+export type FamilyGroup =
+ | 'power'
+ | 'susAdd'
+ | 'shells'
+ | 'quartalCluster'
+ | 'darkColors'
+ | 'triads'
+ | 'drones'
+ | 'legacy';
+
+export type FamilyTag = 'metal-friendly' | 'color' | 'pedal-compatible' | 'dissonant' | 'triad';
+
+/** Interval recipe relative to the chosen root. Intervals are pitch classes 0..11 above the root. */
+export interface VoicingFamily {
+ id: string;
+ /** Short UI label, e.g. "5", "m(add9)". */
+ label: string;
+ description: string;
+ group: FamilyGroup;
+ required: readonly number[];
+ optional: readonly number[];
+ forbidden: readonly number[];
+ /** Allowed intervals of the lowest sounding note (hard constraint), or 'any'. */
+ bass: readonly number[] | 'any';
+ /** Soft preference for the bass interval (musical cost when violated). */
+ preferredBass?: number;
+ minNotes: number;
+ maxNotes: number;
+ tags: readonly FamilyTag[];
+ /** Requires at least one open string sounding the root or the fifth. */
+ drone?: boolean;
+}
+
+export type VoicingTag =
+ | 'pedalCompatible'
+ | 'drone'
+ | 'usesOpenStrings'
+ | 'rootInBass'
+ | 'lowStrings'
+ | 'middleStrings'
+ | 'topStrings';
+
+export type StringGroup = 'low3' | 'middle' | 'top3';
+
+export interface GeneratedVoicing {
+ shape: Shape;
+ fingering: Fingering;
+ /** Sounding MIDI notes, low -> high string order (muted strings skipped). */
+ midi: number[];
+ /** Degree labels of sounding notes, low -> high pitch, space-separated, e.g. "1 5 b2 1". */
+ degrees: string;
+ /** Degree label per string, low -> high; null for muted strings. */
+ degreesByString: (string | null)[];
+ /** Honest chord symbol, or null when the notes do not support a confident symbol. */
+ symbol: string | null;
+ /** Display name: symbol when confident, otherwise an interval description. */
+ name: string;
+ familyId: string;
+ cost: number;
+ breakdown: CostBreakdown;
+ /** 0..1 distance from common open/CAGED shapes and reference shapes. Not part of difficulty. */
+ novelty: number;
+ tags: VoicingTag[];
+}
+
+export type BindingConstraint =
+ | 'PITCH_CLASSES'
+ | 'NOTE_COUNT'
+ | 'INTERIOR_MUTES'
+ | 'BASS'
+ | 'NEEDS_BARRE'
+ | 'REACH'
+ | 'PAIR_STRETCH'
+ | 'FINGER_CROSSING'
+ | 'MAX_FRET'
+ | 'OPEN_STRINGS'
+ | 'NO_DRONE_STRING';
+
+export type VoicingSort = 'easiest' | 'unusual';
+
+export interface GenerateParams {
+ /** Pitch class 0..11 or a note name ("E", "C#", "Db"). */
+ root: number | string;
+ /** Family object or a registered family id. */
+ family: VoicingFamily | string;
+ tuning: Tuning;
+ profile: HandProfile;
+ /** High-gain context: enables the low-register mud term. Default true. */
+ distortion?: boolean;
+ /** 'chug' favours fewer, lower strings for palm-muted use. Default 'strum'. */
+ context?: 'strum' | 'chug';
+ /** Include the musical cost bucket. Default true. */
+ musical?: boolean;
+ /** Maximum results. Default 12. */
+ limit?: number;
+ sort?: VoicingSort;
+ /** Optional seed: only perturbs ordering among near-equal results, deterministically. */
+ seed?: string;
+ /** Extra shapes counted as "known" for novelty (e.g. a curated library). */
+ referenceShapes?: readonly Shape[];
+ /** Spread results across positions and string sets. Default true. */
+ diversify?: boolean;
+}
+
+/**
+ * Counts from the real search. Above LAZY_SEARCH_MIN_SHAPES fingering-feasible shapes the fingering search is
+ * lazy: `playable` and the fingering-stage rejections then cover only searched shapes (voicings are identical).
+ */
+export interface GenerateStats {
+ windows: number;
+ combinations: number;
+ pitchValid: number;
+ playable: number;
+ rejections: Partial>;
+}
+
+export interface GenerateResult {
+ voicings: GeneratedVoicing[];
+ stats: GenerateStats;
+ /**
+ * Present when `voicings` is empty: the rule that blocks the most nearly-valid shapes in a wider diagnostic
+ * search (wider windows, frets above maxFret, open strings). It may be absent from `stats.rejections`.
+ */
+ empty?: { constraint: BindingConstraint; message: string };
+}
+
+// ---------------------------------------------------------------------------
+// Transitions
+// ---------------------------------------------------------------------------
+
+export interface FingeredShape {
+ shape: Shape;
+ fingering: Fingering;
+}
+
+export interface TransitionBreakdown {
+ /** Sum of along-the-neck fingertip movement for fingers used in both shapes (mm). */
+ fingerTravelMm: number;
+ /** Sum of |string change| for fingers used in both shapes. */
+ stringChanges: number;
+ lifted: number;
+ placed: number;
+ /** Same finger, same string, same fret in both shapes. */
+ anchors: number;
+ /** Same finger, same string, different fret (guide-finger slide). */
+ slides: number;
+ /** Movement of the hand position (lowest fretted fingertip) in mm. */
+ positionShiftMm: number;
+ /** Unscaled cost. */
+ raw: number;
+ /** Seconds available for the change. */
+ timeSec: number;
+ /** raw scaled by available time. */
+ cost: number;
+}
+
+export interface SequenceTiming {
+ bpm: number;
+ /** Beats each slot lasts before the next change; length = number of slots. */
+ beatsPerSlot: readonly number[];
+ /** Also count the last -> first transition (looping riff). Default true. */
+ cyclic?: boolean;
+}
+
+export interface SequenceTransition {
+ from: number;
+ to: number;
+ cost: number;
+ breakdown: TransitionBreakdown;
+}
+
+export interface SequenceResult {
+ /** Chosen candidate index per slot. */
+ path: number[];
+ /** Hardest transition cost on the path (minimized first). */
+ maxCost: number;
+ /** Sum of transition costs on the path (tie-break). */
+ sumCost: number;
+ transitions: SequenceTransition[];
+ /**
+ * null when the path has no transition (no slots, or one slot with `cyclic: false`). Check it before use: the
+ * consuming apps compile without `strictNullChecks`, so the compiler does not flag a missing null check.
+ */
+ hardest: SequenceTransition | null;
+}
+
+// ---------------------------------------------------------------------------
+// Rhythm
+// ---------------------------------------------------------------------------
+
+export type Meter = { numerator: number; denominator: 4 | 8 };
+
+export type HitTarget =
+ /** Pedal note, usually the lowest string open or the root on it. */
+ | { kind: 'pedal' }
+ /** Index into the harmony slots (voicings chosen by the user or optimizer). */
+ | { kind: 'slot'; slot: number }
+ /** Top two notes of the slot voicing. */
+ | { kind: 'dyad'; slot: number }
+ /** Muted percussive scratch. */
+ | { kind: 'dead' };
+
+export interface RhythmEvent {
+ /** Onset from pattern start. */
+ tick: number;
+ /** > 0, no overlap with the next event. */
+ durationTicks: number;
+ target: HitTarget;
+ palmMute: boolean;
+ /** 0 normal, 1 accent, 2 strong accent */
+ accent: 0 | 1 | 2;
+ pick: 'down' | 'up';
+ /** Sustain from the previous event, no new attack. */
+ tie?: boolean;
+}
+
+export type RhythmGrid = '16th' | '16th-triplet';
+
+export type GroupingSpec = 'even' | '3-3-2' | '3-3-3-3-4' | 'random-odd' | readonly number[];
+
+export type RhythmStyleId =
+ | 'chugEngine'
+ | 'gallop'
+ | 'reverseGallop'
+ | 'displacedThrees'
+ | 'halftimeStomp'
+ | 'tremoloWall'
+ | 'syncopatedStabs'
+ | 'sevenEight'
+ | 'fiveOverFour';
+
+export type PickingMode = 'alternate' | 'downstrokes';
+
+export interface RhythmParams {
+ style: RhythmStyleId;
+ meter: Meter;
+ bars: number;
+ grid: RhythmGrid;
+ grouping: GroupingSpec;
+ /** Polymeter: accent-cycle length in grid units, looping against the barline. */
+ cycleSixteenths?: number;
+ /** 0..1 */
+ density: number;
+ /** 0..1 */
+ syncopation: number;
+ /** 0..1 share of hits on the pedal. */
+ pedalRatio: number;
+ /** 0..1 share of palm-muted hits. */
+ palmMuteRatio: number;
+ /** 1..4 harmony slots. */
+ slotCount: number;
+ picking: PickingMode;
+ /** Allow the first beat to be empty (pickup / anticipation feel). */
+ anticipation: boolean;
+}
+
+export interface RhythmPattern {
+ id: string;
+ name: string;
+ seed: string;
+ meter: Meter;
+ bars: number;
+ ppq: 480;
+ /** Polymeter phrase length that loops against the bar. */
+ cycleTicks?: number;
+ /** Sorted by tick; rests are gaps. */
+ events: RhythmEvent[];
+ tags: string[];
+ params: RhythmParams;
+}
+
+export interface RhythmWarning {
+ code: 'tooFastForProfile';
+ message: string;
+ /** Smallest inter-onset interval in ticks. */
+ minIoiTicks: number;
+ /** BPM above which the pattern exceeds the profile's comfortable sixteenth tempo. */
+ bpmLimit: number;
+}
+
+export interface RhythmValidation {
+ ok: boolean;
+ errors: string[];
+ warnings: RhythmWarning[];
+}
+
+/** A harmony slot for playback: one voicing plus an optional pedal note. */
+export interface HarmonySlot {
+ shape: Shape;
+ /** MIDI note for pedal hits; null falls back to the slot's lowest sounding note. */
+ pedalMidi: number | null;
+}
+
+export interface PlaybackEvent {
+ tick: number;
+ timeSec: number;
+ midi: number[];
+ /** 0..1 */
+ velocity: number;
+ durationSec: number;
+ kind: HitTarget['kind'];
+ palmMute: boolean;
+ accent: 0 | 1 | 2;
+}
+
+export interface MidiNoteEvent {
+ tick: number;
+ durationTicks: number;
+ pitch: number;
+ /** 1..127 */
+ velocity: number;
+}
diff --git a/engine/voicingSpec.test.ts b/engine/voicingSpec.test.ts
new file mode 100644
index 0000000..3ccb4fc
--- /dev/null
+++ b/engine/voicingSpec.test.ts
@@ -0,0 +1,529 @@
+import { describe, it, expect } from 'vitest';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import {
+ VOICING_FAMILIES,
+ DRONE_FAMILIES,
+ ALL_FAMILIES,
+ FAMILY_GROUPS,
+ getFamily,
+ withDrone,
+ allowedIntervals,
+ matchFamily,
+ fromLegacyNotes,
+ relaxFamily
+} from './voicingSpec';
+import type { Shape, VoicingFamily } from './types';
+import { E_STANDARD, DROP_D } from './tuning';
+import { parseNoteName, parsePitchClass, pitchClass, intervalFrom } from './pitch';
+
+const x = null;
+const E = 4;
+const A = 9;
+const B = 11;
+const C = 0;
+const D = 2;
+const F = 5;
+
+const fam = (id: string): VoicingFamily => {
+ const family = getFamily(id);
+ if (!family) throw new Error(`missing family ${id}`);
+ return family;
+};
+
+describe('family recipes', () => {
+ type Recipe = [
+ id: string,
+ group: string,
+ required: number[],
+ optional: number[],
+ bass: number[] | 'any',
+ preferredBass: number | undefined,
+ minNotes: number,
+ maxNotes: number,
+ tags: string[]
+ ];
+ const recipes: Recipe[] = [
+ ['power5', 'power', [0, 7], [], [0], undefined, 2, 4, ['metal-friendly', 'pedal-compatible']],
+ ['power5_b2', 'power', [0, 1, 7], [], [0], undefined, 3, 5, ['metal-friendly', 'dissonant']],
+ ['dyad_b2', 'power', [0, 1], [], [0], undefined, 2, 4, ['metal-friendly', 'dissonant']],
+ ['tritone', 'power', [0, 6], [], [0], undefined, 2, 4, ['metal-friendly', 'dissonant']],
+ ['fourth', 'power', [0, 5], [], [0], undefined, 2, 4, ['metal-friendly']],
+ ['sus2', 'susAdd', [0, 2, 7], [], 'any', 0, 3, 6, []],
+ ['sus4', 'susAdd', [0, 5, 7], [], 'any', 0, 3, 6, []],
+ ['add9', 'susAdd', [0, 2, 4], [7], 'any', 0, 3, 6, ['color']],
+ ['m_add9', 'susAdd', [0, 2, 3], [7], 'any', 0, 3, 6, ['color', 'metal-friendly']],
+ ['7', 'shells', [0, 4, 10], [], [0], undefined, 3, 4, []],
+ ['m7', 'shells', [0, 3, 10], [], [0], undefined, 3, 4, []],
+ ['maj7', 'shells', [0, 4, 11], [], [0], undefined, 3, 4, ['color']],
+ ['quartal', 'quartalCluster', [0, 5, 10], [3], [0], undefined, 3, 4, ['color']],
+ ['cluster_m2', 'quartalCluster', [0, 1, 2], [], 'any', 0, 3, 4, ['dissonant', 'color']],
+ ['cluster_b3', 'quartalCluster', [0, 2, 3], [], 'any', 0, 3, 4, ['color']],
+ ['phrygian', 'darkColors', [0, 1, 4, 7], [10], [0], undefined, 4, 6, ['metal-friendly', 'dissonant']],
+ ['major', 'triads', [0, 4, 7], [], 'any', undefined, 3, 6, ['triad']],
+ ['minor', 'triads', [0, 3, 7], [], 'any', undefined, 3, 6, ['triad']],
+ ['dim', 'triads', [0, 3, 6], [], 'any', undefined, 3, 6, ['triad']],
+ ['aug', 'triads', [0, 4, 8], [], 'any', undefined, 3, 6, ['triad']]
+ ];
+
+ it('lists exactly the base families in order', () => {
+ expect(VOICING_FAMILIES.map((f) => f.id)).toEqual(recipes.map((r) => r[0]));
+ });
+
+ it.each(recipes)('%s has the contract recipe', (id, group, required, optional, bass, preferredBass, min, max, tags) => {
+ const f = fam(id);
+ expect(f.group).toBe(group);
+ expect([...f.required]).toEqual(required);
+ expect([...f.optional]).toEqual(optional);
+ expect(f.bass === 'any' ? 'any' : [...f.bass]).toEqual(bass);
+ expect(f.preferredBass).toBe(preferredBass);
+ expect(f.minNotes).toBe(min);
+ expect(f.maxNotes).toBe(max);
+ expect([...f.tags].sort()).toEqual([...tags].sort());
+ expect(f.drone).toBeFalsy();
+ });
+
+ it('documents the forbidden thirds of power and sus families', () => {
+ expect([...fam('power5').forbidden]).toEqual([3, 4]);
+ expect([...fam('sus2').forbidden]).toEqual([3, 4]);
+ expect([...fam('sus4').forbidden]).toEqual([3, 4]);
+ });
+
+ it('keeps every recipe internally consistent', () => {
+ const labels = new Set();
+ for (const f of ALL_FAMILIES) {
+ expect(f.label.length).toBeGreaterThan(0);
+ expect(f.description.length).toBeGreaterThan(0);
+ expect(labels.has(f.label)).toBe(false);
+ labels.add(f.label);
+ expect(f.required[0]).toBe(0);
+ const all = [...f.required, ...f.optional, ...f.forbidden];
+ for (const i of all) expect(Number.isInteger(i) && i >= 0 && i < 12).toBe(true);
+ expect([...f.required]).toEqual([...new Set(f.required)].sort((a, b) => a - b));
+ for (const i of f.optional) expect(f.required).not.toContain(i);
+ for (const i of f.forbidden) expect(allowedIntervals(f)).not.toContain(i);
+ expect(f.minNotes).toBeLessThanOrEqual(f.maxNotes);
+ expect(f.minNotes).toBeGreaterThanOrEqual(Math.min(2, f.required.length));
+ expect(f.maxNotes).toBeLessThanOrEqual(6);
+ if (f.bass !== 'any') for (const i of f.bass) expect(allowedIntervals(f)).toContain(i);
+ if (f.preferredBass !== undefined) expect(allowedIntervals(f)).toContain(f.preferredBass);
+ }
+ });
+
+ it('freezes the shared recipes', () => {
+ expect(Object.isFrozen(fam('power5'))).toBe(true);
+ expect(Object.isFrozen(fam('power5').required)).toBe(true);
+ });
+});
+
+describe('drone families and groups', () => {
+ it('derives the drone variants with withDrone', () => {
+ expect(DRONE_FAMILIES.map((f) => f.id)).toEqual([
+ 'drone_power5',
+ 'drone_fourth',
+ 'drone_sus2',
+ 'drone_m_add9',
+ 'drone_quartal',
+ 'drone_phrygian'
+ ]);
+ for (const d of DRONE_FAMILIES) {
+ const base = fam(d.id.replace(/^drone_/, ''));
+ expect(d.group).toBe('drones');
+ expect(d.drone).toBe(true);
+ expect(d.required).toEqual(base.required);
+ expect(d.optional).toEqual(base.optional);
+ expect(d.forbidden).toEqual(base.forbidden);
+ expect(d.bass).toEqual(base.bass);
+ expect(d.minNotes).toBe(base.minNotes);
+ expect(d.maxNotes).toBe(base.maxNotes);
+ expect(d.label).toBe(`Drone ${base.label}`);
+ }
+ });
+
+ it('withDrone is idempotent and leaves the input untouched', () => {
+ const power = fam('power5');
+ const drone = withDrone(power);
+ expect(drone.id).toBe('drone_power5');
+ expect(withDrone(drone)).toBe(drone);
+ expect(power.drone).toBeFalsy();
+ expect(power.group).toBe('power');
+ });
+
+ it('ALL_FAMILIES is the base list followed by the drones, with unique ids', () => {
+ expect(ALL_FAMILIES).toEqual([...VOICING_FAMILIES, ...DRONE_FAMILIES]);
+ expect(new Set(ALL_FAMILIES.map((f) => f.id)).size).toBe(ALL_FAMILIES.length);
+ });
+
+ it('getFamily finds base and drone families and returns undefined otherwise', () => {
+ expect(getFamily('m_add9')?.label).toBe('m(add9)');
+ expect(getFamily('drone_quartal')?.drone).toBe(true);
+ expect(getFamily('nope')).toBeUndefined();
+ });
+
+ it('groups families in UI order and covers each family exactly once', () => {
+ expect(FAMILY_GROUPS.map((g) => [g.id, g.label])).toEqual([
+ ['power', 'Power and dyads'],
+ ['susAdd', 'Sus and add'],
+ ['shells', 'Shells'],
+ ['quartalCluster', 'Quartal and clusters'],
+ ['darkColors', 'Dark colors'],
+ ['triads', 'Triads'],
+ ['drones', 'Drones']
+ ]);
+ const listed = FAMILY_GROUPS.flatMap((g) => g.familyIds);
+ expect([...listed].sort()).toEqual(ALL_FAMILIES.map((f) => f.id).sort());
+ for (const g of FAMILY_GROUPS) for (const id of g.familyIds) expect(fam(id).group).toBe(g.id);
+ expect(FAMILY_GROUPS[0].familyIds).toEqual(['power5', 'power5_b2', 'dyad_b2', 'tritone', 'fourth']);
+ });
+});
+
+describe('allowedIntervals', () => {
+ it('returns sorted unique required and optional intervals', () => {
+ expect(allowedIntervals(fam('phrygian'))).toEqual([0, 1, 4, 7, 10]);
+ expect(allowedIntervals(fam('quartal'))).toEqual([0, 3, 5, 10]);
+ expect(allowedIntervals(fam('power5'))).toEqual([0, 7]);
+ });
+});
+
+describe('matchFamily', () => {
+ const ok = { ok: true };
+ // Narrows with `=== true`: the repo tsconfig is non-strict, where `!r.ok` does not narrow the union.
+ const mismatch = (family: string, root: number, shape: Shape, tuning = E_STANDARD) => {
+ const r = matchFamily(fam(family), root, shape, tuning);
+ return r.ok === true ? null : r;
+ };
+ const reason = (family: string, root: number, shape: Shape, tuning = E_STANDARD): string | null =>
+ mismatch(family, root, shape, tuning)?.reason ?? null;
+
+ it('accepts hand-checked power chords and octave doublings', () => {
+ expect(matchFamily(fam('power5'), E, [0, 2, 2, x, x, x], E_STANDARD)).toEqual(ok);
+ expect(matchFamily(fam('power5'), E, [0, 2, x, x, x, x], E_STANDARD)).toEqual(ok);
+ expect(matchFamily(fam('power5'), A, [5, 7, 7, x, x, x], E_STANDARD)).toEqual(ok);
+ expect(matchFamily(fam('power5'), A, [x, 0, 2, 2, x, x], E_STANDARD)).toEqual(ok);
+ });
+
+ it('accepts Drop D one-finger power chords', () => {
+ expect(matchFamily(fam('power5'), D, [0, 0, 0, x, x, x], DROP_D)).toEqual(ok);
+ expect(matchFamily(fam('power5'), F, [3, 3, 3, x, x, x], DROP_D)).toEqual(ok);
+ // The same grip in E standard is E A D: not a power chord.
+ expect(reason('power5', E, [0, 0, 0, x, x, x])).toBe('PITCH_CLASSES');
+ });
+
+ it('rejects wrong note counts', () => {
+ expect(mismatch('power5', E, [0, x, x, x, x, x])).toMatchObject({
+ reason: 'NOTE_COUNT',
+ detail: expect.stringContaining('1 note,')
+ });
+ expect(reason('power5', E, [0, 2, 2, 4, 5, 0])).toBe('NOTE_COUNT');
+ expect(reason('power5', E, [x, x, x, x, x, x])).toBe('NOTE_COUNT');
+ // Shells cap at four notes; the six-string Em(add9) fits m_add9's six-note cap.
+ expect(reason('7', E, [0, 2, 0, 1, 0, 0])).toBe('NOTE_COUNT');
+ expect(matchFamily(fam('m_add9'), E, [0, 2, 4, 0, 0, 0], E_STANDARD)).toEqual(ok);
+ expect(matchFamily(fam('m_add9'), E, [0, 2, 4, 0, x, x], E_STANDARD)).toEqual(ok);
+ });
+
+ it('rejects missing, outside and forbidden pitch classes', () => {
+ expect(mismatch('power5_b2', E, [0, 2, 2, x, x, x])).toEqual({
+ ok: false,
+ reason: 'PITCH_CLASSES',
+ detail: 'missing b2'
+ });
+ // Open E major has the major third: forbidden for power5.
+ expect(mismatch('power5', E, [0, 2, 2, 1, x, x])).toEqual({
+ ok: false,
+ reason: 'PITCH_CLASSES',
+ detail: '3 is forbidden in 5'
+ });
+ expect(mismatch('power5_b2', E, [0, 2, 2, 1, x, x])?.detail).toBe('missing b2');
+ // E A E G#: the major third is simply outside the 4th dyad.
+ expect(mismatch('fourth', E, [0, 0, 2, 1, x, x])?.detail).toBe('3 is outside 4th');
+ // sus2 E B F# B is fine, adding G (b3) breaks it.
+ expect(matchFamily(fam('sus2'), E, [0, 2, 4, 4, x, x], E_STANDARD)).toEqual(ok);
+ expect(reason('sus2', E, [0, 2, 4, 0, x, x])).toBe('PITCH_CLASSES');
+ });
+
+ it('accepts optional intervals and both shapes with and without them', () => {
+ expect(matchFamily(fam('add9'), C, [x, 3, 2, 0, 3, x], E_STANDARD)).toEqual(ok); // C E G D
+ expect(matchFamily(fam('add9'), C, [x, 3, 2, x, 3, x], E_STANDARD)).toEqual(ok); // C E D
+ expect(matchFamily(fam('phrygian'), E, [0, 8, 6, 4, x, x], E_STANDARD)).toEqual(ok); // E F G# B
+ expect(reason('phrygian', E, [0, 7, 6, x, x, x])).toBe('NOTE_COUNT');
+ });
+
+ it('accepts any inversion for triads', () => {
+ expect(matchFamily(fam('major'), C, [x, 3, 2, 0, 1, 0], E_STANDARD)).toEqual(ok);
+ expect(matchFamily(fam('major'), C, [0, 3, 2, 0, 1, x], E_STANDARD)).toEqual(ok); // E bass
+ expect(matchFamily(fam('major'), C, [3, 3, 2, 0, 1, x], E_STANDARD)).toEqual(ok); // G bass
+ expect(reason('minor', C, [x, 3, 2, 0, 1, 0])).toBe('PITCH_CLASSES');
+ });
+
+ it('enforces the hard bass rule', () => {
+ expect(mismatch('power5', E, [x, 2, 2, x, x, x])).toEqual({
+ ok: false,
+ reason: 'BASS',
+ detail: 'bass is 5, 5 needs 1'
+ }); // B2 E3
+ expect(reason('7', C, [x, x, 2, 3, 1, x])).toBe('BASS'); // E A# C: shell with the third in the bass
+ });
+
+ it('finds the bass by pitch, not by string', () => {
+ // String 0 fret 12 is E3, but the open A string (A2) is lower: bass is the 4th of E.
+ expect(reason('fourth', E, [12, 0, x, x, x, x])).toBe('BASS');
+ // String 0 fret 10 is D3 above the open A2: the A root is the real bass.
+ expect(matchFamily(fam('fourth'), A, [10, 0, x, x, x, x], E_STANDARD)).toEqual(ok);
+ });
+
+ it('applies the drone rule to open strings only', () => {
+ expect(matchFamily(fam('drone_power5'), D, [0, 0, 0, x, x, x], DROP_D)).toEqual(ok);
+ expect(matchFamily(fam('drone_power5'), D, [x, x, 0, 2, 3, x], E_STANDARD)).toEqual(ok);
+ // The open high E is the fifth of A.
+ expect(matchFamily(fam('drone_power5'), A, [x, 12, 14, x, x, 0], E_STANDARD)).toEqual(ok);
+ expect(mismatch('drone_power5', F, [1, 3, 3, x, x, x])).toEqual({
+ ok: false,
+ reason: 'NO_DRONE_STRING',
+ detail: 'no open string sounds the root or 5th'
+ });
+ // Open D is the b3 of B: allowed by quartal, but not a drone on the root or fifth.
+ const bQuartal: Shape = [7, 7, 0, 2, x, x];
+ expect(matchFamily(fam('quartal'), B, bQuartal, E_STANDARD)).toEqual(ok);
+ expect(reason('drone_quartal', B, bQuartal)).toBe('NO_DRONE_STRING');
+ });
+
+ it('keeps pitch-class rules ahead of the drone rule', () => {
+ // An open fifth is not allowed in a 4th dyad, so drone_fourth needs an open root.
+ expect(reason('drone_fourth', A, [x, 0, 0, x, x, 0])).toBe('PITCH_CLASSES');
+ expect(matchFamily(fam('drone_fourth'), E, [0, 0, x, x, x, x], E_STANDARD)).toEqual(ok);
+ });
+
+ it('normalizes the root pitch class', () => {
+ expect(matchFamily(fam('power5'), E + 12, [0, 2, 2, x, x, x], E_STANDARD)).toEqual(ok);
+ });
+});
+
+describe('fromLegacyNotes', () => {
+ it('converts "The Omen" (standard-dark, dark-1)', () => {
+ const f = fromLegacyNotes(['E2', 'F2', 'B2', 'E3'], 'E');
+ expect([...f.required]).toEqual([0, 1, 7]);
+ expect([...f.optional]).toEqual([]);
+ expect([...f.forbidden]).toEqual([]);
+ expect(f.bass).toEqual([0]);
+ expect(f.minNotes).toBe(3);
+ expect(f.maxNotes).toBe(4);
+ expect(f.group).toBe('legacy');
+ expect(f.id).toBe('legacy:0.1.7/0');
+ expect(f.label).toBe('1 b2 5');
+ expect([...f.tags]).toEqual([]);
+ expect(f.preferredBass).toBeUndefined();
+ });
+
+ it('reads flat spellings against a sharp baseRoot (drop-melodic "Drop Bb Maj7")', () => {
+ const f = fromLegacyNotes(['Bb2', 'D3', 'F3', 'A3'], 'A#');
+ expect([...f.required]).toEqual([0, 4, 7, 11]);
+ expect(f.bass).toEqual([0]);
+ expect(f.label).toBe('1 3 5 7');
+ const sus = fromLegacyNotes(['Bb2', 'C3', 'F3', 'Bb3'], 'A#'); // "Drop Bb sus2"
+ expect([...sus.required]).toEqual([0, 2, 7]);
+ });
+
+ it('keeps pitch classes of notes below the guitar range ("E Low Stack")', () => {
+ const f = fromLegacyNotes(['E1', 'E2', 'B2'], 'E');
+ expect([...f.required]).toEqual([0, 7]);
+ expect(f.bass).toEqual([0]);
+ expect(f.minNotes).toBe(2);
+ expect(f.maxNotes).toBe(3);
+ expect(f.id).toBe('legacy:0.7/0');
+ });
+
+ it('converts other real rows', () => {
+ expect([...fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3', 'A3'], 'E').required]).toEqual([0, 2, 3, 5, 7, 10]);
+ expect(fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3', 'A3'], 'E').maxNotes).toBe(6);
+ expect([...fromLegacyNotes(['F#2', 'A2', 'C3', 'F#3'], 'F#').required]).toEqual([0, 3, 6]);
+ expect([...fromLegacyNotes(['D2', 'D#2', 'A2', 'D3'], 'D').required]).toEqual([0, 1, 7]);
+ });
+
+ it('takes the bass interval from the lowest parsed note, wherever it is listed', () => {
+ const f = fromLegacyNotes(['C3', 'E3', 'G2'], 'C');
+ expect(f.bass).toEqual([7]);
+ expect(f.id).toBe('legacy:0.4.7/7');
+ });
+
+ it('clamps maxNotes to the guitar and skips unparseable tokens', () => {
+ expect(fromLegacyNotes(['E2', 'B2', 'E3', 'B3', 'E4', 'B4', 'E5'], 'E').maxNotes).toBe(6);
+ const f = fromLegacyNotes(['E2', 'foo', 'B2', ''], 'E');
+ expect([...f.required]).toEqual([0, 7]);
+ expect(f.maxNotes).toBe(2);
+ });
+
+ it('throws when nothing parses or the root is invalid', () => {
+ expect(() => fromLegacyNotes(['nope'], 'E')).toThrow();
+ expect(() => fromLegacyNotes([], 'E')).toThrow();
+ expect(() => fromLegacyNotes(['E2'], 'H')).toThrow();
+ });
+
+ it('produces a family that matches its own notes', () => {
+ const f = fromLegacyNotes(['E2', 'F2', 'B2', 'E3'], 'E');
+ expect(matchFamily(f, E, [0, x, 3, 4, 5, x], E_STANDARD)).toEqual({ ok: true }); // E2 F3 B3 E4
+ expect(matchFamily(f, E, [0, 2, 3, x, x, x], E_STANDARD)).toEqual({ ok: true }); // E2 B2 F3
+ });
+
+ const chordsDir = fileURLToPath(new URL('../public/chords/', import.meta.url));
+ describe.skipIf(!existsSync(chordsDir))('every row in public/chords', () => {
+ interface Row {
+ id: string;
+ notes: string[];
+ baseRoot: string;
+ relatedChords?: Row[];
+ }
+ const rows: Row[] = readdirSync(chordsDir)
+ .filter((file) => file.endsWith('.json'))
+ .sort()
+ .flatMap((file) => JSON.parse(readFileSync(chordsDir + file, 'utf8')) as Row[])
+ .flatMap((row) => [row, ...(row.relatedChords ?? [])]);
+
+ it('loads the real library', () => {
+ expect(rows.length).toBeGreaterThan(40);
+ });
+
+ it('turns each row into the pitch classes of its notes with the lowest note as bass', () => {
+ for (const row of rows) {
+ const f = fromLegacyNotes(row.notes, row.baseRoot);
+ const root = parsePitchClass(row.baseRoot) as number;
+ const midi = row.notes.map((n) => parseNoteName(n) as number);
+ const expected = [...new Set(midi.map((m) => intervalFrom(root, pitchClass(m))))].sort((a, b) => a - b);
+ expect([...f.required], row.id).toEqual(expected);
+ expect(f.bass, row.id).toEqual([intervalFrom(root, pitchClass(Math.min(...midi)))]);
+ expect(f.minNotes, row.id).toBe(expected.length);
+ expect(f.maxNotes, row.id).toBe(Math.min(6, Math.max(expected.length, row.notes.length)));
+ expect(f.id, row.id).toBe(`legacy:${expected.join('.')}/${f.bass}`);
+ }
+ });
+ });
+});
+
+describe('relaxFamily', () => {
+ const steps = (id: string): string[][] => relaxFamily(fam(id)).map((s) => s.relaxed);
+ /** True when no rung of the ladder accepts the shape. */
+ const neverAccepted = (family: VoicingFamily, root: number, shape: Shape, tuning = E_STANDARD): boolean =>
+ relaxFamily(family).every((s) => matchFamily(s.family, root, shape, tuning).ok !== true);
+
+ it('relaxes 7 shells: bass first, then note count', () => {
+ const ladder = relaxFamily(fam('7'));
+ expect(ladder.map((s) => s.relaxed)).toEqual([['any bass note'], ['any bass note', 'any note count']]);
+ const [first, second] = ladder;
+ expect(first.family.bass).toBe('any');
+ expect(first.family.preferredBass).toBe(0);
+ expect(first.family.maxNotes).toBe(4);
+ expect(second.family.bass).toBe('any');
+ expect(second.family.minNotes).toBe(3);
+ expect(second.family.maxNotes).toBe(6);
+ });
+
+ it('never frees the bass of a dyad: inverting it would change its only interval', () => {
+ for (const id of ['power5', 'dyad_b2', 'fourth']) {
+ expect(steps(id), id).toEqual([['any note count']]);
+ for (const step of relaxFamily(fam(id))) expect(step.family.bass, id).toEqual([0]);
+ }
+ expect(relaxFamily(fam('power5'))[0].family.maxNotes).toBe(6);
+ // C#3 C4 is a major 7th, not a b2 dyad on C; B2 E3 is a 4th, not E5; A2 E3 is a 5th, not an E 4th dyad.
+ expect(neverAccepted(fam('dyad_b2'), C, [9, x, 10, x, x, x])).toBe(true);
+ expect(neverAccepted(fam('power5'), E, [x, 2, 2, x, x, x])).toBe(true);
+ expect(neverAccepted(fam('fourth'), E, [x, 0, 2, x, x, x])).toBe(true);
+ });
+
+ it('keeps the bass relaxation for the tritone, its own inversion', () => {
+ expect(steps('tritone')).toEqual([['any bass note'], ['any bass note', 'any note count']]);
+ expect(matchFamily(relaxFamily(fam('tritone'))[0].family, E, [x, 1, 2, x, x, x], E_STANDARD).ok).toBe(true); // A# E
+ });
+
+ it('never decays a family into a dyad free to invert (sus2, sus4, triads)', () => {
+ for (const id of ['sus2', 'sus4', 'major', 'minor']) expect(steps(id), id).toEqual([]);
+ // B3 F#4 is a B5 power chord, not F#sus4 without its fifth.
+ expect(neverAccepted(fam('sus4'), 6, [x, x, x, x, 0, 2])).toBe(true);
+ });
+
+ it('never drops the interval a family is named for', () => {
+ for (const [id, named] of [
+ ['add9', 2],
+ ['m_add9', 2],
+ ['cluster_b3', 2],
+ ['quartal', 5]
+ ] as const) {
+ for (const step of relaxFamily(fam(id))) expect(step.family.required, `${id} ${step.relaxed}`).toContain(named);
+ }
+ // G2 B2 E3 is Em/G, not E m(add9); F#2 D#3 is a 6th dyad, not a b3 cluster on D#.
+ expect(neverAccepted(fam('m_add9'), E, [3, 2, 2, x, x, x])).toBe(true);
+ expect(neverAccepted(fam('add9'), C, [x, 3, 2, 0, 1, x])).toBe(true);
+ expect(neverAccepted(fam('cluster_b3'), 3, [2, 6, x, x, x, x])).toBe(true);
+ expect(steps('cluster_b3')).toEqual([['any note count']]);
+ expect(relaxFamily(fam('cluster_b3'))[0].family.minNotes).toBe(3);
+ expect(steps('quartal')).toEqual([['any bass note'], ['any bass note', 'any note count']]);
+ });
+
+ it('moves extensions to optional when a third or seventh is required (library m9 row)', () => {
+ const m9 = fromLegacyNotes(['E2', 'G2', 'B2', 'D3', 'F#3'], 'E');
+ const ladder = relaxFamily(m9);
+ expect(ladder.map((s) => s.relaxed.at(-1))).toEqual(['any bass note', 'no 5th', 'no extensions', 'any note count']);
+ expect([...ladder[2].family.required]).toEqual([0, 3, 10]);
+ expect([...ladder[2].family.optional]).toEqual([2, 7]);
+ expect(ladder[3].family.minNotes).toBe(3);
+ });
+
+ it('leaves sus4 extensions alone (no third, no seventh)', () => {
+ const custom: VoicingFamily = { ...fam('sus4'), id: 'custom_sus4', bass: [0], preferredBass: undefined };
+ expect(relaxFamily(custom).map((s) => s.relaxed.at(-1))).toEqual(['any bass note']);
+ expect([...relaxFamily(custom)[0].family.required]).toEqual([0, 5, 7]);
+ });
+
+ it('walks the full ladder for phrygian and stays cumulative', () => {
+ const ladder = relaxFamily(fam('phrygian'));
+ expect(ladder.map((s) => s.relaxed)).toEqual([
+ ['any bass note'],
+ ['any bass note', 'no 5th'],
+ ['any bass note', 'no 5th', 'any note count']
+ ]);
+ const last = ladder[2].family;
+ expect(last.bass).toBe('any');
+ expect([...last.required]).toEqual([0, 1, 4]);
+ expect([...last.optional]).toEqual([7, 10]);
+ expect(last.minNotes).toBe(3);
+ expect(last.maxNotes).toBe(6);
+ });
+
+ it('keeps the drone rule, group and forbidden list, and gives each step a distinct id', () => {
+ for (const id of ['drone_power5', 'drone_phrygian']) {
+ const base = fam(id);
+ const ladder = relaxFamily(base);
+ expect(ladder.length, id).toBeGreaterThan(0);
+ for (const step of ladder) {
+ expect(step.family.drone).toBe(true);
+ expect(step.family.group).toBe('drones');
+ expect(step.family.forbidden).toEqual(base.forbidden);
+ expect(step.family.id.startsWith(id)).toBe(true);
+ }
+ const ids = ladder.map((s) => s.family.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ expect(ids).not.toContain(id);
+ }
+ });
+
+ it('returns nothing for a family that is already fully relaxed', () => {
+ for (const id of ['power5', 'phrygian']) {
+ const loose = relaxFamily(fam(id)).at(-1)!.family;
+ expect(relaxFamily(loose), id).toEqual([]);
+ }
+ });
+
+ it('relaxed families accept shapes the exact recipe rejects', () => {
+ const inverted: Shape = [x, x, 2, 3, 1, x]; // E A# C: C7 shell with the third in the bass
+ expect(matchFamily(fam('7'), C, inverted, E_STANDARD).ok).toBe(false);
+ expect(matchFamily(relaxFamily(fam('7'))[0].family, C, inverted, E_STANDARD).ok).toBe(true);
+ const noFifth: Shape = [0, 8, 6, x, x, x]; // E F G#
+ expect(matchFamily(fam('phrygian'), E, noFifth, E_STANDARD).ok).toBe(false);
+ expect(matchFamily(relaxFamily(fam('phrygian'))[2].family, E, noFifth, E_STANDARD).ok).toBe(true);
+ });
+
+ it('does not mutate the input family', () => {
+ const before = JSON.stringify(fam('phrygian'));
+ relaxFamily(fam('phrygian'));
+ expect(JSON.stringify(fam('phrygian'))).toBe(before);
+ });
+});
diff --git a/engine/voicingSpec.ts b/engine/voicingSpec.ts
new file mode 100644
index 0000000..9b9fdcf
--- /dev/null
+++ b/engine/voicingSpec.ts
@@ -0,0 +1,510 @@
+// Voicing families: interval recipes relative to a chosen root, plus exact validation of concrete shapes.
+import type { FamilyGroup, FamilyTag, Shape, Tuning, VoicingFamily } from './types';
+import { degreeLabel, intervalFrom, parseNoteName, parsePitchClass, pitchClass } from './pitch';
+import { shapeToMidiByString } from './shape';
+
+export type FamilyMismatchReason = 'PITCH_CLASSES' | 'NOTE_COUNT' | 'BASS' | 'NO_DRONE_STRING';
+
+/** Narrow with `m.ok === false`: without strictNullChecks (the apps' setting) `!m.ok` does not narrow. */
+export type FamilyMatch = { ok: true } | { ok: false; reason: FamilyMismatchReason; detail: string };
+
+/** Guitar string count cap for note-count limits. */
+const MAX_VOICING_NOTES = 6;
+
+const sortedUnique = (values: readonly number[]): number[] =>
+ [...new Set(values.map(pitchClass))].sort((a, b) => a - b);
+
+const freezeFamily = (family: VoicingFamily): VoicingFamily =>
+ Object.freeze({
+ ...family,
+ required: Object.freeze([...family.required]),
+ optional: Object.freeze([...family.optional]),
+ forbidden: Object.freeze([...family.forbidden]),
+ bass: family.bass === 'any' ? 'any' : Object.freeze([...family.bass]),
+ tags: Object.freeze([...family.tags])
+ });
+
+interface FamilyInput {
+ id: string;
+ label: string;
+ description: string;
+ group: FamilyGroup;
+ required: number[];
+ optional?: number[];
+ forbidden?: number[];
+ bass: number[] | 'any';
+ preferredBass?: number;
+ minNotes: number;
+ maxNotes: number;
+ tags?: FamilyTag[];
+}
+
+const define = (input: FamilyInput): VoicingFamily => {
+ const family: VoicingFamily = {
+ id: input.id,
+ label: input.label,
+ description: input.description,
+ group: input.group,
+ required: input.required,
+ optional: input.optional ?? [],
+ forbidden: input.forbidden ?? [],
+ bass: input.bass,
+ minNotes: input.minNotes,
+ maxNotes: input.maxNotes,
+ tags: input.tags ?? []
+ };
+ if (input.preferredBass !== undefined) family.preferredBass = input.preferredBass;
+ return freezeFamily(family);
+};
+
+const triad = (id: string, label: string, required: number[], description: string): VoicingFamily =>
+ define({ id, label, description, group: 'triads', required, bass: 'any', minNotes: 3, maxNotes: 6, tags: ['triad'] });
+
+export const VOICING_FAMILIES: readonly VoicingFamily[] = Object.freeze([
+ define({
+ id: 'power5',
+ label: '5',
+ description: 'Root and fifth: the tight, gain-proof metal workhorse.',
+ group: 'power',
+ required: [0, 7],
+ forbidden: [3, 4],
+ bass: [0],
+ minNotes: 2,
+ maxNotes: 4,
+ tags: ['metal-friendly', 'pedal-compatible']
+ }),
+ define({
+ id: 'power5_b2',
+ label: '5 b2',
+ description: 'Power chord with a minor-second rub for a darker, menacing edge.',
+ group: 'power',
+ required: [0, 1, 7],
+ bass: [0],
+ minNotes: 3,
+ maxNotes: 5,
+ tags: ['metal-friendly', 'dissonant']
+ }),
+ define({
+ id: 'dyad_b2',
+ label: 'b2 dyad',
+ description: 'Root against its minor second: a raw, grinding dissonance.',
+ group: 'power',
+ required: [0, 1],
+ bass: [0],
+ minNotes: 2,
+ maxNotes: 4,
+ tags: ['metal-friendly', 'dissonant']
+ }),
+ define({
+ id: 'tritone',
+ label: 'Tritone',
+ description: 'Root and flat fifth: an unresolved, uneasy dyad.',
+ group: 'power',
+ required: [0, 6],
+ bass: [0],
+ minNotes: 2,
+ maxNotes: 4,
+ tags: ['metal-friendly', 'dissonant']
+ }),
+ define({
+ id: 'fourth',
+ label: '4th',
+ description: 'Root and fourth: a hollow dyad that stays clear under gain.',
+ group: 'power',
+ required: [0, 5],
+ bass: [0],
+ minNotes: 2,
+ maxNotes: 4,
+ tags: ['metal-friendly']
+ }),
+ define({
+ id: 'sus2',
+ label: 'sus2',
+ description: 'Root, second and fifth: open and unresolved, no third.',
+ group: 'susAdd',
+ required: [0, 2, 7],
+ forbidden: [3, 4],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 6
+ }),
+ define({
+ id: 'sus4',
+ label: 'sus4',
+ description: 'Root, fourth and fifth: suspended tension with no third.',
+ group: 'susAdd',
+ required: [0, 5, 7],
+ forbidden: [3, 4],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 6
+ }),
+ define({
+ id: 'add9',
+ label: 'add9',
+ description: 'Major third with an added ninth; the fifth is optional.',
+ group: 'susAdd',
+ required: [0, 2, 4],
+ optional: [7],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 6,
+ tags: ['color']
+ }),
+ define({
+ id: 'm_add9',
+ label: 'm(add9)',
+ description: 'Minor third with an added ninth: dark and wide; the fifth is optional.',
+ group: 'susAdd',
+ required: [0, 2, 3],
+ optional: [7],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 6,
+ tags: ['color', 'metal-friendly']
+ }),
+ define({
+ id: '7',
+ label: '7 shell',
+ description: 'Dominant shell: root, major third and flat seventh.',
+ group: 'shells',
+ required: [0, 4, 10],
+ bass: [0],
+ minNotes: 3,
+ maxNotes: 4
+ }),
+ define({
+ id: 'm7',
+ label: 'm7 shell',
+ description: 'Minor seventh shell: root, minor third and flat seventh.',
+ group: 'shells',
+ required: [0, 3, 10],
+ bass: [0],
+ minNotes: 3,
+ maxNotes: 4
+ }),
+ define({
+ id: 'maj7',
+ label: 'maj7 shell',
+ description: 'Major seventh shell: root, major third and major seventh.',
+ group: 'shells',
+ required: [0, 4, 11],
+ bass: [0],
+ minNotes: 3,
+ maxNotes: 4,
+ tags: ['color']
+ }),
+ define({
+ id: 'quartal',
+ label: 'Quartal',
+ description: 'The notes of stacked fourths (root, fourth, flat seventh, optional minor third) in any spacing.',
+ group: 'quartalCluster',
+ required: [0, 5, 10],
+ optional: [3],
+ bass: [0],
+ minNotes: 3,
+ maxNotes: 4,
+ tags: ['color']
+ }),
+ define({
+ id: 'cluster_m2',
+ label: 'm2 cluster',
+ description: 'Root, minor second and major second: chromatic neighbours that grind when voiced close.',
+ group: 'quartalCluster',
+ required: [0, 1, 2],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 4,
+ tags: ['dissonant', 'color']
+ }),
+ define({
+ id: 'cluster_b3',
+ label: 'b3 cluster',
+ description: 'Root, second and minor third with no fifth: a dark, moody colour.',
+ group: 'quartalCluster',
+ required: [0, 2, 3],
+ bass: 'any',
+ preferredBass: 0,
+ minNotes: 3,
+ maxNotes: 4,
+ tags: ['color']
+ }),
+ define({
+ id: 'phrygian',
+ label: 'Phrygian',
+ description: 'Root, flat second, major third and fifth: Phrygian-dominant menace.',
+ group: 'darkColors',
+ required: [0, 1, 4, 7],
+ optional: [10],
+ bass: [0],
+ minNotes: 4,
+ maxNotes: 6,
+ tags: ['metal-friendly', 'dissonant']
+ }),
+ triad('major', 'Major', [0, 4, 7], 'Major triad in any inversion.'),
+ triad('minor', 'Minor', [0, 3, 7], 'Minor triad in any inversion.'),
+ triad('dim', 'Dim', [0, 3, 6], 'Diminished triad in any inversion.'),
+ triad('aug', 'Aug', [0, 4, 8], 'Augmented triad in any inversion.')
+]);
+
+/** Adds the open-string drone rule; pitch-class, bass and note-count rules stay untouched. Idempotent. */
+export const withDrone = (family: VoicingFamily): VoicingFamily => {
+ if (family.drone) return family;
+ return freezeFamily({
+ ...family,
+ id: `drone_${family.id}`,
+ label: `Drone ${family.label}`,
+ description: `${family.description} An open string rings the root or fifth.`,
+ group: 'drones',
+ drone: true
+ });
+};
+
+const DRONE_BASE_IDS = ['power5', 'fourth', 'sus2', 'm_add9', 'quartal', 'phrygian'] as const;
+
+export const DRONE_FAMILIES: readonly VoicingFamily[] = Object.freeze(
+ DRONE_BASE_IDS.map((id) => withDrone(VOICING_FAMILIES.find((f) => f.id === id) as VoicingFamily))
+);
+
+export const ALL_FAMILIES: readonly VoicingFamily[] = Object.freeze([...VOICING_FAMILIES, ...DRONE_FAMILIES]);
+
+const GROUP_LABELS: readonly [Exclude, string][] = [
+ ['power', 'Power and dyads'],
+ ['susAdd', 'Sus and add'],
+ ['shells', 'Shells'],
+ ['quartalCluster', 'Quartal and clusters'],
+ ['darkColors', 'Dark colors'],
+ ['triads', 'Triads'],
+ ['drones', 'Drones']
+];
+
+export const FAMILY_GROUPS: readonly { id: FamilyGroup; label: string; familyIds: readonly string[] }[] = Object.freeze(
+ GROUP_LABELS.map(([id, label]) =>
+ Object.freeze({
+ id,
+ label,
+ familyIds: Object.freeze(ALL_FAMILIES.filter((f) => f.group === id).map((f) => f.id))
+ })
+ )
+);
+
+const FAMILY_BY_ID: ReadonlyMap = new Map(ALL_FAMILIES.map((f) => [f.id, f]));
+
+export const getFamily = (id: string): VoicingFamily | undefined => FAMILY_BY_ID.get(id);
+
+export const allowedIntervals = (family: VoicingFamily): number[] =>
+ sortedUnique([...family.required, ...family.optional]);
+
+// ---------------------------------------------------------------------------
+// Matching concrete shapes
+// ---------------------------------------------------------------------------
+
+const maskOf = (intervals: readonly number[]): number => {
+ let mask = 0;
+ for (const i of intervals) mask |= 1 << pitchClass(i);
+ return mask;
+};
+
+const degreesOfMask = (mask: number): string => {
+ const labels: string[] = [];
+ for (let i = 0; i < 12; i++) if (mask & (1 << i)) labels.push(degreeLabel(i));
+ return labels.join(' ');
+};
+
+const noteCountText = (family: VoicingFamily): string =>
+ family.minNotes === family.maxNotes ? `${family.minNotes}` : `${family.minNotes}..${family.maxNotes}`;
+
+const DRONE_INTERVALS_MASK = maskOf([0, 7]);
+
+/**
+ * Exact validation in a fixed order: note count, pitch classes (required present, nothing outside
+ * required + optional, nothing forbidden), bass interval of the lowest-pitched note, then the drone rule.
+ */
+export const matchFamily = (family: VoicingFamily, rootPc: number, shape: Shape, tuning: Tuning): FamilyMatch => {
+ const root = pitchClass(rootPc);
+ const byString = shapeToMidiByString(shape, tuning);
+
+ let count = 0;
+ let bass = Infinity;
+ let present = 0;
+ for (const midi of byString) {
+ if (midi === null) continue;
+ count++;
+ if (midi < bass) bass = midi;
+ present |= 1 << intervalFrom(root, pitchClass(midi));
+ }
+
+ if (count < family.minNotes || count > family.maxNotes) {
+ return {
+ ok: false,
+ reason: 'NOTE_COUNT',
+ detail: `${count} ${count === 1 ? 'note' : 'notes'}, ${family.label} needs ${noteCountText(family)}`
+ };
+ }
+
+ const required = maskOf(family.required);
+ const allowed = required | maskOf(family.optional);
+ const missing = required & ~present;
+ if (missing) {
+ return { ok: false, reason: 'PITCH_CLASSES', detail: `missing ${degreesOfMask(missing)}` };
+ }
+ const forbidden = present & maskOf(family.forbidden);
+ if (forbidden) {
+ return { ok: false, reason: 'PITCH_CLASSES', detail: `${degreesOfMask(forbidden)} is forbidden in ${family.label}` };
+ }
+ const outside = present & ~allowed;
+ if (outside) {
+ return { ok: false, reason: 'PITCH_CLASSES', detail: `${degreesOfMask(outside)} is outside ${family.label}` };
+ }
+
+ if (family.bass !== 'any') {
+ const bassInterval = intervalFrom(root, pitchClass(bass));
+ if (!family.bass.includes(bassInterval)) {
+ return {
+ ok: false,
+ reason: 'BASS',
+ detail: `bass is ${degreeLabel(bassInterval)}, ${family.label} needs ${family.bass.map(degreeLabel).join(' or ')}`
+ };
+ }
+ }
+
+ if (family.drone) {
+ const hasDrone = byString.some(
+ (midi, s) =>
+ midi !== null && shape[s] === 0 && (DRONE_INTERVALS_MASK & (1 << intervalFrom(root, pitchClass(midi)))) !== 0
+ );
+ if (!hasDrone) {
+ return { ok: false, reason: 'NO_DRONE_STRING', detail: 'no open string sounds the root or 5th' };
+ }
+ }
+
+ return { ok: true };
+};
+
+// ---------------------------------------------------------------------------
+// Legacy RiffForge chord rows
+// ---------------------------------------------------------------------------
+
+/**
+ * Converts a RiffForge JSON `notes` array into a recipe: pitch classes relative to `baseRoot` are all
+ * required, the lowest parsed note fixes the bass. Unparseable tokens are skipped and do not count as notes.
+ */
+export const fromLegacyNotes = (notes: readonly string[], baseRoot: string): VoicingFamily => {
+ const root = parsePitchClass(baseRoot);
+ if (root === null) throw new Error(`Invalid baseRoot: ${baseRoot}`);
+ const midi = notes.map(parseNoteName).filter((m): m is number => m !== null);
+ if (midi.length === 0) throw new Error(`No parseable notes in [${notes.join(', ')}]`);
+
+ const required = sortedUnique(midi.map((m) => intervalFrom(root, pitchClass(m))));
+ const bass = intervalFrom(root, pitchClass(Math.min(...midi)));
+ const label = required.map(degreeLabel).join(' ');
+ return define({
+ id: `legacy:${required.join('.')}/${bass}`,
+ label,
+ description: `Library chord recipe ${label} with ${degreeLabel(bass)} in the bass.`,
+ group: 'legacy',
+ required,
+ bass: [bass],
+ minNotes: required.length,
+ maxNotes: Math.min(MAX_VOICING_NOTES, Math.max(required.length, midi.length))
+ });
+};
+
+// ---------------------------------------------------------------------------
+// Relaxation ladder
+// ---------------------------------------------------------------------------
+
+interface RelaxStep {
+ key: string;
+ label: string;
+ apply: (family: VoicingFamily) => VoicingFamily;
+}
+
+const moveToOptional = (family: VoicingFamily, intervals: readonly number[]): VoicingFamily => ({
+ ...family,
+ required: family.required.filter((i) => !intervals.includes(i)),
+ optional: sortedUnique([...family.optional, ...family.required.filter((i) => intervals.includes(i))])
+});
+
+const EXTENSIONS = [2, 5, 9];
+const THIRDS_AND_SEVENTHS = [3, 4, 10, 11];
+
+const RELAX_STEPS: readonly RelaxStep[] = [
+ {
+ key: 'anyBass',
+ label: 'any bass note',
+ // Keeps the old bass as a soft preference so root-position results still rank first.
+ apply: (f) => {
+ if (f.bass === 'any') return f;
+ const preferredBass = f.preferredBass ?? f.bass[0];
+ return preferredBass === undefined ? { ...f, bass: 'any' } : { ...f, bass: 'any', preferredBass };
+ }
+ },
+ {
+ key: 'no5',
+ label: 'no 5th',
+ apply: (f) => (f.required.includes(7) && f.required.length >= 3 ? moveToOptional(f, [7]) : f)
+ },
+ {
+ key: 'noExt',
+ label: 'no extensions',
+ apply: (f) => (f.required.some((i) => THIRDS_AND_SEVENTHS.includes(i)) ? moveToOptional(f, EXTENSIONS) : f)
+ },
+ {
+ key: 'anyCount',
+ label: 'any note count',
+ apply: (f) => ({ ...f, minNotes: Math.max(2, f.required.length), maxNotes: MAX_VOICING_NOTES })
+ }
+];
+
+/**
+ * Two required pitch classes over a free bass: the dyad may sound inverted, which swaps its only interval for the
+ * complement (b2 -> 7, 4 -> 5). The tritone is its own inversion.
+ */
+const invertibleDyad = (f: VoicingFamily): boolean => {
+ const required = sortedUnique(f.required);
+ return f.bass === 'any' && required.length === 2 && required[1] - required[0] !== 6;
+};
+
+const sameRecipe = (a: VoicingFamily, b: VoicingFamily): boolean =>
+ a.minNotes === b.minNotes &&
+ a.maxNotes === b.maxNotes &&
+ a.preferredBass === b.preferredBass &&
+ a.required.join() === b.required.join() &&
+ a.optional.join() === b.optional.join() &&
+ (a.bass === 'any' ? b.bass === 'any' : b.bass !== 'any' && a.bass.join() === b.bass.join());
+
+/**
+ * Ordered, cumulative relaxations for the "closest relaxed suggestion". Steps that change nothing are
+ * skipped, and so are steps that would leave an invertible dyad: freeing the bass of a b2 dyad, or dropping the
+ * 5th of sus4 or the 9th of add9, would return a different interval instead of a looser version of the family.
+ * Each relaxed family gets a derived id (`~~...`) so it never collides with the exact one.
+ */
+export const relaxFamily = (family: VoicingFamily): { family: VoicingFamily; relaxed: string[] }[] => {
+ const ladder: { family: VoicingFamily; relaxed: string[] }[] = [];
+ let current = family;
+ const keys: string[] = [];
+ const labels: string[] = [];
+ for (const step of RELAX_STEPS) {
+ const next = step.apply(current);
+ if (sameRecipe(next, current)) continue;
+ if (invertibleDyad(next) && !invertibleDyad(current)) continue;
+ keys.push(step.key);
+ labels.push(step.label);
+ current = next;
+ ladder.push({
+ family: freezeFamily({
+ ...current,
+ id: `${family.id}~${keys.join('~')}`,
+ description: `${family.description} Relaxed: ${labels.join(', ')}.`
+ }),
+ relaxed: [...labels]
+ });
+ }
+ return ladder;
+};
diff --git a/engine/weights.ts b/engine/weights.ts
new file mode 100644
index 0000000..4c844c3
--- /dev/null
+++ b/engine/weights.ts
@@ -0,0 +1,69 @@
+// Default cost weights. Every term is normalized to 0..1 before weighting, so a weight reads as
+// "the most this term can add". Values are heuristic starting points; the ISMIR 2023 fitted
+// weights (Velez Vasquez et al.) guide the relative order, they are not reproduced exactly.
+import type { CostBucket, CostTermKey } from './types';
+
+export interface CostWeight {
+ bucket: CostBucket;
+ weight: number;
+}
+
+export const PLAYABILITY_WEIGHTS: Readonly> = {
+ // normalized = reachRatio^2. ISMIR 2023 gives finger positioning (cramped/wide) its top weight (3);
+ // squaring keeps comfortable spans cheap and makes the last few millimetres expensive.
+ reach: { bucket: 'physical', weight: 3.0 },
+ // normalized = maxPairStretch^2: how far the worst consecutive finger pair is pulled beyond its relaxed
+ // one-fret-per-finger spacing toward its hard limit. Plain ratio-to-limit would punish the ordinary
+ // one-finger-per-fret hand frame (middle-ring sits near its limit there) and pick odd fingerings.
+ pairStretch: { bucket: 'physical', weight: 1.5 },
+ // normalized = fingerCount / 4. ISMIR fingering difficulty (2) also covers barre, which is a hard
+ // constraint here, so the remaining finger-count share is lower.
+ fingerCount: { bucket: 'physical', weight: 1.0 },
+ // normalized = 1 when the pinky frets a note. Hori and Sagayama rank the pinky least preferred.
+ pinky: { bucket: 'physical', weight: 0.5 },
+ // normalized = min(1, contortions / 2): lower-numbered finger on a higher string at the same fret.
+ contortion: { bucket: 'physical', weight: 1.0 },
+ // normalized = min(1, interiorMutes / 2): each muted string inside the voicing needs a finger to
+ // damp it, which gets noisy under gain.
+ interiorMutes: { bucket: 'physical', weight: 1.5 },
+ // normalized = min(1, lowestFret / 12). Mild preference for lower positions only.
+ position: { bucket: 'physical', weight: 0.3 },
+ // normalized = 1 when the thumb frets the lowest string (only possible when the profile allows it).
+ thumb: { bucket: 'physical', weight: 1.0 },
+ // normalized = min(1, barres). Only reachable in barre mode; the generator default forbids barres.
+ barre: { bucket: 'physical', weight: 2.0 },
+
+ // normalized = min(1, skippedStrings / 2). ISMIR right-hand complexity (skipped strings) weight 2.
+ stringSkips: { bucket: 'rightHand', weight: 2.0 },
+ // Chug context only: 0.5 * min(1, (soundingStrings - 1) / 5) + 0.5 * meanStringIndex / (strings - 1).
+ // Fewer, lower strings stay tight under palm muting. Always 0 in the 'strum' context.
+ chugStrings: { bucket: 'rightHand', weight: 0.5 },
+
+ // Distortion only: normalized = min(1, pairs / 2) over note pairs both below MIDI 52 (E3) that are
+ // 1..4 semitones apart. Narrow low intervals turn to mud under high gain.
+ lowMud: { bucket: 'musical', weight: 2.0 },
+ // normalized = min(1, thirdsSounding - 1): a doubled third is thick and hard to keep in tune.
+ doubledThird: { bucket: 'musical', weight: 0.5 },
+ // normalized = 1 when the family prefers the root in the bass (preferredBass 0) and the bass is another
+ // degree. A preferred bass on another degree (a relaxed slash-bass recipe) is not charged here.
+ rootNotInBass: { bucket: 'musical', weight: 0.5 }
+};
+
+export const PHYSICAL_TERMS: readonly CostTermKey[] = [
+ 'reach',
+ 'pairStretch',
+ 'fingerCount',
+ 'pinky',
+ 'contortion',
+ 'interiorMutes',
+ 'position',
+ 'thumb',
+ 'barre'
+];
+
+export const RIGHT_HAND_TERMS: readonly CostTermKey[] = ['stringSkips', 'chugStrings'];
+
+export const MUSICAL_TERMS: readonly CostTermKey[] = ['lowMud', 'doubledThird', 'rootNotInBass'];
+
+/** MIDI note below which narrow intervals count as low-register mud (E3). */
+export const LOW_MUD_THRESHOLD_MIDI = 52;
diff --git a/public/chords/drop-melodic.json b/public/chords/drop-melodic.json
index a0a5529..46367a5 100644
--- a/public/chords/drop-melodic.json
+++ b/public/chords/drop-melodic.json
@@ -9,9 +9,9 @@
"baseRoot": "D",
"relatedChords": [
{ "id": "drop-melodic-1-child-1", "name": "Drop Bb Maj7", "subtext": "Bb(VI)", "notes": ["Bb2", "D3", "F3", "A3"], "description": "Relative major transition.", "fretboard": "x 1 3 2 1 x", "baseRoot": "A#" },
- { "id": "drop-melodic-1-child-2", "name": "Drop Gm Add9", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "D4"], "description": "Subdominant extension.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
+ { "id": "drop-melodic-1-child-2", "name": "Drop Gm Add9", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"], "description": "Subdominant extension.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
{ "id": "drop-melodic-1-child-3", "name": "Drop Am Lift", "subtext": "Am(v)", "notes": ["A2", "C3", "E3", "A3"], "description": "Dominant resolution.", "fretboard": "x 0 2 2 0 x", "baseRoot": "A" },
- { "id": "drop-melodic-1-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "A#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
+ { "id": "drop-melodic-1-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "G#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
{ "id": "drop-melodic-1-child-5", "name": "Drop Edim Suspension", "subtext": "Edim(ii)", "notes": ["E2", "G2", "A#2", "E3"], "description": "Supertonic movement.", "fretboard": "0 3 6 0 x x", "baseRoot": "E" },
{ "id": "drop-melodic-1-child-6", "name": "Drop C Major", "subtext": "C(VII)", "notes": ["C2", "E2", "G2", "C3"], "description": "Subtonic resolution.", "fretboard": "x 3 5 5 5 x", "baseRoot": "C" }
]
@@ -26,7 +26,7 @@
"baseRoot": "A#",
"relatedChords": [
{ "id": "drop-melodic-2-child-1", "name": "Drop Ghost Return", "subtext": "Dm(i)", "notes": ["D2", "A2", "E3", "F3", "A3", "D4"], "description": "Back to tonic.", "fretboard": "0 2 4 0 0 0", "baseRoot": "D" },
- { "id": "drop-melodic-2-child-2", "name": "Drop Gm Add9", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "D4"], "description": "Subdominant flow.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
+ { "id": "drop-melodic-2-child-2", "name": "Drop Gm Add9", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"], "description": "Subdominant flow.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
{ "id": "drop-melodic-2-child-3", "name": "Drop F Major Bright", "subtext": "F(IV)", "notes": ["F2", "A2", "C3", "F3"], "description": "Bright subdominant.", "fretboard": "1 3 3 2 1 x", "baseRoot": "F" },
{ "id": "drop-melodic-2-child-4", "name": "Drop Am Lift", "subtext": "Am(v)", "notes": ["A2", "C3", "E3", "A3"], "description": "Dominant movement.", "fretboard": "x 0 2 2 0 x", "baseRoot": "A" },
{ "id": "drop-melodic-2-child-5", "name": "Drop Edim Suspension", "subtext": "Edim(ii)", "notes": ["E2", "G2", "A#2", "E3"], "description": "Smooth transition.", "fretboard": "0 3 6 0 x x", "baseRoot": "E" },
@@ -37,7 +37,7 @@
"id": "drop-melodic-3",
"name": "Drop Gm Add9",
"subtext": "Gm(iv)",
- "notes": ["G2", "D3", "G3", "A3", "D4"],
+ "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"],
"description": "Subdominant with extension. Flowing.",
"fretboard": "3 0 0 2 0 x",
"baseRoot": "G",
@@ -60,9 +60,9 @@
"baseRoot": "D",
"relatedChords": [
{ "id": "drop-melodic-4-child-1", "name": "Drop Bb Maj7 Ethereal", "subtext": "Bb(VI)", "notes": ["Bb2", "D3", "F3", "A3"], "description": "Ethereal transition.", "fretboard": "x 1 3 2 1 x", "baseRoot": "A#" },
- { "id": "drop-melodic-4-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "D4"], "description": "Flowing subdominant.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
+ { "id": "drop-melodic-4-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"], "description": "Flowing subdominant.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
{ "id": "drop-melodic-4-child-3", "name": "Drop Am Lift", "subtext": "Am(v)", "notes": ["A2", "C3", "E3", "A3"], "description": "Dominant movement.", "fretboard": "x 0 2 2 0 x", "baseRoot": "A" },
- { "id": "drop-melodic-4-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "A#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
+ { "id": "drop-melodic-4-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "G#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
{ "id": "drop-melodic-4-child-5", "name": "Drop Edim Suspension", "subtext": "Edim(ii)", "notes": ["E2", "G2", "A#2", "E3"], "description": "Smooth transition.", "fretboard": "0 3 6 0 x x", "baseRoot": "E" },
{ "id": "drop-melodic-4-child-6", "name": "Drop C Major", "subtext": "C(VII)", "notes": ["C2", "E2", "G2", "C3"], "description": "Subtonic resolution.", "fretboard": "x 3 5 5 5 x", "baseRoot": "C" }
]
@@ -77,7 +77,7 @@
"baseRoot": "A#",
"relatedChords": [
{ "id": "drop-melodic-5-child-1", "name": "Drop Ghost Return", "subtext": "Dm(i)", "notes": ["D2", "A2", "E3", "F3", "A3", "D4"], "description": "Return to tonic.", "fretboard": "0 2 4 0 0 0", "baseRoot": "D" },
- { "id": "drop-melodic-5-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "D4"], "description": "Subdominant flow.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
+ { "id": "drop-melodic-5-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"], "description": "Subdominant flow.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
{ "id": "drop-melodic-5-child-3", "name": "Drop F Major Bright", "subtext": "F(IV)", "notes": ["F2", "A2", "C3", "F3"], "description": "Bright movement.", "fretboard": "1 3 3 2 1 x", "baseRoot": "F" },
{ "id": "drop-melodic-5-child-4", "name": "Drop Am Resolve", "subtext": "Am(v)", "notes": ["A2", "C3", "E3", "A3"], "description": "Dominant resolution.", "fretboard": "x 0 2 2 0 x", "baseRoot": "A" },
{ "id": "drop-melodic-5-child-5", "name": "Drop Edim Suspension", "subtext": "Edim(ii)", "notes": ["E2", "G2", "A#2", "E3"], "description": "Smooth transition.", "fretboard": "0 3 6 0 x x", "baseRoot": "E" },
@@ -94,9 +94,9 @@
"baseRoot": "D",
"relatedChords": [
{ "id": "drop-melodic-6-child-1", "name": "Drop Bb Maj7 Rich", "subtext": "Bb(VI)", "notes": ["Bb2", "D3", "F3", "A3"], "description": "Rich transition.", "fretboard": "x 1 3 2 1 x", "baseRoot": "A#" },
- { "id": "drop-melodic-6-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "D4"], "description": "Flowing subdominant.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
+ { "id": "drop-melodic-6-child-2", "name": "Drop Gm Add9 Flow", "subtext": "Gm(iv)", "notes": ["G2", "D3", "G3", "A3", "A#3", "D4"], "description": "Flowing subdominant.", "fretboard": "3 0 0 2 0 x", "baseRoot": "G" },
{ "id": "drop-melodic-6-child-3", "name": "Drop Am Lift", "subtext": "Am(v)", "notes": ["A2", "C3", "E3", "A3"], "description": "Dominant movement.", "fretboard": "x 0 2 2 0 x", "baseRoot": "A" },
- { "id": "drop-melodic-6-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "A#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
+ { "id": "drop-melodic-6-child-4", "name": "Drop Fm Mediant", "subtext": "Fm(iii)", "notes": ["F2", "G#2", "C3", "F3"], "description": "Mediant tension.", "fretboard": "1 3 3 1 1 x", "baseRoot": "F" },
{ "id": "drop-melodic-6-child-5", "name": "Drop Edim Suspension", "subtext": "Edim(ii)", "notes": ["E2", "G2", "A#2", "E3"], "description": "Smooth transition.", "fretboard": "0 3 6 0 x x", "baseRoot": "E" },
{ "id": "drop-melodic-6-child-6", "name": "Drop C Major", "subtext": "C(VII)", "notes": ["C2", "E2", "G2", "C3"], "description": "Subtonic resolution.", "fretboard": "x 3 5 5 5 x", "baseRoot": "C" }
]
diff --git a/public/chords/standard-melodic.json b/public/chords/standard-melodic.json
index 7c4dc94..75d09fe 100644
--- a/public/chords/standard-melodic.json
+++ b/public/chords/standard-melodic.json
@@ -9,7 +9,7 @@
"baseRoot": "E",
"relatedChords": [
{ "id": "melodic-1-child-1", "name": "C Maj7 Bridge", "subtext": "C(VI)", "notes": ["C3", "E3", "G3", "B3"], "description": "Relative major transition.", "fretboard": "x 3 2 0 1 0", "baseRoot": "C" },
- { "id": "melodic-1-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "E4"], "description": "Subdominant extension.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
+ { "id": "melodic-1-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "C4", "E4"], "description": "Subdominant extension.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
{ "id": "melodic-1-child-3", "name": "G Major Lift", "subtext": "G(V)", "notes": ["G2", "B2", "D3", "G3"], "description": "Dominant resolution.", "fretboard": "3 2 0 0 3 x", "baseRoot": "G" },
{ "id": "melodic-1-child-4", "name": "Bm Suspension", "subtext": "Bm(iii)", "notes": ["B2", "D3", "F#3", "B3"], "description": "Mediant tension.", "fretboard": "x 2 4 4 2 x", "baseRoot": "B" },
{ "id": "melodic-1-child-5", "name": "D Minor Resolve", "subtext": "Dm(ii)", "notes": ["D3", "F3", "A3", "D4"], "description": "Supertonic movement.", "fretboard": "x x 0 2 3 1", "baseRoot": "D" },
@@ -26,7 +26,7 @@
"baseRoot": "C",
"relatedChords": [
{ "id": "melodic-2-child-1", "name": "Em Ghost Return", "subtext": "Em(i)", "notes": ["E2", "B2", "F#3", "G3", "B3", "E4"], "description": "Back to tonic.", "fretboard": "0 2 4 0 0 0", "baseRoot": "E" },
- { "id": "melodic-2-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "E4"], "description": "Subdominant flow.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
+ { "id": "melodic-2-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "C4", "E4"], "description": "Subdominant flow.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
{ "id": "melodic-2-child-3", "name": "F Major Bright", "subtext": "F(IV)", "notes": ["F2", "A2", "C3", "F3"], "description": "Bright subdominant.", "fretboard": "1 3 3 2 1 x", "baseRoot": "F" },
{ "id": "melodic-2-child-4", "name": "G Major Lift", "subtext": "G(V)", "notes": ["G2", "B2", "D3", "G3"], "description": "Dominant movement.", "fretboard": "3 2 0 0 3 x", "baseRoot": "G" },
{ "id": "melodic-2-child-5", "name": "Dm Suspension", "subtext": "Dm(ii)", "notes": ["D3", "F3", "A3", "D4"], "description": "Smooth transition.", "fretboard": "x x 0 2 3 1", "baseRoot": "D" },
@@ -37,7 +37,7 @@
"id": "melodic-3",
"name": "Am Add9",
"subtext": "Am(iv)",
- "notes": ["A2", "E3", "A3", "B3", "E4"],
+ "notes": ["A2", "E3", "A3", "B3", "C4", "E4"],
"description": "Subdominant with extension. Flowing.",
"fretboard": "x 0 2 2 0 0",
"baseRoot": "A",
@@ -60,7 +60,7 @@
"baseRoot": "E",
"relatedChords": [
{ "id": "melodic-4-child-1", "name": "C Maj7 Ethereal", "subtext": "C(VI)", "notes": ["C3", "E3", "G3", "B3"], "description": "Ethereal transition.", "fretboard": "x 3 2 0 1 0", "baseRoot": "C" },
- { "id": "melodic-4-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "E4"], "description": "Flowing subdominant.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
+ { "id": "melodic-4-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "C4", "E4"], "description": "Flowing subdominant.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
{ "id": "melodic-4-child-3", "name": "G Major Lift", "subtext": "G(V)", "notes": ["G2", "B2", "D3", "G3"], "description": "Dominant movement.", "fretboard": "3 2 0 0 3 x", "baseRoot": "G" },
{ "id": "melodic-4-child-4", "name": "Bm Mediant", "subtext": "Bm(iii)", "notes": ["B2", "D3", "F#3", "B3"], "description": "Mediant tension.", "fretboard": "x 2 4 4 2 x", "baseRoot": "B" },
{ "id": "melodic-4-child-5", "name": "Dm Suspension", "subtext": "Dm(ii)", "notes": ["D3", "F3", "A3", "D4"], "description": "Smooth transition.", "fretboard": "x x 0 2 3 1", "baseRoot": "D" },
@@ -77,7 +77,7 @@
"baseRoot": "C",
"relatedChords": [
{ "id": "melodic-5-child-1", "name": "Em Ghost Return", "subtext": "Em(i)", "notes": ["E2", "B2", "F#3", "G3", "B3", "E4"], "description": "Return to tonic.", "fretboard": "0 2 4 0 0 0", "baseRoot": "E" },
- { "id": "melodic-5-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "E4"], "description": "Subdominant flow.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
+ { "id": "melodic-5-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "C4", "E4"], "description": "Subdominant flow.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
{ "id": "melodic-5-child-3", "name": "F Major Bright", "subtext": "F(IV)", "notes": ["F2", "A2", "C3", "F3"], "description": "Bright movement.", "fretboard": "1 3 3 2 1 x", "baseRoot": "F" },
{ "id": "melodic-5-child-4", "name": "G Major Resolve", "subtext": "G(V)", "notes": ["G2", "B2", "D3", "G3"], "description": "Dominant resolution.", "fretboard": "3 2 0 0 3 x", "baseRoot": "G" },
{ "id": "melodic-5-child-5", "name": "Dm Suspension", "subtext": "Dm(ii)", "notes": ["D3", "F3", "A3", "D4"], "description": "Smooth transition.", "fretboard": "x x 0 2 3 1", "baseRoot": "D" },
@@ -94,7 +94,7 @@
"baseRoot": "E",
"relatedChords": [
{ "id": "melodic-6-child-1", "name": "C Maj7 Rich", "subtext": "C(VI)", "notes": ["C3", "E3", "G3", "B3"], "description": "Rich transition.", "fretboard": "x 3 2 0 1 0", "baseRoot": "C" },
- { "id": "melodic-6-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "E4"], "description": "Flowing subdominant.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
+ { "id": "melodic-6-child-2", "name": "Am Add9 Flow", "subtext": "Am(iv)", "notes": ["A2", "E3", "A3", "B3", "C4", "E4"], "description": "Flowing subdominant.", "fretboard": "x 0 2 2 0 0", "baseRoot": "A" },
{ "id": "melodic-6-child-3", "name": "G Major Lift", "subtext": "G(V)", "notes": ["G2", "B2", "D3", "G3"], "description": "Dominant movement.", "fretboard": "3 2 0 0 3 x", "baseRoot": "G" },
{ "id": "melodic-6-child-4", "name": "Bm Mediant", "subtext": "Bm(iii)", "notes": ["B2", "D3", "F#3", "B3"], "description": "Mediant tension.", "fretboard": "x 2 4 4 2 x", "baseRoot": "B" },
{ "id": "melodic-6-child-5", "name": "Dm Suspension", "subtext": "Dm(ii)", "notes": ["D3", "F3", "A3", "D4"], "description": "Smooth transition.", "fretboard": "x x 0 2 3 1", "baseRoot": "D" },
diff --git a/services/audioEngine.ts b/services/audioEngine.ts
index f46d063..5ce2cb4 100644
--- a/services/audioEngine.ts
+++ b/services/audioEngine.ts
@@ -1,9 +1,33 @@
import * as Tone from 'tone';
+import type { PlaybackEvent } from '../engine/types';
+import { midiToName } from '../engine/pitch';
export interface SequenceStep {
notes: string[];
}
+export interface RhythmClick {
+ timeSec: number;
+ strong: boolean;
+}
+
+export interface PlayRhythmOptions {
+ /** Loop length in seconds (the whole pattern). */
+ loopSeconds: number;
+ bpm: number;
+ /** Metronome pulses inside one loop (beats; eighths in x/8), strong on bar starts. */
+ clickTimes: RhythmClick[];
+ metronome: boolean;
+ /** Called on the UI thread (Tone.Draw) when events[index] sounds. */
+ onEvent?: (index: number) => void;
+ /** Optional playhead: called with the grid step index every stepSeconds, rests included. */
+ stepSeconds?: number;
+ onStep?: (step: number) => void;
+}
+
+/** Per-string strum spread for rhythm hits; kept tight so chugs stay on the grid. */
+const RHYTHM_STRUM_SEC = 0.006;
+
class AudioEngine {
private synth: Tone.PolySynth | null = null;
private distortion: Tone.Distortion | null = null;
@@ -13,6 +37,12 @@ class AudioEngine {
private initialized: boolean = false;
private repeatEventId: number | null = null;
private metronomeEnabled: boolean = false;
+ private rhythmSynth: Tone.PolySynth | null = null;
+ private rhythmParts: Tone.Part[] = [];
+ private rhythmLoopSeconds = 0;
+ private rhythmOffsetSeconds = 0;
+ private rhythmMetronome = false;
+ private rhythmStopListeners = new Set<() => void>();
constructor() {
// Lazy initialization
@@ -72,6 +102,15 @@ class AudioEngine {
}
}).connect(this.limiter);
+ // 6. Rhythm Lab voice: same distortion/reverb/limiter chain, but a percussive envelope so palm-muted
+ // 16ths stay tight (the chord synth's 50 ms attack and 2 s release would smear them)
+ this.rhythmSynth = new Tone.PolySynth(Tone.Synth, {
+ volume: -2,
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.003, decay: 0.25, sustain: 0.45, release: 0.08 }
+ }).connect(this.distortion);
+ this.rhythmSynth.maxPolyphony = 48;
+
this.initialized = true;
}
@@ -85,6 +124,7 @@ class AudioEngine {
oscillator: { type: "sawtooth" },
volume: -8 // Lower volume to compensate for distortion gain
});
+ this.rhythmSynth?.set({ oscillator: { type: 'sawtooth' }, volume: -8 });
this.distortion.wet.rampTo(1, 0.2);
} else {
@@ -94,6 +134,7 @@ class AudioEngine {
oscillator: { type: "triangle" },
volume: -2 // Boost volume for clean signal
});
+ this.rhythmSynth?.set({ oscillator: { type: 'triangle' }, volume: -2 });
this.distortion.wet.rampTo(0, 0.2);
}
@@ -132,6 +173,9 @@ class AudioEngine {
public playSequence(steps: SequenceStep[], bpm: number, onStep?: (index: number) => void) {
if (!this.initialized) return;
+ // The RiffBar and the Rhythm Lab share the Transport: stop the rhythm loop and tell the lab
+ if (this.isRhythmPlaying()) this.stopRhythm();
+
this.stopSequence();
const transport = Tone.getTransport();
@@ -190,6 +234,111 @@ class AudioEngine {
public isReady() {
return this.initialized;
}
+
+ /**
+ * Loops a Rhythm Lab pattern on the Transport: one Tone.Part over the playback events (time = timeSec,
+ * looped at loopSeconds), a click Part for the metronome and an optional playhead Part. Calling it while a
+ * rhythm plays restarts cleanly at the same position in the loop. Callers stop a running RiffBar sequence
+ * (and its store state) first; this method only guards the shared Transport.
+ */
+ public playRhythm(events: PlaybackEvent[], opts: PlayRhythmOptions) {
+ if (!this.initialized || !this.rhythmSynth || !(opts.loopSeconds > 0)) return;
+
+ const transport = Tone.getTransport();
+ let offset = 0;
+ if (this.rhythmParts.length > 0 && this.rhythmLoopSeconds > 0) {
+ // Loop position = start offset + Transport time; keep the same fraction of the new loop
+ const position = (this.rhythmOffsetSeconds + transport.seconds) % this.rhythmLoopSeconds;
+ const phase = position / this.rhythmLoopSeconds;
+ offset = Number.isFinite(phase) ? phase * opts.loopSeconds : 0;
+ }
+ this.teardownRhythm();
+ if (this.repeatEventId !== null) this.stopSequence();
+
+ transport.bpm.value = opts.bpm;
+ this.rhythmLoopSeconds = opts.loopSeconds;
+ this.rhythmOffsetSeconds = offset;
+ this.rhythmMetronome = opts.metronome;
+ const synth = this.rhythmSynth;
+ const draw = Tone.getDraw();
+
+ const notePart = new Tone.Part<{ time: number; index: number }>((time, value) => {
+ const event = events[value.index];
+ if (!event || event.midi.length === 0) return;
+ const notes = event.midi.map(midiToName);
+ const duration = Math.max(0.01, event.durationSec);
+ const spread = event.kind === 'dead' ? 0.002 : RHYTHM_STRUM_SEC;
+ notes.forEach((note, i) => {
+ synth.triggerAttackRelease(note, duration, time + i * spread, event.velocity);
+ });
+ if (opts.onEvent) draw.schedule(() => opts.onEvent?.(value.index), time);
+ }, events.map((event, index) => ({ time: event.timeSec, index })));
+
+ const clickPart = new Tone.Part<{ time: number; strong: boolean }>((time, value) => {
+ if (!this.rhythmMetronome || !this.clickSynth) return;
+ this.clickSynth.triggerAttackRelease(value.strong ? 'C5' : 'G4', '32n', time, value.strong ? 1 : 0.55);
+ }, opts.clickTimes.map((click) => ({ time: click.timeSec, strong: click.strong })));
+
+ this.rhythmParts = [notePart, clickPart];
+
+ if (opts.onStep && opts.stepSeconds !== undefined && opts.stepSeconds > 0) {
+ const steps = Math.round(opts.loopSeconds / opts.stepSeconds);
+ const onStep = opts.onStep;
+ const stepPart = new Tone.Part<{ time: number; step: number }>((time, value) => {
+ draw.schedule(() => onStep(value.step), time);
+ }, Array.from({ length: steps }, (_, step) => ({ time: step * (opts.stepSeconds as number), step })));
+ this.rhythmParts.push(stepPart);
+ }
+
+ for (const part of this.rhythmParts) {
+ part.loop = true;
+ part.loopStart = 0;
+ part.loopEnd = opts.loopSeconds;
+ part.start(0, offset);
+ }
+ transport.start();
+ }
+
+ /** Stops the rhythm loop and notifies onRhythmStopped listeners (when one was playing). */
+ public stopRhythm() {
+ const wasPlaying = this.rhythmParts.length > 0;
+ this.teardownRhythm();
+ if (!wasPlaying) return;
+ this.rhythmStopListeners.forEach((listener) => listener());
+ }
+
+ public isRhythmPlaying() {
+ return this.rhythmParts.length > 0;
+ }
+
+ /** Metronome on/off for a running rhythm loop without restarting it. */
+ public setRhythmMetronome(enabled: boolean) {
+ this.rhythmMetronome = enabled;
+ }
+
+ /** Registers a listener for rhythm stops (e.g. the RiffBar took over the Transport). Returns an unsubscribe. */
+ public onRhythmStopped(listener: () => void): () => void {
+ this.rhythmStopListeners.add(listener);
+ return () => {
+ this.rhythmStopListeners.delete(listener);
+ };
+ }
+
+ private teardownRhythm() {
+ if (this.rhythmParts.length === 0) return;
+ for (const part of this.rhythmParts) {
+ part.stop();
+ part.dispose();
+ }
+ this.rhythmParts = [];
+ this.rhythmLoopSeconds = 0;
+ this.rhythmOffsetSeconds = 0;
+ const transport = Tone.getTransport();
+ transport.stop();
+ transport.cancel();
+ Tone.getDraw().cancel();
+ this.rhythmSynth?.releaseAll();
+ }
}
export const audioEngine = new AudioEngine();
diff --git a/stores/handProfileStore.test.ts b/stores/handProfileStore.test.ts
new file mode 100644
index 0000000..c8729e1
--- /dev/null
+++ b/stores/handProfileStore.test.ts
@@ -0,0 +1,202 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { HAND_PROFILE_STORAGE_KEY, parseCalibration } from './handProfileStore';
+import type { ImportProfileResult } from './handProfileStore';
+import {
+ DEFAULT_CALIBRATION,
+ DEFAULT_HAND_PROFILE,
+ STRETCH_TOLERANCE_ALLOW,
+ calibrationFromProfile,
+ profileFromCalibration
+} from '../engine/handProfile';
+import { spanMm } from '../engine/geometry';
+
+// Minimal in-memory localStorage for the node test environment
+const createStorageMock = () => {
+ let data: Record = {};
+ return {
+ getItem: (key: string) => (key in data ? data[key] : null),
+ setItem: (key: string, value: string) => { data[key] = String(value); },
+ removeItem: (key: string) => { delete data[key]; },
+ clear: () => { data = {}; },
+ key: (i: number) => Object.keys(data)[i] ?? null,
+ get length() { return Object.keys(data).length; }
+ } as Storage;
+};
+
+const freshStore = async () => {
+ vi.resetModules();
+ const mod = await import('./handProfileStore');
+ return mod.useHandProfileStore;
+};
+
+const stored = () => JSON.parse(localStorage.getItem(HAND_PROFILE_STORAGE_KEY) ?? 'null');
+
+beforeEach(() => {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('localStorage', createStorageMock());
+});
+
+describe('handProfileStore', () => {
+ it('starts from the defaults when nothing is stored', async () => {
+ const store = await freshStore();
+ const { profile, calibration } = store.getState();
+ expect(profile).toEqual(DEFAULT_HAND_PROFILE);
+ expect(calibration).toEqual(DEFAULT_CALIBRATION);
+ // The default answers reproduce the default profile exactly
+ expect(profileFromCalibration(calibration)).toEqual(DEFAULT_HAND_PROFILE);
+ });
+
+ it('falls back to the defaults without localStorage', async () => {
+ vi.stubGlobal('localStorage', undefined);
+ const store = await freshStore();
+ expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE);
+ // Writes must not throw either
+ expect(() => store.getState().setCalibration({ lowPinkyFret: 3 })).not.toThrow();
+ });
+
+ it('falls back to the defaults when storage access throws', async () => {
+ vi.stubGlobal('localStorage', {
+ getItem: () => { throw new Error('SecurityError'); },
+ setItem: () => { throw new Error('QuotaExceededError'); }
+ });
+ const store = await freshStore();
+ expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE);
+ expect(() => store.getState().setCalibration({ maxFret: 12 })).not.toThrow();
+ expect(store.getState().profile.maxFret).toBe(12);
+ });
+
+ it('persists calibration changes and reloads them', async () => {
+ const store = await freshStore();
+ store.getState().setCalibration({ lowPinkyFret: 5, allowStretches: true, maxFret: 12 });
+
+ const saved = stored();
+ expect(saved.version).toBe(1);
+ expect(saved.calibration.lowPinkyFret).toBe(5);
+ expect(saved.profile.maxFret).toBe(12);
+
+ const reloaded = await freshStore();
+ expect(reloaded.getState().calibration).toEqual(store.getState().calibration);
+ expect(reloaded.getState().profile).toEqual(store.getState().profile);
+ });
+
+ it('setCalibration rebuilds the profile from the merged answers', async () => {
+ const store = await freshStore();
+ store.getState().setCalibration({ lowPinkyFret: 3 });
+ const { profile, calibration } = store.getState();
+
+ expect(calibration).toEqual({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 });
+ expect(profile.reachAtLowMm).toBeCloseTo(spanMm(1, 3, DEFAULT_CALIBRATION.scaleLengthMm), 6);
+ expect(profile.reachAtLowMm).toBeLessThan(DEFAULT_HAND_PROFILE.reachAtLowMm);
+ // Untouched answers survive the patch
+ expect(profile.reachAtHighMm).toBeCloseTo(DEFAULT_HAND_PROFILE.reachAtHighMm, 6);
+
+ store.getState().setCalibration({ allowStretches: true, allowOpenStrings: false, noBarre: false });
+ expect(store.getState().profile.stretchTolerance).toBe(STRETCH_TOLERANCE_ALLOW);
+ expect(store.getState().profile.allowOpenStrings).toBe(false);
+ expect(store.getState().profile.noBarre).toBe(false);
+ expect(store.getState().calibration.lowPinkyFret).toBe(3);
+ });
+
+ it('resetProfile restores and persists the defaults', async () => {
+ const store = await freshStore();
+ store.getState().setCalibration({ lowPinkyFret: 7, highPinkyFret: 13, comfortableSixteenthBpm: 200 });
+ store.getState().resetProfile();
+ expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE);
+ expect(store.getState().calibration).toEqual(DEFAULT_CALIBRATION);
+ expect(stored().calibration).toEqual(DEFAULT_CALIBRATION);
+ });
+
+ it.each([
+ ['invalid JSON', '{not json'],
+ ['a number', '42'],
+ ['null', 'null'],
+ ['an array', '[1,2,3]'],
+ ['a wrapper without a profile', JSON.stringify({ version: 1, calibration: DEFAULT_CALIBRATION })],
+ ['a string profile', JSON.stringify({ version: 1, profile: 'wide' })]
+ ])('falls back to the defaults for corrupt storage: %s', async (_label, raw) => {
+ localStorage.setItem(HAND_PROFILE_STORAGE_KEY, raw);
+ const store = await freshStore();
+ expect(store.getState().profile).toEqual(DEFAULT_HAND_PROFILE);
+ expect(store.getState().calibration).toEqual(DEFAULT_CALIBRATION);
+ });
+
+ it('sanitizes a stored profile and rebuilds an invalid calibration from it', async () => {
+ const wide = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 6, highPinkyFret: 12 });
+ localStorage.setItem(
+ HAND_PROFILE_STORAGE_KEY,
+ JSON.stringify({
+ version: 1,
+ calibration: { ...DEFAULT_CALIBRATION, lowPinkyFret: 'six' },
+ profile: { ...wide, maxFret: 999, noBarre: 'yes' }
+ })
+ );
+ const store = await freshStore();
+ const { profile, calibration } = store.getState();
+ expect(profile.maxFret).toBe(24);
+ expect(profile.noBarre).toBe(true);
+ expect(profile.reachAtLowMm).toBeCloseTo(wide.reachAtLowMm, 6);
+ expect(calibration).toEqual(calibrationFromProfile(profile));
+ expect(calibration.lowPinkyFret).toBe(6);
+ expect(calibration.highPinkyFret).toBe(12);
+ });
+
+ it('exports a wrapper and imports it back (round trip)', async () => {
+ const store = await freshStore();
+ store.getState().setCalibration({ lowPinkyFret: 6, highPinkyFret: 12, scaleLengthMm: 27 * 25.4, allowStretches: true });
+ const exportedProfile = store.getState().profile;
+ const json = store.getState().exportProfileJson();
+
+ const parsed = JSON.parse(json);
+ expect(parsed.app).toBe('riffforge');
+ expect(parsed.version).toBe(1);
+ expect(parsed.profile).toEqual(exportedProfile);
+ expect(json).toContain('\n '); // pretty printed
+
+ store.getState().resetProfile();
+ expect(store.getState().importProfileJson(json)).toEqual({ ok: true });
+ expect(store.getState().profile).toEqual(exportedProfile);
+ expect(store.getState().calibration).toEqual(calibrationFromProfile(exportedProfile));
+ expect(stored().profile).toEqual(exportedProfile);
+ });
+
+ it('imports a bare profile object', async () => {
+ const store = await freshStore();
+ const narrow = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 });
+ expect(store.getState().importProfileJson(JSON.stringify(narrow))).toEqual({ ok: true });
+ expect(store.getState().profile).toEqual(narrow);
+ expect(store.getState().calibration.lowPinkyFret).toBe(3);
+ });
+
+ it.each([
+ ['empty text', ''],
+ ['invalid JSON', '{"profile": '],
+ ['a number', '7'],
+ ['null', 'null'],
+ ['an array', '[]'],
+ ['an unrelated object', JSON.stringify({ hello: 'world' })],
+ ['a wrapper with garbage', JSON.stringify({ app: 'riffforge', version: 1, profile: { reachAtLowMm: 'far' } })]
+ ])('import rejects %s without throwing or changing the profile', async (_label, text) => {
+ const store = await freshStore();
+ store.getState().setCalibration({ lowPinkyFret: 5 });
+ const before = store.getState().profile;
+
+ const run = (): ImportProfileResult => store.getState().importProfileJson(text);
+ expect(run).not.toThrow();
+ const result = run();
+ expect(result.ok).toBe(false);
+ if (result.ok === false) expect(result.error.length).toBeGreaterThan(0);
+ expect(store.getState().profile).toBe(before);
+ });
+});
+
+describe('parseCalibration', () => {
+ it('accepts complete answers and rejects missing or out-of-range ones', () => {
+ expect(parseCalibration(DEFAULT_CALIBRATION)).toEqual(DEFAULT_CALIBRATION);
+ expect(parseCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 9 })).toBeNull();
+ expect(parseCalibration({ ...DEFAULT_CALIBRATION, highPinkyFret: 10.5 })).toBeNull();
+ expect(parseCalibration({ ...DEFAULT_CALIBRATION, scaleLengthMm: 100 })).toBeNull();
+ const { noBarre: _omit, ...missing } = DEFAULT_CALIBRATION;
+ expect(parseCalibration(missing)).toBeNull();
+ expect(parseCalibration(null)).toBeNull();
+ });
+});
diff --git a/stores/handProfileStore.ts b/stores/handProfileStore.ts
new file mode 100644
index 0000000..efe97ef
--- /dev/null
+++ b/stores/handProfileStore.ts
@@ -0,0 +1,128 @@
+import { create } from 'zustand';
+import type { CalibrationAnswers, HandProfile } from '../engine/types';
+import {
+ CALIBRATION_HIGH_PINKY_RANGE,
+ CALIBRATION_LOW_PINKY_RANGE,
+ DEFAULT_CALIBRATION,
+ PROFILE_LIMITS,
+ calibrationFromProfile,
+ createDefaultHandProfile,
+ migrateHandProfile,
+ profileFromCalibration,
+ validateHandProfile
+} from '../engine/handProfile';
+
+export const HAND_PROFILE_STORAGE_KEY = 'riffforge:hand-profile:v1';
+export const HAND_PROFILE_EXPORT_APP = 'riffforge';
+
+export type ImportProfileResult = { ok: true } | { ok: false; error: string };
+
+interface HandProfileState {
+ profile: HandProfile;
+ calibration: CalibrationAnswers;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const isNumberIn = (value: unknown, [lo, hi]: readonly [number, number], integer = false): value is number =>
+ typeof value === 'number' && Number.isFinite(value) && value >= lo && value <= hi && (!integer || Number.isInteger(value));
+
+/** Stored calibration answers, or null when any field is missing or out of range. */
+export const parseCalibration = (value: unknown): CalibrationAnswers | null => {
+ if (!isRecord(value)) return null;
+ const { scaleLengthMm, lowPinkyFret, highPinkyFret, allowStretches, noBarre, allowOpenStrings, maxFret, comfortableSixteenthBpm } = value;
+ if (
+ !isNumberIn(scaleLengthMm, PROFILE_LIMITS.scaleLengthMm) ||
+ !isNumberIn(lowPinkyFret, CALIBRATION_LOW_PINKY_RANGE, true) ||
+ !isNumberIn(highPinkyFret, CALIBRATION_HIGH_PINKY_RANGE, true) ||
+ typeof allowStretches !== 'boolean' ||
+ typeof noBarre !== 'boolean' ||
+ typeof allowOpenStrings !== 'boolean' ||
+ !isNumberIn(maxFret, PROFILE_LIMITS.maxFret, true) ||
+ !isNumberIn(comfortableSixteenthBpm, PROFILE_LIMITS.comfortableSixteenthBpm, true)
+ ) {
+ return null;
+ }
+ return { scaleLengthMm, lowPinkyFret, highPinkyFret, allowStretches, noBarre, allowOpenStrings, maxFret, comfortableSixteenthBpm };
+};
+
+const defaults = (): HandProfileState => ({ profile: createDefaultHandProfile(), calibration: { ...DEFAULT_CALIBRATION } });
+
+/** Reads `{ version, calibration, profile }` from storage; anything unreadable falls back to the defaults. */
+const loadState = (): HandProfileState => {
+ try {
+ if (typeof localStorage === 'undefined') return defaults();
+ const raw = localStorage.getItem(HAND_PROFILE_STORAGE_KEY);
+ if (!raw) return defaults();
+ const parsed: unknown = JSON.parse(raw);
+ if (!isRecord(parsed) || !isRecord(parsed.profile)) return defaults();
+ const profile = migrateHandProfile(parsed.profile);
+ return { profile, calibration: parseCalibration(parsed.calibration) ?? calibrationFromProfile(profile) };
+ } catch {
+ return defaults();
+ }
+};
+
+const saveState = ({ profile, calibration }: HandProfileState): void => {
+ try {
+ if (typeof localStorage === 'undefined') return;
+ localStorage.setItem(HAND_PROFILE_STORAGE_KEY, JSON.stringify({ version: 1, calibration, profile }));
+ } catch {
+ // Storage unavailable (private mode, quota): the profile stays in memory only
+ }
+};
+
+/** The core fields a hand profile must carry before the rest is sanitized; rejects unrelated JSON. */
+const looksLikeProfile = (value: unknown): value is Record =>
+ isRecord(value) &&
+ typeof value.scaleLengthMm === 'number' && Number.isFinite(value.scaleLengthMm) &&
+ typeof value.reachAtLowMm === 'number' && Number.isFinite(value.reachAtLowMm) &&
+ typeof value.reachAtHighMm === 'number' && Number.isFinite(value.reachAtHighMm);
+
+interface HandProfileStore extends HandProfileState {
+ setCalibration: (patch: Partial) => void;
+ resetProfile: () => void;
+ exportProfileJson: () => string;
+ importProfileJson: (text: string) => ImportProfileResult;
+}
+
+const initial = loadState();
+
+export const useHandProfileStore = create((set, get) => ({
+ profile: initial.profile,
+ calibration: initial.calibration,
+
+ setCalibration: (patch) => {
+ const calibration = { ...get().calibration, ...patch };
+ const next = { calibration, profile: profileFromCalibration(calibration) };
+ saveState(next);
+ set(next);
+ },
+
+ resetProfile: () => {
+ const next = defaults();
+ saveState(next);
+ set(next);
+ },
+
+ exportProfileJson: () => JSON.stringify({ app: HAND_PROFILE_EXPORT_APP, version: 1, profile: get().profile }, null, 2),
+
+ importProfileJson: (text) => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return { ok: false, error: 'Not valid JSON.' };
+ }
+ const candidate = isRecord(parsed) && isRecord(parsed.profile) ? parsed.profile : parsed;
+ if (!looksLikeProfile(candidate)) {
+ return { ok: false, error: 'This JSON is not a hand profile (scale length and reach values are missing).' };
+ }
+ const profile = validateHandProfile(candidate);
+ const next = { profile, calibration: calibrationFromProfile(profile) };
+ saveState(next);
+ set(next);
+ return { ok: true };
+ }
+}));
diff --git a/stores/rhythmStore.test.ts b/stores/rhythmStore.test.ts
new file mode 100644
index 0000000..4935a69
--- /dev/null
+++ b/stores/rhythmStore.test.ts
@@ -0,0 +1,435 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { RHYTHM_STORAGE_KEY, regenerateKeepingLocks } from './rhythmStore';
+import { RIFF_STORAGE_KEY } from './riffStore';
+import { generateRhythm } from '../engine/rhythm/generate';
+import { defaultRhythmParams } from '../engine/rhythm/styles';
+import { validateRhythm } from '../engine/rhythm/validate';
+import { barTicks } from '../engine/rhythm/grid';
+import { DROP_D, E_STANDARD } from '../engine/tuning';
+import { DEFAULT_HAND_PROFILE } from '../engine/handProfile';
+import { shapeToMidi } from '../engine/shape';
+import type { RhythmPattern } from '../engine/types';
+import type { SlotInput } from './rhythmStore';
+
+// Minimal in-memory localStorage for the node test environment
+const createStorageMock = () => {
+ let data: Record = {};
+ return {
+ getItem: (key: string) => (key in data ? data[key] : null),
+ setItem: (key: string, value: string) => { data[key] = String(value); },
+ removeItem: (key: string) => { delete data[key]; },
+ clear: () => { data = {}; },
+ key: (i: number) => Object.keys(data)[i] ?? null,
+ get length() { return Object.keys(data).length; }
+ } as Storage;
+};
+
+const freshStores = async () => {
+ vi.resetModules();
+ const rhythm = await import('./rhythmStore');
+ const riff = await import('./riffStore');
+ return { useRhythmStore: rhythm.useRhythmStore, useRiffStore: riff.useRiffStore };
+};
+
+const freshStore = async () => (await freshStores()).useRhythmStore;
+
+const slotInput = (root: string, shape: (number | null)[], label = `${root}5`): SlotInput => ({
+ label,
+ root,
+ source: { kind: 'family', familyId: 'power5' },
+ shape,
+ tuningId: E_STANDARD.id
+});
+
+const E5 = slotInput('E', [0, 2, 2, null, null, null]);
+const G5 = slotInput('G', [3, 5, 5, null, null, null]);
+const A5 = slotInput('A', [5, 7, 7, null, null, null]);
+const C5 = slotInput('C', [null, 3, 5, 5, null, null]);
+const D5 = slotInput('D', [null, 5, 7, 7, null, null]);
+
+const eventsInBar = (pattern: RhythmPattern, bar: number) => {
+ const length = barTicks(pattern.meter);
+ return pattern.events.filter((e) => Math.floor(e.tick / length) === bar);
+};
+
+const slotsUsed = (pattern: RhythmPattern) =>
+ new Set(pattern.events.flatMap((e) => (e.target.kind === 'slot' || e.target.kind === 'dyad' ? [e.target.slot] : [])));
+
+beforeEach(() => {
+ vi.stubGlobal('localStorage', createStorageMock());
+});
+
+describe('rhythmStore defaults', () => {
+ it('starts on the default style, seed and pattern with no slots', async () => {
+ const store = await freshStore();
+ const s = store.getState();
+ expect(s.params).toEqual({ ...defaultRhythmParams('chugEngine'), slotCount: 1 });
+ expect(s.seed).toBe('riffforge');
+ expect(s.pattern).toEqual(generateRhythm(s.params, 'riffforge'));
+ expect(s.slots).toEqual([]);
+ expect(s.lockedBars).toEqual([]);
+ expect(s.metronomeOn).toBe(false);
+ expect(s.isPlaying).toBe(false);
+ expect(s.playheadUnit).toBe(-1);
+ expect(s.optimization).toBeNull();
+ expect(s.bpm).toBe(120);
+ });
+
+ it("takes the RiffBar's bpm at first load", async () => {
+ localStorage.setItem(RIFF_STORAGE_KEY, JSON.stringify({ steps: [], bpm: 165 }));
+ const store = await freshStore();
+ expect(store.getState().bpm).toBe(165);
+ });
+
+ it('is deterministic for a seed', async () => {
+ const a = (await freshStore()).getState().pattern;
+ localStorage.clear();
+ const b = (await freshStore()).getState().pattern;
+ expect(b).toEqual(a);
+ const params = { ...defaultRhythmParams('gallop'), slotCount: 2 };
+ expect(regenerateKeepingLocks(null, params, 'seed-x', []).pattern).toEqual(generateRhythm(params, 'seed-x'));
+ });
+});
+
+describe('rhythmStore persistence', () => {
+ it('round-trips params, seed, pattern, slots, bpm, locks and metronome', async () => {
+ const store = await freshStore();
+ const s = store.getState();
+ s.setStyle('gallop');
+ s.setParam({ bars: 4, density: 0.55 });
+ s.addSlot(E5);
+ s.addSlot(G5);
+ s.toggleLockBar(1);
+ s.setBpm(157);
+ s.setMetronome(true);
+ s.toggleHitAt(1, { kind: 'dead' });
+ s.setIsPlaying(true);
+ const before = store.getState();
+
+ const reloaded = (await freshStore()).getState();
+ expect(reloaded.params).toEqual(before.params);
+ expect(reloaded.seed).toBe(before.seed);
+ expect(reloaded.pattern).toEqual(before.pattern);
+ expect(reloaded.slots).toEqual(before.slots);
+ expect(reloaded.bpm).toBe(157);
+ expect(reloaded.lockedBars).toEqual([1]);
+ expect(reloaded.metronomeOn).toBe(true);
+ expect(reloaded.isPlaying).toBe(false);
+ expect(reloaded.optimization).toBeNull();
+ });
+
+ it('clamps bpm to 60..240', async () => {
+ const store = await freshStore();
+ store.getState().setBpm(500);
+ expect(store.getState().bpm).toBe(240);
+ store.getState().setBpm(10);
+ expect(store.getState().bpm).toBe(60);
+ });
+});
+
+describe('rhythmStore corrupt storage', () => {
+ it('falls back to defaults on invalid JSON', async () => {
+ localStorage.setItem(RHYTHM_STORAGE_KEY, '{not json');
+ const s = (await freshStore()).getState();
+ expect(s.params.style).toBe('chugEngine');
+ expect(s.pattern).toEqual(generateRhythm(s.params, 'riffforge'));
+ });
+
+ it('falls back to defaults on an unknown style', async () => {
+ localStorage.setItem(RHYTHM_STORAGE_KEY, JSON.stringify({ params: { style: 'polka' }, seed: 'abc', bpm: 130 }));
+ const s = (await freshStore()).getState();
+ expect(s.params.style).toBe('chugEngine');
+ expect(s.bpm).toBe(130);
+ expect(s.pattern).toEqual(generateRhythm(s.params, 'abc'));
+ });
+
+ it('regenerates an invalid pattern from params and seed and drops invalid slots', async () => {
+ const params = { ...defaultRhythmParams('sevenEight'), slotCount: 1 };
+ const broken = generateRhythm(params, 'p');
+ broken.events = [...broken.events].reverse();
+ localStorage.setItem(
+ RHYTHM_STORAGE_KEY,
+ JSON.stringify({
+ params,
+ seed: 'p',
+ pattern: broken,
+ slots: [{ id: 'bad', label: 'x', root: 'E', source: { kind: 'family', familyId: 'power5' }, shape: [0, 2], tuningId: 'e-standard', pedalMidi: null }, 42],
+ bpm: 'fast',
+ lockedBars: [0, 7, -1, 'x'],
+ metronomeOn: 'yes'
+ })
+ );
+ const s = (await freshStore()).getState();
+ expect(s.slots).toEqual([]);
+ expect(s.pattern).toEqual(generateRhythm(params, 'p'));
+ expect(validateRhythm(s.pattern).ok).toBe(true);
+ expect(s.bpm).toBe(120);
+ expect(s.metronomeOn).toBe(false);
+ expect(s.lockedBars.every((b) => b >= 0 && b < s.pattern.bars)).toBe(true);
+ });
+
+ it('keeps a stored pattern that still validates, even after hand edits', async () => {
+ const store = await freshStore();
+ store.getState().toggleHitAt(2, { kind: 'dead' });
+ store.getState().cycleAccentAt(2);
+ const edited = store.getState().pattern;
+ expect(edited).not.toEqual(generateRhythm(store.getState().params, store.getState().seed));
+ expect((await freshStore()).getState().pattern).toEqual(edited);
+ });
+});
+
+describe('rhythmStore locks', () => {
+ it('keeps a locked bar across newIdea', async () => {
+ const store = await freshStore();
+ store.getState().setStyle('gallop');
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ store.getState().toggleLockBar(0);
+ for (let i = 0; i < 5; i++) {
+ const before = store.getState();
+ before.newIdea();
+ const after = store.getState();
+ expect(after.seed).not.toBe(before.seed);
+ expect(after.lockedBars).toEqual([0]);
+ expect(eventsInBar(after.pattern, 0)).toEqual(eventsInBar(before.pattern, 0));
+ expect(validateRhythm(after.pattern).ok).toBe(true);
+ }
+ });
+
+ it('keeps locked bars across density changes but drops them on a grid change', async () => {
+ const store = await freshStore();
+ store.getState().toggleLockBar(1);
+ const bar1 = eventsInBar(store.getState().pattern, 1);
+ store.getState().setParam({ density: 0.2 });
+ expect(eventsInBar(store.getState().pattern, 1)).toEqual(bar1);
+ expect(store.getState().lockedBars).toEqual([1]);
+ store.getState().setParam({ grid: '16th-triplet' });
+ expect(store.getState().lockedBars).toEqual([]);
+ expect(store.getState().pattern.params.grid).toBe('16th-triplet');
+ });
+
+ it('mutates without touching a locked bar', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ store.getState().toggleLockBar(0);
+ const bar0 = eventsInBar(store.getState().pattern, 0);
+ let changed = 0;
+ for (let i = 0; i < 6; i++) {
+ const before = store.getState().pattern;
+ if (store.getState().mutate()) {
+ changed++;
+ expect(store.getState().pattern.events).not.toEqual(before.events);
+ }
+ expect(eventsInBar(store.getState().pattern, 0)).toEqual(bar0);
+ expect(validateRhythm(store.getState().pattern).ok).toBe(true);
+ }
+ expect(changed).toBeGreaterThan(0);
+ });
+
+ it('ignores locks outside the pattern', async () => {
+ const store = await freshStore();
+ store.getState().toggleLockBar(9);
+ store.getState().toggleLockBar(-1);
+ expect(store.getState().lockedBars).toEqual([]);
+ });
+});
+
+describe('rhythmStore slots', () => {
+ it('keeps params.slotCount in sync on add and remove', async () => {
+ const store = await freshStore();
+ const s = store.getState();
+ expect(s.addSlot(E5)).toBe(true);
+ expect(store.getState().params.slotCount).toBe(1);
+ expect(s.addSlot(G5)).toBe(true);
+ expect(store.getState().params.slotCount).toBe(2);
+ expect(store.getState().pattern.params.slotCount).toBe(2);
+ expect(slotsUsed(store.getState().pattern)).toEqual(new Set([0, 1]));
+ expect(s.addSlot(A5)).toBe(true);
+ expect(s.addSlot(C5)).toBe(true);
+ expect(s.addSlot(D5)).toBe(false);
+ expect(store.getState().slots).toHaveLength(4);
+ expect(store.getState().pattern.params.slotCount).toBe(4);
+
+ store.getState().removeSlot(store.getState().slots[1].id);
+ expect(store.getState().slots.map((x) => x.label)).toEqual(['E5', 'A5', 'C5']);
+ expect(store.getState().params.slotCount).toBe(3);
+ expect(Math.max(...slotsUsed(store.getState().pattern))).toBeLessThan(3);
+ expect(validateRhythm(store.getState().pattern).ok).toBe(true);
+
+ for (const slot of store.getState().slots) store.getState().removeSlot(slot.id);
+ expect(store.getState().slots).toEqual([]);
+ expect(store.getState().params.slotCount).toBe(1);
+ });
+
+ it('keeps the rhythm figure when the slot count changes (same seed)', async () => {
+ const store = await freshStore();
+ const ticks = store.getState().pattern.events.map((e) => [e.tick, e.durationTicks, e.accent, e.palmMute]);
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ expect(store.getState().pattern.events.map((e) => [e.tick, e.durationTicks, e.accent, e.palmMute])).toEqual(ticks);
+ });
+
+ it('keeps a hand edit in an unlocked bar when a slot is added', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ const before = store.getState().pattern.events;
+ store.getState().toggleHitAt(3, { kind: 'dead' });
+ const edited = store.getState().pattern;
+ expect(edited.events).not.toEqual(before);
+ expect(edited.events.some((e) => e.target.kind === 'dead')).toBe(true);
+ expect(store.getState().lockedBars).toEqual([]);
+
+ expect(store.getState().addSlot(G5)).toBe(true);
+ const after = store.getState().pattern;
+ expect(store.getState().params.slotCount).toBe(2);
+ expect(after.params.slotCount).toBe(2);
+ expect(after.events).toEqual(edited.events);
+ expect(validateRhythm(after).ok).toBe(true);
+ });
+
+ it('keeps a hand edit and folds slots into range when a slot is removed', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ store.getState().addSlot(A5);
+ store.getState().toggleHitAt(3, { kind: 'dead' });
+ const deadTicks = store.getState().pattern.events.filter((e) => e.target.kind === 'dead').map((e) => e.tick);
+ expect(deadTicks.length).toBeGreaterThan(0);
+
+ store.getState().removeSlot(store.getState().slots[2].id);
+ const after = store.getState().pattern;
+ expect(after.params.slotCount).toBe(2);
+ expect(after.events.filter((e) => e.target.kind === 'dead').map((e) => e.tick)).toEqual(deadTicks);
+ expect(Math.max(...slotsUsed(after))).toBeLessThan(2);
+ expect(validateRhythm(after).ok).toBe(true);
+ });
+
+ it('keeps a mutated pattern when the slot count changes', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ expect(store.getState().mutate()).toBe(true);
+ const mutated = store.getState().pattern.events;
+ store.getState().addSlot(A5);
+ expect(store.getState().pattern.events).toEqual(mutated);
+ });
+
+ it('rejects slots that do not fit their tuning', async () => {
+ const store = await freshStore();
+ expect(store.getState().addSlot({ ...E5, shape: [0, 2, 2] })).toBe(false);
+ expect(store.getState().addSlot({ ...E5, tuningId: 'seven-string' })).toBe(false);
+ expect(store.getState().slots).toEqual([]);
+ });
+
+ it('fills the default pedal and updates it with the shape', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(C5);
+ const slot = store.getState().slots[0];
+ expect(slot.pedalMidi).toBe(48);
+ expect(store.getState().setSlotShape(slot.id, [8, 10, 10, null, null, null])).toBe(true);
+ expect(store.getState().slots[0].pedalMidi).toBe(48);
+ expect(store.getState().setSlotShape(slot.id, [8, 10])).toBe(false);
+ });
+
+ it('bridges distinct RiffBar steps into slots', async () => {
+ const { useRhythmStore, useRiffStore } = await freshStores();
+ expect(useRhythmStore.getState().useRiffBarChords(E_STANDARD)).toBe(0);
+ useRiffStore.setState({
+ steps: [
+ { key: 'a', baseId: 'x', name: 'E5', subtext: 'E5', notes: ['E2', 'B2', 'E3'] },
+ { key: 'b', baseId: 'x', name: 'E5', subtext: 'E5', notes: ['E2', 'B2', 'E3'] },
+ { key: 'c', baseId: 'y', name: 'C5', subtext: 'C5', notes: ['C3', 'G3', 'C4'] },
+ { key: 'd', baseId: 'x', name: 'E5', subtext: 'E5', notes: ['E2', 'B2', 'E3'] }
+ ]
+ });
+ expect(useRhythmStore.getState().useRiffBarChords(E_STANDARD, DEFAULT_HAND_PROFILE)).toBe(2);
+ const { slots, params, pattern } = useRhythmStore.getState();
+ expect(slots.map((s) => s.label)).toEqual(['E5', 'C5']);
+ expect(slots[0].source).toEqual({ kind: 'notes', notes: ['E2', 'B2', 'E3'], baseRoot: 'E' });
+ expect(shapeToMidi(slots[0].shape, E_STANDARD)).toEqual([40, 47, 52]);
+ expect([...shapeToMidi(slots[1].shape, E_STANDARD)].sort((a, b) => a - b)).toEqual([48, 55, 60]);
+ expect(params.slotCount).toBe(2);
+ expect(pattern.params.slotCount).toBe(2);
+ });
+
+ it('retunes slots to the app tuning', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ expect(store.getState().retuneSlots(DROP_D, DEFAULT_HAND_PROFILE)).toBe(0);
+ const slot = store.getState().slots[0];
+ expect(slot.tuningId).toBe(DROP_D.id);
+ expect([...shapeToMidi(slot.shape, DROP_D)].sort((a, b) => a - b)).toEqual([40, 47, 52]);
+ });
+
+ it('optimizes slot fingerings and reports the hardest change', async () => {
+ const store = await freshStore();
+ expect(store.getState().optimizeSlots(DEFAULT_HAND_PROFILE, E_STANDARD).ok).toBe(false);
+ store.getState().addSlot(E5);
+ store.getState().addSlot(A5);
+ const out = store.getState().optimizeSlots(DEFAULT_HAND_PROFILE, E_STANDARD);
+ expect(out.ok).toBe(true);
+ const { optimization, slots } = store.getState();
+ expect(optimization).not.toBeNull();
+ expect(optimization!.fingers).toHaveLength(2);
+ expect(optimization!.text).toMatch(/^Hardest change: slot/);
+ expect(slots).toHaveLength(2);
+ store.getState().setSlotShape(slots[0].id, [0, 2, 2, null, null, null]);
+ expect(store.getState().optimization).toBeNull();
+ });
+});
+
+describe('rhythmStore style and params', () => {
+ it('setStyle uses the preset with the slot count of the lab', async () => {
+ const store = await freshStore();
+ store.getState().addSlot(E5);
+ store.getState().addSlot(G5);
+ store.getState().setStyle('fiveOverFour');
+ const { params, pattern } = store.getState();
+ expect(params).toEqual({ ...defaultRhythmParams('fiveOverFour'), slotCount: 2 });
+ expect(pattern.cycleTicks).toBe(5 * 120);
+ expect(validateRhythm(pattern).ok).toBe(true);
+ store.getState().setStyle('sevenEight');
+ expect(store.getState().pattern.meter).toEqual({ numerator: 7, denominator: 8 });
+ });
+
+ it('setParam only accepts the exposed knobs and bar options', async () => {
+ const store = await freshStore();
+ store.getState().setParam({ bars: 4, picking: 'downstrokes', syncopation: 2 });
+ expect(store.getState().params).toMatchObject({ bars: 4, picking: 'downstrokes', syncopation: 1 });
+ expect(store.getState().pattern.bars).toBe(4);
+ expect(store.getState().pattern.events.every((e) => e.pick === 'down')).toBe(true);
+ store.getState().setParam({ bars: 3 });
+ expect(store.getState().params.bars).toBe(4);
+ });
+});
+
+describe('scheduleRhythmRestart', () => {
+ it('restarts while playing, but never after Stop or cancel', async () => {
+ vi.useFakeTimers();
+ try {
+ vi.resetModules();
+ const { scheduleRhythmRestart, useRhythmStore } = await import('./rhythmStore');
+ const restart = vi.fn();
+
+ useRhythmStore.getState().setIsPlaying(true);
+ scheduleRhythmRestart(restart, 60);
+ vi.advanceTimersByTime(60);
+ expect(restart).toHaveBeenCalledTimes(1);
+
+ scheduleRhythmRestart(restart, 60);
+ vi.advanceTimersByTime(30);
+ useRhythmStore.getState().setIsPlaying(false);
+ vi.advanceTimersByTime(30);
+ expect(restart).toHaveBeenCalledTimes(1);
+
+ useRhythmStore.getState().setIsPlaying(true);
+ const cancel = scheduleRhythmRestart(restart, 60);
+ cancel();
+ vi.advanceTimersByTime(60);
+ expect(restart).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/stores/rhythmStore.ts b/stores/rhythmStore.ts
new file mode 100644
index 0000000..501d922
--- /dev/null
+++ b/stores/rhythmStore.ts
@@ -0,0 +1,482 @@
+import { create } from 'zustand';
+import type { FingerNumber, HandProfile, HitTarget, RhythmParams, RhythmPattern, RhythmStyleId, Tuning } from '../engine/types';
+import { generateRhythm, sanitizeRhythmParams } from '../engine/rhythm/generate';
+import { RHYTHM_STYLE_IDS, defaultRhythmParams } from '../engine/rhythm/styles';
+import { mutateRhythm, regenerateRhythm } from '../engine/rhythm/variation';
+import { validateRhythm } from '../engine/rhythm/validate';
+import { clearHit, cycleAccent, setSlotCount, togglePalmMute, toggleHit } from '../engine/rhythm/edit';
+import { barTicks } from '../engine/rhythm/grid';
+import { getTuning } from '../engine/tuning';
+import { isValidShape } from '../engine/shape';
+import { clampBpm, useRiffStore } from './riffStore';
+import { useChordStore } from './chordStore';
+import { useHandProfileStore } from './handProfileStore';
+import { tuningForMode } from '../utils/libraryVoicing';
+import {
+ MAX_RHYTHM_SLOTS,
+ createRhythmSlot,
+ defaultPedalMidi,
+ optimizeSlotShapes,
+ retuneSlot,
+ sanitizeRhythmSlot,
+ slotShapesKey,
+ slotsFromRiffSteps,
+ type RhythmSlot,
+ type SlotInput
+} from '../utils/rhythmSlots';
+
+export type { RhythmSlot, RhythmSlotSource, SlotInput } from '../utils/rhythmSlots';
+export { MAX_RHYTHM_SLOTS, createRhythmSlot } from '../utils/rhythmSlots';
+export { MIN_BPM, MAX_BPM } from './riffStore';
+
+export const RHYTHM_STORAGE_KEY = 'riffforge:rhythm:v1';
+export const RHYTHM_BAR_OPTIONS: readonly number[] = [1, 2, 4];
+export const DEFAULT_RHYTHM_STYLE: RhythmStyleId = 'chugEngine';
+export const DEFAULT_RHYTHM_SEED = 'riffforge';
+
+// Single access point for the hand profile used by the Rhythm Lab (the user's calibrated profile).
+export const getRhythmHandProfile = (): HandProfile => useHandProfileStore.getState().profile;
+export const useRhythmHandProfile = (): HandProfile => useHandProfileStore((s) => s.profile);
+
+/** The app's current tuning (Standard / Drop from the chord store). */
+export const getRhythmTuning = (): Tuning => tuningForMode(useChordStore.getState().tuningMode);
+
+/** Knobs the Rhythm Lab exposes besides the style preset. */
+export type RhythmParamPatch = Partial>;
+
+export interface RhythmOptimization {
+ text: string;
+ maxCost: number;
+ /** Finger per string per slot, chosen together with the neighbours. Valid while shapesKey matches. */
+ fingers: (FingerNumber | null)[][];
+ shapesKey: string;
+}
+
+export type OptimizeOutcome = { ok: true; text: string } | { ok: false; message: string };
+
+interface PersistedRhythm {
+ params: RhythmParams;
+ seed: string;
+ pattern: RhythmPattern;
+ slots: RhythmSlot[];
+ bpm: number;
+ lockedBars: number[];
+ metronomeOn: boolean;
+}
+
+export interface RhythmStore extends PersistedRhythm {
+ isPlaying: boolean;
+ /** Grid unit under the playhead, -1 when idle. */
+ playheadUnit: number;
+ optimization: RhythmOptimization | null;
+
+ setStyle: (style: RhythmStyleId) => void;
+ setParam: (patch: RhythmParamPatch) => void;
+ newIdea: () => void;
+ mutate: () => boolean;
+ toggleLockBar: (bar: number) => void;
+ toggleHitAt: (unit: number, target: HitTarget) => void;
+ cycleAccentAt: (unit: number) => void;
+ togglePalmMuteAt: (unit: number) => void;
+ clearHitAt: (unit: number) => void;
+ setBpm: (bpm: number) => void;
+ setMetronome: (on: boolean) => void;
+ addSlot: (slot: SlotInput) => boolean;
+ removeSlot: (id: string) => void;
+ setSlotShape: (id: string, shape: readonly (number | null)[]) => boolean;
+ /**
+ * RiffBar bridge: replaces the slots with the first distinct RiffBar steps (app tuning and hand profile by
+ * default); returns how many slots were made (0 leaves the slots alone).
+ */
+ useRiffBarChords: (tuning?: Tuning, profile?: HandProfile) => number;
+ /** Re-voices slots that belong to another tuning; returns how many slots had to be dropped. */
+ retuneSlots: (tuning?: Tuning, profile?: HandProfile) => number;
+ optimizeSlots: (profile: HandProfile, tuning: Tuning) => OptimizeOutcome;
+ setIsPlaying: (value: boolean) => void;
+ setPlayheadUnit: (unit: number) => void;
+}
+
+// ---------------------------------------------------------------------------
+// Pure state helpers (exported for tests)
+// ---------------------------------------------------------------------------
+
+export const slotCountFor = (slots: readonly unknown[]): number => Math.min(MAX_RHYTHM_SLOTS, Math.max(1, slots.length));
+
+/** App code may use crypto for seeds; the engine itself stays seeded and deterministic. */
+export const randomSeed = (): string => {
+ try {
+ const bytes = new Uint32Array(1);
+ globalThis.crypto.getRandomValues(bytes);
+ return bytes[0].toString(36).padStart(7, '0');
+ } catch {
+ return Math.floor(Math.random() * 0xffffffff).toString(36).padStart(7, '0');
+ }
+};
+
+const sanitizeLocks = (bars: readonly unknown[], barCount: number): number[] =>
+ [...new Set(bars.filter((b): b is number => Number.isInteger(b) && (b as number) >= 0 && (b as number) < barCount))].sort(
+ (a, b) => a - b
+ );
+
+const sameMeter = (a: RhythmPattern['meter'], b: RhythmPattern['meter']): boolean =>
+ a.numerator === b.numerator && a.denominator === b.denominator;
+
+/**
+ * A fresh pattern for params + seed that keeps the old pattern's locked bars (engine regenerateRhythm). Locks
+ * only survive while meter and grid stay the same; otherwise the old bars would not line up with the new grid.
+ * When the merge does not validate, the fresh pattern wins and the locks are dropped.
+ */
+export const regenerateKeepingLocks = (
+ old: RhythmPattern | null,
+ params: RhythmParams,
+ seed: string,
+ lockedBars: readonly number[]
+): { pattern: RhythmPattern; lockedBars: number[] } => {
+ const fresh = generateRhythm(params, seed);
+ const locks = sanitizeLocks(lockedBars, fresh.bars);
+ if (!old || locks.length === 0) return { pattern: fresh, lockedBars: locks };
+ if (!sameMeter(old.meter, fresh.meter) || old.params.grid !== fresh.params.grid) return { pattern: fresh, lockedBars: [] };
+ try {
+ const input: RhythmPattern = { ...old, bars: fresh.bars, meter: { ...fresh.meter }, params: fresh.params };
+ const merged = regenerateRhythm(input, seed, locks);
+ if (validateRhythm(merged).ok === true) return { pattern: merged, lockedBars: locks };
+ } catch {
+ // Fall through to the fresh pattern
+ }
+ return { pattern: fresh, lockedBars: [] };
+};
+
+const eventsInBars = (pattern: RhythmPattern, bars: readonly number[]): string => {
+ const bar = barTicks(pattern.meter);
+ return JSON.stringify(pattern.events.filter((e) => bars.includes(Math.floor(e.tick / bar))));
+};
+
+/**
+ * True when an unlocked bar no longer matches what params + seed generate, i.e. the player edited or mutated it.
+ * Locked bars are the player's by definition and are kept by regenerateKeepingLocks anyway.
+ */
+export const hasUnlockedChanges = (
+ pattern: RhythmPattern,
+ params: RhythmParams,
+ seed: string,
+ lockedBars: readonly number[]
+): boolean => {
+ const fresh = generateRhythm(params, seed);
+ if (fresh.bars !== pattern.bars || !sameMeter(fresh.meter, pattern.meter) || fresh.params.grid !== pattern.params.grid) return true;
+ const unlocked = Array.from({ length: pattern.bars }, (_, bar) => bar).filter((bar) => !lockedBars.includes(bar));
+ return eventsInBars(pattern, unlocked) !== eventsInBars(fresh, unlocked);
+};
+
+const MUTATE_ATTEMPTS = 8;
+
+/** mutateRhythm with seeds derived from the pattern seed; a result that touches a locked bar is retried. */
+export const mutateKeepingLocks = (pattern: RhythmPattern, seed: string, lockedBars: readonly number[], round: number): RhythmPattern | null => {
+ const locked = eventsInBars(pattern, lockedBars);
+ const before = JSON.stringify(pattern.events);
+ for (let attempt = 0; attempt < MUTATE_ATTEMPTS; attempt++) {
+ const next = mutateRhythm(pattern, `${seed}/m${round}.${attempt}`);
+ if (JSON.stringify(next.events) === before) continue;
+ if (lockedBars.length > 0 && eventsInBars(next, lockedBars) !== locked) continue;
+ return next;
+ }
+ return null;
+};
+
+const isPatternLike = (value: unknown): value is RhythmPattern => {
+ if (typeof value !== 'object' || value === null) return false;
+ const p = value as Record;
+ return (
+ typeof p.id === 'string' &&
+ typeof p.seed === 'string' &&
+ Array.isArray(p.events) &&
+ typeof p.params === 'object' &&
+ p.params !== null &&
+ typeof p.meter === 'object' &&
+ p.meter !== null &&
+ Array.isArray(p.tags)
+ );
+};
+
+const paramsFor = (style: RhythmStyleId, slots: readonly unknown[]): RhythmParams => ({
+ ...defaultRhythmParams(style),
+ slotCount: slotCountFor(slots)
+});
+
+const defaultBpm = (): number => {
+ try {
+ return clampBpm(useRiffStore.getState().bpm);
+ } catch {
+ return clampBpm(NaN);
+ }
+};
+
+export const createDefaultRhythmState = (): PersistedRhythm => {
+ const params = paramsFor(DEFAULT_RHYTHM_STYLE, []);
+ return {
+ params,
+ seed: DEFAULT_RHYTHM_SEED,
+ pattern: generateRhythm(params, DEFAULT_RHYTHM_SEED),
+ slots: [],
+ bpm: defaultBpm(),
+ lockedBars: [],
+ metronomeOn: false
+ };
+};
+
+/** An old pattern is only reused for its locked bars when it validates on its own. */
+const safePattern = (pattern: RhythmPattern): RhythmPattern | null => {
+ try {
+ return validateRhythm(pattern).ok === true ? pattern : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Parses persisted state defensively: unknown styles or malformed params fall back to the defaults, invalid
+ * slots are dropped (shapes must fit their tuning), and a pattern that does not validate against its params is
+ * regenerated from params + seed (keeping valid locks). Never throws.
+ */
+export const parseRhythmState = (raw: string | null): PersistedRhythm => {
+ const fallback = createDefaultRhythmState();
+ if (!raw) return fallback;
+ try {
+ const parsed = JSON.parse(raw) as Record;
+ if (typeof parsed !== 'object' || parsed === null) return fallback;
+
+ const slots = (Array.isArray(parsed.slots) ? parsed.slots : [])
+ .map(sanitizeRhythmSlot)
+ .filter((s): s is RhythmSlot => s !== null)
+ .slice(0, MAX_RHYTHM_SLOTS);
+ const rawParams = parsed.params as RhythmParams | undefined;
+ const knownStyle = !!rawParams && typeof rawParams === 'object' && RHYTHM_STYLE_IDS.includes(rawParams.style);
+ const params: RhythmParams = knownStyle
+ ? { ...sanitizeRhythmParams(rawParams as RhythmParams), slotCount: slotCountFor(slots) }
+ : paramsFor(DEFAULT_RHYTHM_STYLE, slots);
+ const seed = typeof parsed.seed === 'string' && parsed.seed.length > 0 ? parsed.seed : DEFAULT_RHYTHM_SEED;
+ const bars = params.bars;
+ const lockedBars = sanitizeLocks(Array.isArray(parsed.lockedBars) ? parsed.lockedBars : [], bars);
+
+ let pattern: RhythmPattern | null = null;
+ if (knownStyle && isPatternLike(parsed.pattern)) {
+ const candidate = parsed.pattern;
+ const matches =
+ candidate.params.style === params.style &&
+ candidate.bars === params.bars &&
+ candidate.params.grid === params.grid &&
+ candidate.params.slotCount === params.slotCount &&
+ sameMeter(candidate.meter, params.meter);
+ if (matches && validateRhythm(candidate).ok === true) pattern = candidate;
+ }
+ let locks = lockedBars;
+ if (pattern === null) {
+ const rebuilt = regenerateKeepingLocks(isPatternLike(parsed.pattern) ? safePattern(parsed.pattern) : null, params, seed, lockedBars);
+ pattern = rebuilt.pattern;
+ locks = rebuilt.lockedBars;
+ }
+
+ return {
+ params,
+ seed,
+ pattern,
+ slots,
+ bpm: typeof parsed.bpm === 'number' ? clampBpm(parsed.bpm) : fallback.bpm,
+ lockedBars: locks,
+ metronomeOn: parsed.metronomeOn === true
+ };
+ } catch {
+ return fallback;
+ }
+};
+
+const loadRhythm = (): PersistedRhythm => {
+ try {
+ if (typeof localStorage === 'undefined') return createDefaultRhythmState();
+ return parseRhythmState(localStorage.getItem(RHYTHM_STORAGE_KEY));
+ } catch {
+ return createDefaultRhythmState();
+ }
+};
+
+const saveRhythm = (state: PersistedRhythm): void => {
+ try {
+ if (typeof localStorage === 'undefined') return;
+ const { params, seed, pattern, slots, bpm, lockedBars, metronomeOn } = state;
+ localStorage.setItem(RHYTHM_STORAGE_KEY, JSON.stringify({ params, seed, pattern, slots, bpm, lockedBars, metronomeOn }));
+ } catch {
+ // Storage unavailable (private mode, quota): the lab keeps working in memory
+ }
+};
+
+const PERSISTED_KEYS: readonly (keyof PersistedRhythm)[] = ['params', 'seed', 'pattern', 'slots', 'bpm', 'lockedBars', 'metronomeOn'];
+
+const clamp01 = (x: unknown, fallback: number): number =>
+ typeof x === 'number' && Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
+
+// ---------------------------------------------------------------------------
+// Store
+// ---------------------------------------------------------------------------
+
+const initial = loadRhythm();
+let mutationRound = 0;
+
+export const useRhythmStore = create((set, get) => {
+ /**
+ * New slots: keeps params.slotCount in sync when the count changes. An untouched pattern is regenerated (same
+ * seed, locks kept) so every slot gets its chord changes; edited or mutated events are kept, with slot targets
+ * folded into range.
+ */
+ const withSlots = (slots: RhythmSlot[]): Partial => {
+ const state = get();
+ const count = slotCountFor(slots);
+ if (count === state.params.slotCount) return { slots, optimization: null };
+ const params = { ...state.params, slotCount: count };
+ if (hasUnlockedChanges(state.pattern, state.params, state.seed, state.lockedBars)) {
+ return { slots, params, pattern: setSlotCount(state.pattern, count), optimization: null };
+ }
+ const { pattern, lockedBars } = regenerateKeepingLocks(state.pattern, params, state.seed, state.lockedBars);
+ return { slots, params, pattern, lockedBars, optimization: null };
+ };
+
+ const regenerate = (params: RhythmParams, seed: string): Partial => {
+ const state = get();
+ const { pattern, lockedBars } = regenerateKeepingLocks(state.pattern, params, seed, state.lockedBars);
+ return { params, seed, pattern, lockedBars };
+ };
+
+ return {
+ ...initial,
+ isPlaying: false,
+ playheadUnit: -1,
+ optimization: null,
+
+ setStyle: (style) => {
+ if (!RHYTHM_STYLE_IDS.includes(style)) return;
+ set(regenerate(paramsFor(style, get().slots), get().seed));
+ },
+
+ setParam: (patch) => {
+ const current = get().params;
+ const next: RhythmParams = { ...current };
+ if (patch.density !== undefined) next.density = clamp01(patch.density, current.density);
+ if (patch.syncopation !== undefined) next.syncopation = clamp01(patch.syncopation, current.syncopation);
+ if (patch.grid === '16th' || patch.grid === '16th-triplet') next.grid = patch.grid;
+ if (patch.picking === 'alternate' || patch.picking === 'downstrokes') next.picking = patch.picking;
+ if (patch.bars !== undefined && RHYTHM_BAR_OPTIONS.includes(patch.bars)) next.bars = patch.bars;
+ set(regenerate(next, get().seed));
+ },
+
+ newIdea: () => {
+ mutationRound = 0;
+ set(regenerate(get().params, randomSeed()));
+ },
+
+ mutate: () => {
+ const { pattern, seed, lockedBars } = get();
+ const next = mutateKeepingLocks(pattern, seed, lockedBars, mutationRound++);
+ if (!next) return false;
+ set({ pattern: next });
+ return true;
+ },
+
+ toggleLockBar: (bar) => {
+ const { lockedBars, pattern } = get();
+ if (!Number.isInteger(bar) || bar < 0 || bar >= pattern.bars) return;
+ set({ lockedBars: lockedBars.includes(bar) ? lockedBars.filter((b) => b !== bar) : [...lockedBars, bar].sort((a, b) => a - b) });
+ },
+
+ toggleHitAt: (unit, target) => set({ pattern: toggleHit(get().pattern, unit, target) }),
+ cycleAccentAt: (unit) => set({ pattern: cycleAccent(get().pattern, unit) }),
+ togglePalmMuteAt: (unit) => set({ pattern: togglePalmMute(get().pattern, unit) }),
+ clearHitAt: (unit) => set({ pattern: clearHit(get().pattern, unit) }),
+
+ setBpm: (bpm) => set({ bpm: clampBpm(bpm) }),
+ setMetronome: (on) => set({ metronomeOn: on === true }),
+
+ addSlot: (input) => {
+ const { slots } = get();
+ if (slots.length >= MAX_RHYTHM_SLOTS) return false;
+ const slot = createRhythmSlot(input);
+ if (!slot || slots.some((s) => s.id === slot.id)) return false;
+ set(withSlots([...slots, slot]));
+ return true;
+ },
+
+ removeSlot: (id) => {
+ const { slots } = get();
+ if (!slots.some((s) => s.id === id)) return;
+ set(withSlots(slots.filter((s) => s.id !== id)));
+ },
+
+ setSlotShape: (id, shape) => {
+ const { slots } = get();
+ const slot = slots.find((s) => s.id === id);
+ const tuning = slot ? getTuning(slot.tuningId) : undefined;
+ if (!slot || !tuning || !isValidShape(shape, tuning.openMidi.length) || !shape.some((f) => f !== null)) return false;
+ const next = [...shape];
+ set({
+ slots: slots.map((s) => (s.id === id ? { ...s, shape: next, pedalMidi: defaultPedalMidi(next, tuning, s.root) } : s)),
+ optimization: null
+ });
+ return true;
+ },
+
+ useRiffBarChords: (tuning = getRhythmTuning(), profile = getRhythmHandProfile()) => {
+ const slots = slotsFromRiffSteps(useRiffStore.getState().steps, tuning, profile);
+ if (slots.length === 0) return 0;
+ set(withSlots(slots));
+ return slots.length;
+ },
+
+ retuneSlots: (tuning = getRhythmTuning(), profile = getRhythmHandProfile()) => {
+ const { slots } = get();
+ if (slots.every((s) => s.tuningId === tuning.id)) return 0;
+ const retuned = slots.map((s) => retuneSlot(s, tuning, profile)).filter((s): s is RhythmSlot => s !== null);
+ set(withSlots(retuned));
+ return slots.length - retuned.length;
+ },
+
+ optimizeSlots: (profile, tuning) => {
+ const { slots, pattern, bpm } = get();
+ if (slots.length === 0) return { ok: false, message: 'Add chord slots first.' };
+ try {
+ const out = optimizeSlotShapes(slots, pattern, tuning, profile, bpm);
+ const next = slots.map((s, i) => {
+ const shape = out.shapes[i];
+ const same = s.tuningId === tuning.id && slotShapesKey([s]) === slotShapesKey([{ shape, tuningId: tuning.id }]);
+ return same ? s : { ...s, shape, tuningId: tuning.id, pedalMidi: defaultPedalMidi(shape, tuning, s.root) };
+ });
+ set({
+ slots: next,
+ optimization: {
+ text: out.text,
+ maxCost: out.result.maxCost,
+ fingers: out.fingerings.map((f) => [...f.fingers]),
+ shapesKey: slotShapesKey(next)
+ }
+ });
+ return { ok: true, text: out.text };
+ } catch (error) {
+ return { ok: false, message: error instanceof Error ? error.message : 'Could not optimize these slots.' };
+ }
+ },
+
+ setIsPlaying: (value) => set(value ? { isPlaying: true } : { isPlaying: false, playheadUnit: -1 }),
+ setPlayheadUnit: (unit) => set({ playheadUnit: unit })
+ };
+});
+
+useRhythmStore.subscribe((state, prev) => {
+ if (PERSISTED_KEYS.some((key) => state[key] !== prev[key])) saveRhythm(state);
+});
+
+/**
+ * Restarts playback after `delayMs` (pattern, slot, tuning or tempo changes mid-playback), but only while the lab
+ * is still playing when the timer fires, so a Stop pressed in between is never undone. Returns a cancel function.
+ */
+export const scheduleRhythmRestart = (restart: () => void, delayMs: number): (() => void) => {
+ const timer = setTimeout(() => {
+ if (useRhythmStore.getState().isPlaying) restart();
+ }, delayMs);
+ return () => clearTimeout(timer);
+};
diff --git a/stores/riffStore.test.ts b/stores/riffStore.test.ts
index 8adbc04..453e6b5 100644
--- a/stores/riffStore.test.ts
+++ b/stores/riffStore.test.ts
@@ -119,6 +119,21 @@ describe('riffStore', () => {
expect(state.currentStep).toBe(-1);
});
+ it('respells legacy flat note names from stored v1 riffs so MIDI export works', async () => {
+ localStorage.setItem(RIFF_STORAGE_KEY, JSON.stringify({
+ steps: [{ key: 'old-1', baseId: 'drop-melodic-2', name: 'Drop Bb Maj7', subtext: 'Bb(VI)', notes: ['Bb2', 'D3', 'F3', 'A3', '??'] }],
+ bpm: 100
+ }));
+ const useRiffStore = await freshStore();
+ const { steps, bpm } = useRiffStore.getState();
+ expect(bpm).toBe(100);
+ expect(steps).toHaveLength(1);
+ expect(steps[0].key).toBe('old-1');
+ expect(steps[0].notes).toEqual(['A#2', 'D3', 'F3', 'A3']);
+ const { riffToMidi } = await import('../utils/midi');
+ expect(() => riffToMidi(steps, bpm)).not.toThrow();
+ });
+
it('falls back to defaults on corrupt localStorage payloads', async () => {
localStorage.setItem(RIFF_STORAGE_KEY, '{not json');
let useRiffStore = await freshStore();
diff --git a/stores/riffStore.ts b/stores/riffStore.ts
index d4a24fd..dc24c8a 100644
--- a/stores/riffStore.ts
+++ b/stores/riffStore.ts
@@ -1,4 +1,5 @@
import { create } from 'zustand';
+import { midiToName, parseNoteName } from '../engine/pitch';
export const RIFF_STORAGE_KEY = 'riffforge:riff:v1';
export const MAX_RIFF_STEPS = 16;
@@ -41,6 +42,16 @@ const isValidStep = (value: unknown): value is Omit & { key?: s
);
};
+/**
+ * Older library cards stored curated spellings such as "Bb2", which the MIDI writer rejects. Respell
+ * with sharps and drop anything unreadable so a stored v1 riff still plays and exports.
+ */
+const normalizeNotes = (notes: readonly string[]): string[] =>
+ notes.flatMap((note) => {
+ const midi = parseNoteName(note);
+ return midi === null ? [] : [midiToName(midi)];
+ });
+
const loadRiff = (): PersistedRiff => {
const fallback: PersistedRiff = { steps: [], bpm: DEFAULT_BPM };
try {
@@ -59,7 +70,7 @@ const loadRiff = (): PersistedRiff => {
baseId: step.baseId,
name: step.name,
subtext: step.subtext,
- notes: [...step.notes]
+ notes: normalizeNotes(step.notes)
}))
: [];
diff --git a/types.ts b/types.ts
index 71a559e..b26c36a 100644
--- a/types.ts
+++ b/types.ts
@@ -1,12 +1,42 @@
+import type { FingerNumber } from './engine/types';
+import type { ChordExplorerType } from './utils/chordExplorer';
+
+/** An engine-generated shape for a library card. Tab and audio both derive from `shape`. */
+export interface LibraryVoicing {
+ shape: (number | null)[];
+ /** RiffForge tab, low -> high ("0 2 2 x x x"). */
+ tab: string;
+ fingers: (FingerNumber | null)[];
+ degreesByString: (string | null)[];
+ /** Degrees of the sounding notes, low -> high pitch. */
+ degrees: string;
+ /** Honest name from the sounding notes. */
+ soundsAs: string;
+ /** Sounding note names (sharps), from shapeToMidi. */
+ notes: string[];
+ /** Musical relaxations applied to fit the hand, or null when the recipe is exact. */
+ relaxed: string[] | null;
+ explain: string[];
+ /** Chord Explorer link target from what sounds: the selected root, plus a type only when one fits. */
+ explorer: { root: string; type?: ChordExplorerType };
+}
+
export interface Chord {
id: string;
name: string;
subtext: string;
- notes: string[]; // Original notes
+ /** In the JSON: the curated note recipe. On a resolved card: the sounding notes of `voicing`. */
+ notes: string[];
description: string;
fretboard?: string;
baseRoot: string; // The root note of the original voicing (e.g., 'E' for Opeth chord)
relatedChords?: Chord[]; // Child chords that harmonically work with this parent
+ /** Present on resolved cards that have a playable shape. */
+ voicing?: LibraryVoicing;
+ /** Present on resolved cards without a playable shape for the current hand profile. */
+ unplayableReason?: string;
+ /** Chord Explorer target for a card without a playable shape, from its curated notes. */
+ explorer?: { root: string; type?: ChordExplorerType };
}
export enum TuningMode {
diff --git a/utils/chordExplorer.test.ts b/utils/chordExplorer.test.ts
index 291f8b6..ecd7caa 100644
--- a/utils/chordExplorer.test.ts
+++ b/utils/chordExplorer.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { parseRoot, mapToExplorerType, buildChordExplorerUrl } from './chordExplorer';
+import { parseRoot, mapToExplorerType, buildChordExplorerUrl, buildExplorerUrl, explorerTypeForIntervals, shapeToGvParam } from './chordExplorer';
describe('parseRoot', () => {
it('extracts natural and sharp roots from the name', () => {
@@ -87,8 +87,49 @@ describe('buildChordExplorerUrl', () => {
.toBe('https://chords.thegridbase.com/?root=C%23&type=m7');
});
- it('encodes sharps for major chords too (F#5)', () => {
+ it('encodes sharps for major chords too, with the case-sensitive Major id (F#5)', () => {
expect(buildChordExplorerUrl({ name: 'F#5', subtext: 'F#5' }))
- .toBe('https://chords.thegridbase.com/?root=F%23&type=major');
+ .toBe('https://chords.thegridbase.com/?root=F%23&type=Major');
+ });
+
+ it('appends the exact shape as gv, low -> high with x for muted', () => {
+ expect(buildChordExplorerUrl({ name: 'E', subtext: 'E5' }, [0, 2, 2, null, null, null]))
+ .toBe('https://chords.thegridbase.com/?root=E&type=Major&gv=0-2-2-x-x-x');
+ expect(shapeToGvParam([null, 13, 15, null, 10, 0])).toBe('x-13-15-x-10-0');
+ });
+});
+
+describe('parseRoot fallback', () => {
+ it('rejects ordinary words that only start with a note letter', () => {
+ expect(parseRoot('Drop Ghost', 'm2 Clash')).toBe('E');
+ expect(parseRoot('Diminished Phrygian', 'Dim/b2')).toBe('E');
+ });
+
+ it('reads flat roots and returns sharps', () => {
+ expect(parseRoot('Bb(VI)', '')).toBe('A#');
+ expect(parseRoot('Ebm7', '')).toBe('D#');
+ });
+});
+
+describe('buildExplorerUrl', () => {
+ it('keeps links for sounds Chord Explorer has a type for', () => {
+ expect(buildExplorerUrl({ root: 'E', type: 'major' })).toBe('https://chords.thegridbase.com/?root=E&type=Major');
+ expect(buildExplorerUrl({ root: 'A', type: 'minor' }, [null, 0, 2, 2, 1, 0])).toBe(
+ 'https://chords.thegridbase.com/?root=A&type=minor&gv=x-0-2-2-1-0'
+ );
+ expect(buildExplorerUrl({ root: 'C#', type: 'm7' })).toBe('https://chords.thegridbase.com/?root=C%23&type=m7');
+ });
+
+ it('builds no link without a type, so Chord Explorer never shows E5 as its default minor', () => {
+ expect(buildExplorerUrl({ root: 'E' }, [0, 2, 2, null, null, null])).toBeNull();
+ expect(buildExplorerUrl({ root: 'E', type: undefined })).toBeNull();
+ });
+
+ it('types power chords, dyads, quartal stacks and clusters as nothing', () => {
+ expect(explorerTypeForIntervals([0, 7, 0])).toBeUndefined();
+ expect(explorerTypeForIntervals([0, 1])).toBeUndefined();
+ expect(explorerTypeForIntervals([0, 5, 10])).toBeUndefined();
+ expect(explorerTypeForIntervals([0, 1, 2])).toBeUndefined();
+ expect(explorerTypeForIntervals([0, 3, 7, 0])).toBe('minor');
});
});
diff --git a/utils/chordExplorer.ts b/utils/chordExplorer.ts
index 1739277..d0ffe72 100644
--- a/utils/chordExplorer.ts
+++ b/utils/chordExplorer.ts
@@ -5,23 +5,31 @@
* Chord Explorer type ids: major, minor, dim, aug, 7, m7, maj7, dim7, sus2, sus4
*/
+import { parsePitchClass, pitchClass, pitchClassName } from '../engine/pitch';
+import { VOICING_FAMILIES } from '../engine/voicingSpec';
+
export const CHORD_EXPLORER_BASE_URL = 'https://chords.thegridbase.com/';
export type ChordExplorerType =
| 'major' | 'minor' | 'dim' | 'aug' | '7'
| 'm7' | 'maj7' | 'dim7' | 'sus2' | 'sus4';
-const ROOT_RE = /^([A-G]#?)/;
+// A root is a note letter with an optional accidental followed by a chord quality or a non-letter
+// ("Em", "C#m7", "Bb(VI)", "F#5"); ordinary words that start with A-G ("Drop Ghost", "Diminished") are not roots.
+const QUALITY_AHEAD = '(?=$|[^a-z]|m|maj|min|dim|aug|sus|add)';
+const ROOT_RE = new RegExp(`^([A-G](?:#|b${QUALITY_AHEAD})?)${QUALITY_AHEAD}`);
+const STRIP_ROOT_RE = /^([A-G]#?)/;
-/** Extracts the displayed root ("C#m7" -> "C#"). Falls back to "E". */
-export const parseRoot = (name: string, subtext: string): string => {
- const fromName = name.trim().match(ROOT_RE);
- if (fromName) return fromName[1];
- const fromSubtext = subtext.trim().match(ROOT_RE);
- if (fromSubtext) return fromSubtext[1];
- return 'E';
+const rootOf = (text: string): string | null => {
+ const match = text.trim().match(ROOT_RE);
+ if (!match) return null;
+ const pc = parsePitchClass(match[1]);
+ return pc === null ? null : pitchClassName(pc);
};
+/** Fallback root from a label ("C#m7" -> "C#", "Bb(VI)" -> "A#"). Falls back to "E". */
+export const parseRoot = (name: string, subtext: string): string => rootOf(name) ?? rootOf(subtext) ?? 'E';
+
/**
* Best-effort mapping of a chord quality string (root stripped) to a
* Chord Explorer type id. Checks are ordered most-specific first.
@@ -56,7 +64,7 @@ const qualityToType = (quality: string): ChordExplorerType | null => {
* unknown qualities fall back to minor if an "m" marker is present, else major.
*/
export const mapToExplorerType = (name: string, subtext: string): ChordExplorerType => {
- const stripRoot = (s: string) => s.trim().replace(ROOT_RE, '');
+ const stripRoot = (s: string) => s.trim().replace(STRIP_ROOT_RE, '');
const fromSubtext = qualityToType(stripRoot(subtext));
if (fromSubtext) return fromSubtext;
@@ -69,9 +77,86 @@ export const mapToExplorerType = (name: string, subtext: string): ChordExplorerT
return minorish ? 'minor' : 'major';
};
-/** Builds the full Chord Explorer URL. Sharps are URL-encoded (C# -> C%23). */
-export const buildChordExplorerUrl = (chord: { name: string; subtext: string }): string => {
+/** Chord Explorer's URL ids are case-sensitive and capitalize only Major. */
+const TYPE_PARAM: Record = {
+ major: 'Major', minor: 'minor', dim: 'dim', aug: 'aug', '7': '7',
+ m7: 'm7', maj7: 'maj7', dim7: 'dim7', sus2: 'sus2', sus4: 'sus4'
+};
+
+/** Shape as Chord Explorer's `gv` param: low -> high frets joined by "-", "x" for muted ("0-2-2-x-x-x"). */
+export const shapeToGvParam = (shape: readonly (number | null)[]): string =>
+ shape.map((f) => (f === null ? 'x' : String(f))).join('-');
+
+/**
+ * Builds the full Chord Explorer URL. Sharps are URL-encoded (C# -> C%23). With a shape, Chord Explorer's
+ * "Playable for me" panel opens on that exact voicing.
+ */
+export const buildChordExplorerUrl = (
+ chord: { name: string; subtext: string },
+ shape?: readonly (number | null)[]
+): string => {
const root = parseRoot(chord.name, chord.subtext);
- const type = mapToExplorerType(chord.name, chord.subtext);
- return `${CHORD_EXPLORER_BASE_URL}?root=${encodeURIComponent(root)}&type=${encodeURIComponent(type)}`;
+ const type = TYPE_PARAM[mapToExplorerType(chord.name, chord.subtext)];
+ const gv = shape ? `&gv=${shapeToGvParam(shape)}` : '';
+ return `${CHORD_EXPLORER_BASE_URL}?root=${encodeURIComponent(root)}&type=${encodeURIComponent(type)}${gv}`;
+};
+
+/** What a link should open in Chord Explorer: the sounded root, and a type only when Chord Explorer has one. */
+export interface ExplorerTarget {
+ root: string;
+ type?: ChordExplorerType;
+}
+
+// Engine families Chord Explorer can show. Power chords, dyads, the tritone, fourths, quartal stacks and clusters
+// have no Chord Explorer type, so their links carry no type at all rather than a wrong one.
+const TYPE_BY_FAMILY: Readonly> = {
+ sus2: 'sus2', sus4: 'sus4', add9: 'major', m_add9: 'minor', '7': '7', m7: 'm7', maj7: 'maj7',
+ phrygian: 'major', major: 'major', minor: 'minor', dim: 'dim', aug: 'aug'
+};
+
+// Full Chord Explorer formulas for sounds no engine family matches (e.g. a complete maj7 or an m11)
+const FULL_FORMULAS: readonly [ChordExplorerType, readonly number[]][] = [
+ ['dim7', [0, 3, 6, 9]], ['maj7', [0, 4, 7, 11]], ['m7', [0, 3, 7, 10]], ['7', [0, 4, 7, 10]],
+ ['major', [0, 4, 7]], ['minor', [0, 3, 7]], ['sus4', [0, 5, 7]], ['sus2', [0, 2, 7]], ['aug', [0, 4, 8]], ['dim', [0, 3, 6]]
+];
+
+export const explorerTypeForFamily = (familyId: string): ChordExplorerType | undefined =>
+ TYPE_BY_FAMILY[familyId.replace(/^drone_/, '')];
+
+/**
+ * Chord Explorer type for the sounding pitch classes (intervals above the root): the first engine family whose
+ * pitch classes match exactly (bass and note count ignored), else the largest full formula that is present without
+ * a contradicting third. Undefined when nothing fits honestly.
+ */
+export const explorerTypeForIntervals = (intervals: readonly number[]): ChordExplorerType | undefined => {
+ const sounding = new Set(intervals.map((i) => pitchClass(i)));
+ for (const family of VOICING_FAMILIES) {
+ const allowed = new Set([...family.required, ...family.optional]);
+ if (family.required.every((i) => sounding.has(i)) && [...sounding].every((i) => allowed.has(i))) {
+ return explorerTypeForFamily(family.id);
+ }
+ }
+ const hasMinor = sounding.has(3);
+ const hasMajor = sounding.has(4);
+ for (const [type, formula] of FULL_FORMULAS) {
+ if (!formula.every((i) => sounding.has(i))) continue;
+ const third = formula.includes(4) ? 'major' : formula.includes(3) ? 'minor' : 'none';
+ if (third === 'major' && hasMinor) continue;
+ if (third === 'minor' && hasMajor) continue;
+ if (third === 'none' && (hasMinor || hasMajor)) continue;
+ return type;
+ }
+ return undefined;
+};
+
+/**
+ * Chord Explorer URL for a target; the exact shape travels as gv (callers send it only in E Standard). Null when
+ * Chord Explorer has no type for the sound (power chords, dyads, quartal stacks, clusters): a link without a type
+ * would open its default chord, a label the shape does not sound.
+ */
+export const buildExplorerUrl = (target: ExplorerTarget, shape?: readonly (number | null)[]): string | null => {
+ if (!target.type) return null;
+ const params = [`root=${encodeURIComponent(target.root)}`, `type=${encodeURIComponent(TYPE_PARAM[target.type])}`];
+ if (shape) params.push(`gv=${shapeToGvParam(shape)}`);
+ return `${CHORD_EXPLORER_BASE_URL}?${params.join('&')}`;
};
diff --git a/utils/finder.test.ts b/utils/finder.test.ts
new file mode 100644
index 0000000..3968fec
--- /dev/null
+++ b/utils/finder.test.ts
@@ -0,0 +1,229 @@
+import { describe, it, expect } from 'vitest';
+import {
+ FINDER_GENERATE_LIMIT,
+ createLruCache,
+ explainVoicing,
+ filterVoicings,
+ finderCacheKey,
+ finderExplorerUrl,
+ findVoicings,
+ hintText,
+ noveltyPercent,
+ relaxationHints,
+ resultAnnouncement,
+ voicingTagLabels,
+ type FinderQuery
+} from './finder';
+import { explorerTypeForFamily } from './chordExplorer';
+import { DEFAULT_CALIBRATION, DEFAULT_HAND_PROFILE, profileFromCalibration } from '../engine/handProfile';
+import { DROP_D, E_STANDARD } from '../engine/tuning';
+import { getFamily } from '../engine/voicingSpec';
+import { toRiffForgeTab } from '../engine/shape';
+import type { GeneratedVoicing, VoicingTag } from '../engine/types';
+
+const query = (overrides: Partial = {}): FinderQuery => ({
+ root: 'E',
+ familyId: 'power5',
+ tuning: DROP_D,
+ profile: DEFAULT_HAND_PROFILE,
+ distortion: false,
+ sort: 'easiest',
+ ...overrides
+});
+
+const fakeVoicing = (tags: VoicingTag[]): GeneratedVoicing => ({ tags } as unknown as GeneratedVoicing);
+
+describe('createLruCache', () => {
+ it('evicts the least recently used entry and refreshes entries on read', () => {
+ const cache = createLruCache(2);
+ cache.set('a', 1);
+ cache.set('b', 2);
+ expect(cache.get('a')).toBe(1); // a is now the most recent
+ cache.set('c', 3);
+ expect(cache.get('b')).toBeUndefined();
+ expect(cache.get('a')).toBe(1);
+ expect(cache.get('c')).toBe(3);
+ expect(cache.size).toBe(2);
+ });
+
+ it('overwrites an existing key without growing', () => {
+ const cache = createLruCache(2);
+ cache.set('a', 'x');
+ cache.set('a', 'y');
+ expect(cache.size).toBe(1);
+ expect(cache.get('a')).toBe('y');
+ });
+});
+
+describe('findVoicings', () => {
+ it('memoizes per root, family, tuning, profile hash, distortion and sort', () => {
+ const first = findVoicings(query());
+ expect(findVoicings(query())).toBe(first);
+ // A structurally equal profile object hits the same entry
+ expect(findVoicings(query({ profile: { ...DEFAULT_HAND_PROFILE } }))).toBe(first);
+ expect(findVoicings(query({ distortion: true }))).not.toBe(first);
+ expect(findVoicings(query({ sort: 'unusual' }))).not.toBe(first);
+ expect(findVoicings(query({ tuning: E_STANDARD }))).not.toBe(first);
+ expect(findVoicings(query({ familyId: 'sus2' }))).not.toBe(first);
+ });
+
+ it('keys distinguish every memo input', () => {
+ const keys = new Set([
+ finderCacheKey(query()),
+ finderCacheKey(query({ root: 'F' })),
+ finderCacheKey(query({ familyId: 'sus2' })),
+ finderCacheKey(query({ tuning: E_STANDARD })),
+ finderCacheKey(query({ profile: profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 }) })),
+ finderCacheKey(query({ distortion: true })),
+ finderCacheKey(query({ sort: 'unusual' }))
+ ]);
+ expect(keys.size).toBe(7);
+ });
+
+ it('returns at most 48 diversified, no-barre voicings', () => {
+ const { voicings } = findVoicings(query({ familyId: 'sus2' }));
+ expect(voicings.length).toBeGreaterThan(0);
+ expect(voicings.length).toBeLessThanOrEqual(FINDER_GENERATE_LIMIT);
+ for (const v of voicings) expect(v.fingering.barres).toEqual([]);
+ });
+
+ it('a smaller reach removes wide shapes', () => {
+ const narrow = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3 });
+ const wide = findVoicings(query()).voicings.map((v) => toRiffForgeTab(v.shape));
+ const tight = findVoicings(query({ profile: narrow })).voicings.map((v) => toRiffForgeTab(v.shape));
+ expect(tight.length).toBeLessThan(wide.length);
+ // Two fingers on fret 2 push the fret 4 note onto ring or pinky, past a fret 1 -> 3 hand
+ expect(wide).toContain('2 2 x 4 0 x');
+ expect(tight).not.toContain('2 2 x 4 0 x');
+ for (const tab of tight) expect(wide).toContain(tab);
+ });
+});
+
+describe('filterVoicings', () => {
+ const voicings = [
+ fakeVoicing(['lowStrings', 'pedalCompatible']),
+ fakeVoicing(['middleStrings']),
+ fakeVoicing(['topStrings', 'pedalCompatible']),
+ fakeVoicing(['middleStrings', 'topStrings'])
+ ];
+
+ it('keeps everything with no filters', () => {
+ expect(filterVoicings(voicings, { stringGroup: 'all', pedalOnly: false })).toHaveLength(4);
+ });
+
+ it('filters by string group tag', () => {
+ expect(filterVoicings(voicings, { stringGroup: 'low', pedalOnly: false })).toEqual([voicings[0]]);
+ expect(filterVoicings(voicings, { stringGroup: 'middle', pedalOnly: false })).toEqual([voicings[1], voicings[3]]);
+ expect(filterVoicings(voicings, { stringGroup: 'top', pedalOnly: false })).toEqual([voicings[2], voicings[3]]);
+ });
+
+ it('combines the pedal filter with the string group', () => {
+ expect(filterVoicings(voicings, { stringGroup: 'all', pedalOnly: true })).toEqual([voicings[0], voicings[2]]);
+ expect(filterVoicings(voicings, { stringGroup: 'top', pedalOnly: true })).toEqual([voicings[2]]);
+ });
+});
+
+describe('display helpers', () => {
+ it('labels the tags worth showing in a stable order', () => {
+ expect(voicingTagLabels(['rootInBass', 'lowStrings', 'usesOpenStrings', 'pedalCompatible'])).toEqual([
+ 'pedal',
+ 'open strings',
+ 'root in bass'
+ ]);
+ expect(voicingTagLabels(['middleStrings'])).toEqual([]);
+ });
+
+ it('explains cost against the comfortable reach', () => {
+ const v = findVoicings(query({ familyId: 'sus2' })).voicings.find((x) => x.fingering.metrics.reachRatio > 0);
+ expect(v).toBeDefined();
+ const text = explainVoicing(v as GeneratedVoicing, DEFAULT_HAND_PROFILE);
+ expect(text).toMatch(/^\d finger/);
+ expect(text).toContain('percent of your comfort');
+ expect(text).toContain(' · ');
+ expect(text.toLowerCase()).not.toContain('safe');
+ });
+
+ it('announces counts with the family label', () => {
+ expect(resultAnnouncement(7, 'E', getFamily('sus2')!)).toBe('7 voicings for E sus2');
+ expect(resultAnnouncement(1, 'C#', getFamily('m_add9')!)).toBe('1 voicing for C# m(add9)');
+ });
+
+ it('clamps novelty to a percent', () => {
+ expect(noveltyPercent(0.426)).toBe(43);
+ expect(noveltyPercent(-1)).toBe(0);
+ expect(noveltyPercent(2)).toBe(100);
+ });
+});
+
+describe('Chord Explorer mapping', () => {
+ it('maps families to Chord Explorer types and omits the type where Chord Explorer has none', () => {
+ expect(explorerTypeForFamily('m_add9')).toBe('minor');
+ expect(explorerTypeForFamily('sus2')).toBe('sus2');
+ expect(explorerTypeForFamily('m7')).toBe('m7');
+ expect(explorerTypeForFamily('drone_sus2')).toBe('sus2');
+ for (const id of ['power5', 'power5_b2', 'dyad_b2', 'tritone', 'fourth', 'quartal', 'cluster_m2', 'cluster_b3', 'drone_power5']) {
+ expect(explorerTypeForFamily(id)).toBeUndefined();
+ }
+ });
+
+ it('gives no link for families Chord Explorer has no type for, rather than a typeless one it would open as minor', () => {
+ for (const familyId of ['power5', 'dyad_b2', 'tritone', 'quartal', 'cluster_m2']) {
+ const voicing = findVoicings(query({ tuning: E_STANDARD, familyId })).voicings[0];
+ expect(voicing, familyId).toBeDefined();
+ expect(finderExplorerUrl('E', familyId, voicing, E_STANDARD), familyId).toBeNull();
+ }
+ });
+
+ it('sends the shape only in E Standard', () => {
+ const standard = findVoicings(query({ tuning: E_STANDARD, familyId: 'm_add9' })).voicings[0];
+ const drop = findVoicings(query({ familyId: 'm_add9' })).voicings[0];
+ const standardUrl = finderExplorerUrl('E', 'm_add9', standard, E_STANDARD);
+ expect(standardUrl).toContain('root=E&type=minor');
+ expect(standardUrl).toContain(`&gv=${standard.shape.map((f) => (f === null ? 'x' : f)).join('-')}`);
+ expect(finderExplorerUrl('C#', 'm_add9', drop, DROP_D)).toBe('https://chords.thegridbase.com/?root=C%23&type=minor');
+ });
+});
+
+describe('relaxationHints', () => {
+ const strict = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9, allowOpenStrings: false });
+
+ it('offers allow stretches when that unlocks shapes', () => {
+ const q = query({ tuning: E_STANDARD, familyId: 'dyad_b2', profile: strict });
+ expect(findVoicings(q).voicings).toHaveLength(0);
+ const hints = relaxationHints(q);
+ const stretch = hints.find((h) => h.kind === 'calibration' && h.patch.allowStretches === true);
+ expect(stretch).toBeDefined();
+ expect(stretch!.count).toBeGreaterThan(0);
+ expect(hintText(stretch!)).toMatch(/^With 'Allow stretches': \d+ shapes?$/);
+ expect(hintText({ kind: 'extraMute', label: 'With one more muted inner string', count: FINDER_GENERATE_LIMIT })).toBe(
+ 'With one more muted inner string: 48+ shapes'
+ );
+ });
+
+ it('offers one more muted inner string as a finder-only override', () => {
+ const tight = profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9, allowOpenStrings: false, maxFret: 7 });
+ const q = query({ tuning: E_STANDARD, root: 'C', familyId: 'power5_b2', profile: tight });
+ expect(findVoicings(q).voicings).toHaveLength(0);
+ const hints = relaxationHints(q);
+ expect(hints.some((h) => h.kind === 'extraMute' && h.count > 0)).toBe(true);
+ expect(relaxationHints(q, true).some((h) => h.kind === 'extraMute')).toBe(false);
+ });
+
+ it('never suggests barres and stays silent when nothing helps', () => {
+ // F has no open string on its root or fifth in Drop D: no relaxation can add a drone string
+ const q = query({ root: 'F', familyId: 'drone_power5' });
+ expect(findVoicings(q).voicings).toHaveLength(0);
+ expect(relaxationHints(q)).toEqual([]);
+ for (const family of ['dyad_b2', 'phrygian', 'cluster_m2']) {
+ for (const hint of relaxationHints(query({ tuning: E_STANDARD, familyId: family, profile: strict }))) {
+ if (hint.kind === 'calibration') expect(hint.patch).not.toHaveProperty('noBarre');
+ }
+ }
+ });
+
+ it('skips options that are already on', () => {
+ const relaxed = profileFromCalibration({ ...DEFAULT_CALIBRATION, allowStretches: true, allowOpenStrings: true });
+ const hints = relaxationHints(query({ root: 'F', familyId: 'drone_power5', profile: relaxed }));
+ expect(hints.filter((h) => h.kind === 'calibration')).toEqual([]);
+ });
+});
diff --git a/utils/finder.ts b/utils/finder.ts
new file mode 100644
index 0000000..0be0f39
--- /dev/null
+++ b/utils/finder.ts
@@ -0,0 +1,193 @@
+// Pure helpers for the Voicing Finder: memoized generation, result filters, display labels, Chord Explorer
+// mapping and the empty-state relaxation hints. No React, no audio.
+import type {
+ CalibrationAnswers,
+ GenerateResult,
+ GeneratedVoicing,
+ HandProfile,
+ Tuning,
+ VoicingFamily,
+ VoicingSort,
+ VoicingTag
+} from '../engine/types';
+import { generateVoicings } from '../engine/generateVoicings';
+import { STRETCH_TOLERANCE_ALLOW, handProfileHash } from '../engine/handProfile';
+import { explainCost } from '../engine/playability';
+import { buildExplorerUrl, explorerTypeForFamily } from './chordExplorer';
+
+export const FINDER_DEFAULT_FAMILY = 'power5';
+export const FINDER_GENERATE_LIMIT = 48;
+export const FINDER_SHOW_LIMIT = 12;
+export const FINDER_CACHE_SIZE = 32;
+
+// ---------------------------------------------------------------------------
+// Small LRU cache
+// ---------------------------------------------------------------------------
+
+export interface LruCache {
+ get: (key: string) => V | undefined;
+ set: (key: string, value: V) => void;
+ readonly size: number;
+}
+
+/** Map-backed LRU: reads refresh an entry, writes past `capacity` evict the least recently used one. */
+export const createLruCache = (capacity: number): LruCache => {
+ const map = new Map();
+ return {
+ get: (key) => {
+ if (!map.has(key)) return undefined;
+ const value = map.get(key) as V;
+ map.delete(key);
+ map.set(key, value);
+ return value;
+ },
+ set: (key, value) => {
+ map.delete(key);
+ map.set(key, value);
+ while (map.size > capacity) map.delete(map.keys().next().value as string);
+ },
+ get size() {
+ return map.size;
+ }
+ };
+};
+
+// ---------------------------------------------------------------------------
+// Memoized generation
+// ---------------------------------------------------------------------------
+
+export interface FinderQuery {
+ root: string;
+ familyId: string;
+ tuning: Tuning;
+ profile: HandProfile;
+ distortion: boolean;
+ sort: VoicingSort;
+}
+
+export const finderCacheKey = ({ root, familyId, tuning, profile, distortion, sort }: FinderQuery): string =>
+ [root, familyId, tuning.id, handProfileHash(profile), distortion ? 1 : 0, sort].join('|');
+
+const resultCache = createLruCache(FINDER_CACHE_SIZE);
+
+/** generateVoicings for the finder (diversified, 48 candidates), memoized per query in a module-level LRU. */
+export const findVoicings = (query: FinderQuery): GenerateResult => {
+ const key = finderCacheKey(query);
+ const cached = resultCache.get(key);
+ if (cached) return cached;
+ const result = generateVoicings({
+ root: query.root,
+ family: query.familyId,
+ tuning: query.tuning,
+ profile: query.profile,
+ distortion: query.distortion,
+ sort: query.sort,
+ limit: FINDER_GENERATE_LIMIT,
+ diversify: true
+ });
+ resultCache.set(key, result);
+ return result;
+};
+
+// ---------------------------------------------------------------------------
+// Filters
+// ---------------------------------------------------------------------------
+
+export type StringGroupFilter = 'all' | 'low' | 'middle' | 'top';
+
+export const STRING_GROUP_FILTERS: readonly { id: StringGroupFilter; label: string; tag: VoicingTag | null }[] = [
+ { id: 'all', label: 'All', tag: null },
+ { id: 'low', label: 'Low 3', tag: 'lowStrings' },
+ { id: 'middle', label: 'Middle', tag: 'middleStrings' },
+ { id: 'top', label: 'Top 3', tag: 'topStrings' }
+];
+
+export interface FinderFilters {
+ stringGroup: StringGroupFilter;
+ pedalOnly: boolean;
+}
+
+export const filterVoicings = (voicings: readonly GeneratedVoicing[], { stringGroup, pedalOnly }: FinderFilters): GeneratedVoicing[] => {
+ const groupTag = STRING_GROUP_FILTERS.find((g) => g.id === stringGroup)?.tag ?? null;
+ return voicings.filter(
+ (v) => (groupTag === null || v.tags.includes(groupTag)) && (!pedalOnly || v.tags.includes('pedalCompatible'))
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Display helpers
+// ---------------------------------------------------------------------------
+
+const TAG_LABELS: Partial> = {
+ pedalCompatible: 'pedal',
+ drone: 'drone',
+ usesOpenStrings: 'open strings',
+ rootInBass: 'root in bass'
+};
+
+/** Short chip labels for the tags worth showing on a card, in a stable order. */
+export const voicingTagLabels = (tags: readonly VoicingTag[]): string[] =>
+ (Object.keys(TAG_LABELS) as VoicingTag[]).filter((tag) => tags.includes(tag)).map((tag) => TAG_LABELS[tag] as string);
+
+/** One-line comfort explanation: "3 fingers · reach 82 percent of your comfort · 1 interior mute". */
+export const explainVoicing = (voicing: GeneratedVoicing, profile: HandProfile): string =>
+ explainCost(voicing.breakdown, voicing.fingering, profile).join(' · ');
+
+/** "E sus2", as used in headings and the live announcement. */
+export const familyTitle = (root: string, family: VoicingFamily): string => `${root} ${family.label}`;
+
+export const resultAnnouncement = (count: number, root: string, family: VoicingFamily): string =>
+ `${count} ${count === 1 ? 'voicing' : 'voicings'} for ${familyTitle(root, family)}`;
+
+/** Novelty 0..1 as a whole percent, for the "unusual" meter. */
+export const noveltyPercent = (novelty: number): number => Math.round(Math.min(1, Math.max(0, novelty)) * 100);
+
+// ---------------------------------------------------------------------------
+// Chord Explorer
+// ---------------------------------------------------------------------------
+
+/**
+ * Chord Explorer deep link for a finder result: the selected root, the family's Chord Explorer type when it has
+ * one (no type for power chords, dyads, the tritone, fourths, quartal stacks and clusters, which Chord Explorer
+ * cannot show), and the exact shape only in E Standard, the only tuning Chord Explorer knows.
+ */
+export const finderExplorerUrl = (root: string, familyId: string, voicing: GeneratedVoicing, tuning: Tuning): string | null =>
+ buildExplorerUrl({ root, type: explorerTypeForFamily(familyId) }, tuning.id === 'e-standard' ? voicing.shape : undefined);
+
+// ---------------------------------------------------------------------------
+// Empty-state relaxation hints
+// ---------------------------------------------------------------------------
+
+export type RelaxationHint =
+ | { kind: 'calibration'; label: string; count: number; patch: Partial }
+ /** Interior mutes are not a calibration answer, so this one is a finder-only override. */
+ | { kind: 'extraMute'; label: string; count: number };
+
+const countFor = (query: FinderQuery, profile: HandProfile): number => findVoicings({ ...query, profile }).voicings.length;
+
+/**
+ * One-click ways out of an empty result, each tried alone against the same recipe: allow stretches, allow open
+ * strings, and one more muted inner string. Only options that produce shapes are returned. Barres are never
+ * suggested; the hand profile's no-barre rule stays as the player set it.
+ */
+export const relaxationHints = (query: FinderQuery, extraMuteActive = false): RelaxationHint[] => {
+ const { profile } = query;
+ const hints: RelaxationHint[] = [];
+ if (profile.stretchTolerance < STRETCH_TOLERANCE_ALLOW) {
+ const count = countFor(query, { ...profile, stretchTolerance: STRETCH_TOLERANCE_ALLOW });
+ if (count > 0) hints.push({ kind: 'calibration', label: "With 'Allow stretches'", count, patch: { allowStretches: true } });
+ }
+ if (!profile.allowOpenStrings) {
+ const count = countFor(query, { ...profile, allowOpenStrings: true });
+ if (count > 0) hints.push({ kind: 'calibration', label: "With 'Allow open strings'", count, patch: { allowOpenStrings: true } });
+ }
+ if (!extraMuteActive) {
+ const count = countFor(query, { ...profile, maxInteriorMutes: profile.maxInteriorMutes + 1 });
+ if (count > 0) hints.push({ kind: 'extraMute', label: 'With one more muted inner string', count });
+ }
+ return hints;
+};
+
+/** "With 'Allow stretches': 4 shapes"; a count at the generation limit is a lower bound ("48+ shapes"). */
+export const hintText = (hint: RelaxationHint): string =>
+ `${hint.label}: ${hint.count}${hint.count >= FINDER_GENERATE_LIMIT ? '+' : ''} ${hint.count === 1 ? 'shape' : 'shapes'}`;
diff --git a/utils/libraryVoicing.test.ts b/utils/libraryVoicing.test.ts
new file mode 100644
index 0000000..3b6c0d3
--- /dev/null
+++ b/utils/libraryVoicing.test.ts
@@ -0,0 +1,217 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { curatedExplorerTarget, resolveLibraryChord, resolveLibraryVoicing, moveShape, shapeDistance, tuningForMode } from './libraryVoicing';
+import { DEFAULT_HAND_PROFILE, profileFromCalibration, DEFAULT_CALIBRATION } from '../engine/handProfile';
+import { fromRiffForgeTab, shapeToMidi } from '../engine/shape';
+import { midiToName, pitchClass, parsePitchClass, parseNoteName } from '../engine/pitch';
+import { findBestFingering } from '../engine/fingering';
+import { NOTES } from '../constants';
+import { buildExplorerUrl } from './chordExplorer';
+import { Chord, TuningMode } from '../types';
+
+const CHORD_DIR = join(__dirname, '..', 'public', 'chords');
+
+const loadFile = (file: string): Chord[] => JSON.parse(readFileSync(join(CHORD_DIR, file), 'utf8'));
+
+const allEntries = (): { file: string; mode: TuningMode; chord: Chord }[] =>
+ readdirSync(CHORD_DIR)
+ .filter((f) => f.endsWith('.json'))
+ .flatMap((file) => {
+ const mode = file.startsWith('drop') ? TuningMode.DROP : TuningMode.STANDARD;
+ return loadFile(file).flatMap((chord) =>
+ [chord, ...(chord.relatedChords ?? [])].map((c) => ({ file, mode, chord: c }))
+ );
+ });
+
+const find = (file: string, id: string): Chord => {
+ const entry = allEntries().find((e) => e.file === file && e.chord.id === id);
+ if (!entry) throw new Error(`missing ${file} ${id}`);
+ return entry.chord;
+};
+
+describe('tuningForMode', () => {
+ it('maps the two app tunings', () => {
+ expect(tuningForMode(TuningMode.STANDARD).id).toBe('e-standard');
+ expect(tuningForMode(TuningMode.DROP).id).toBe('drop-d');
+ });
+});
+
+describe('resolveLibraryChord', () => {
+ it('gives Drop D power chords a D bass in Drop D (no +2 on the low string)', () => {
+ const power = find('drop-energetic.json', 'drop-energetic-1');
+ const resolved = resolveLibraryChord(power, 'D', TuningMode.DROP, DEFAULT_HAND_PROFILE);
+ expect(resolved.fretboard).toBe('0 0 0 x x x');
+ expect(resolved.notes).toEqual(['D2', 'A2', 'D3']);
+ });
+
+ it('derives tab and audio from the same shape', () => {
+ const ghost = find('standard-melodic.json', 'melodic-1');
+ const resolved = resolveLibraryChord(ghost, 'G', TuningMode.STANDARD, DEFAULT_HAND_PROFILE);
+ const shape = fromRiffForgeTab(resolved.fretboard!)!;
+ expect(resolved.notes).toEqual(shapeToMidi(shape, tuningForMode(TuningMode.STANDARD)).map(midiToName));
+ expect(resolved.voicing!.tab).toBe(resolved.fretboard);
+ expect(resolved.fretboard).not.toBe('3 5 7 3 3 3');
+ });
+
+ it('keeps ids, descriptions and related chords; the title is the sound, the nickname is secondary', () => {
+ const omen = find('standard-dark.json', 'dark-1');
+ const resolved = resolveLibraryChord(omen, 'F#', TuningMode.STANDARD, DEFAULT_HAND_PROFILE);
+ expect(resolved.id).toBe('dark-1');
+ expect(resolved.description).toBe(omen.description);
+ expect(resolved.relatedChords).toBe(omen.relatedChords);
+ expect(resolved.name).toBe('F#5(b9)');
+ expect(resolved.name).toBe(resolved.voicing!.soundsAs);
+ expect(resolved.subtext).toBe('The Omen · m2 Clash');
+ });
+
+ it('never shows a title or nickname root that differs from what sounds (all entries, 12 roots, both tunings)', () => {
+ const leadingRoot = (text: string): string | null => {
+ const match = text.match(/^([A-G][#b]?)/);
+ return match ? match[1] : null;
+ };
+ for (const { mode, chord } of allEntries()) {
+ for (const root of NOTES) {
+ const resolved = resolveLibraryChord(chord, root, mode, DEFAULT_HAND_PROFILE);
+ expect(resolved.voicing).toBeDefined();
+ expect(resolved.name).toBe(resolved.voicing!.soundsAs);
+ expect(leadingRoot(resolved.name)).toBe(root);
+ expect(leadingRoot(resolved.voicing!.soundsAs)).toBe(root);
+ const nickname = resolved.subtext.split(' · ')[0];
+ const nicknameRoot = nickname.match(/^([A-G](?:#|b(?![a-z]))?)(?=$|[\s(\d]|m|M|maj|min|dim|aug|sus|add)/);
+ if (nicknameRoot) expect(nicknameRoot[1]).toBe(root);
+ expect(resolved.subtext).not.toMatch(/\((?:i|ii|iii|iv|v|vi|vii|I|II|III|IV|V|VI|VII)\)/);
+ }
+ }
+ });
+
+ it('gives the corrected minor add9 rows their minor third', () => {
+ const am = find('standard-melodic.json', 'melodic-3');
+ const gm = find('drop-melodic.json', 'drop-melodic-3');
+ expect(resolveLibraryChord(am, 'A', TuningMode.STANDARD, DEFAULT_HAND_PROFILE).name).toBe('Am(add9)');
+ expect(resolveLibraryChord(gm, 'G', TuningMode.DROP, DEFAULT_HAND_PROFILE).name).toBe('Gm(add9)');
+ });
+
+ it('honours the recipe: the Omen is root, b2 and 5 with the root in the bass', () => {
+ const omen = find('standard-dark.json', 'dark-1');
+ for (const root of NOTES) {
+ const { voicing } = resolveLibraryVoicing(omen, root, tuningForMode(TuningMode.STANDARD), DEFAULT_HAND_PROFILE);
+ expect(voicing).not.toBeNull();
+ const midi = voicing!.notes.map((n) => parseNoteName(n)!);
+ const rootPc = parsePitchClass(root)!;
+ expect(new Set(midi.map((m) => pitchClass(m - rootPc)))).toEqual(new Set([0, 1, 7]));
+ expect(pitchClass(Math.min(...midi) - rootPc)).toBe(0);
+ expect(voicing!.degrees.split(' ')[0]).toBe('1');
+ }
+ });
+
+ it('resolves every library entry at every root to a no-barre shape whose audio equals its tab', () => {
+ let unplayable = 0;
+ let relaxed = 0;
+ let total = 0;
+ for (const { mode, chord } of allEntries()) {
+ for (const root of NOTES) {
+ total++;
+ const resolved = resolveLibraryChord(chord, root, mode, DEFAULT_HAND_PROFILE);
+ if (!resolved.voicing) {
+ unplayable++;
+ expect(resolved.unplayableReason).toBeTruthy();
+ expect(resolved.notes).toEqual([]);
+ continue;
+ }
+ if (resolved.voicing.relaxed) relaxed++;
+ const shape = fromRiffForgeTab(resolved.fretboard!)!;
+ expect(resolved.notes).toEqual(shapeToMidi(shape, tuningForMode(mode)).map(midiToName));
+ const fingering = findBestFingering(shape, DEFAULT_HAND_PROFILE);
+ expect(fingering.ok).toBe(true);
+ if (fingering.ok === true) expect(fingering.fingering.barres).toEqual([]);
+ expect(shape.filter((f) => f !== null && f > 0).length).toBeLessThanOrEqual(4);
+ }
+ }
+ expect(total).toBe(252 * 12);
+ // Every curated recipe must be playable somewhere on the neck for the default hand
+ expect(unplayable).toBe(0);
+ console.info(`library: ${total} resolutions, ${relaxed} relaxed, ${unplayable} unplayable`);
+ });
+
+ it('follows the hand profile: a tiny reach without open strings can make a card unplayable with a reason', () => {
+ const tiny = {
+ ...profileFromCalibration({ ...DEFAULT_CALIBRATION, lowPinkyFret: 3, highPinkyFret: 9 }),
+ allowOpenStrings: false,
+ maxFret: 4
+ };
+ const phrygian = find('standard-dark.json', 'dark-2');
+ const resolved = resolveLibraryChord(phrygian, 'A#', TuningMode.STANDARD, tiny);
+ if (!resolved.voicing) {
+ expect(resolved.unplayableReason).toMatch(/\w/);
+ expect(resolved.fretboard).toBeUndefined();
+ } else {
+ expect(resolved.voicing.shape.every((f) => f !== 0)).toBe(true);
+ }
+ });
+
+ it('is deterministic', () => {
+ const omen = find('drop-dark.json', 'drop-dark-1');
+ const a = resolveLibraryChord(omen, 'C', TuningMode.DROP, DEFAULT_HAND_PROFILE);
+ const b = resolveLibraryChord({ ...omen }, 'C', TuningMode.DROP, { ...DEFAULT_HAND_PROFILE });
+ expect(b).toEqual(a);
+ });
+});
+
+describe('card helpers', () => {
+ it('moves a curated tab as a rigid block, wrapping up an octave only as a whole', () => {
+ expect(moveShape([0, 2, 2, null, null, null], 3)).toEqual([3, 5, 5, null, null, null]);
+ expect(moveShape([0, 2, 2, null, null, null], -2)).toEqual([10, 12, 12, null, null, null]);
+ expect(moveShape([null, null, null, null, null, null], 3)).toBeNull();
+ });
+
+ it('measures shape distance by string set first, then frets', () => {
+ expect(shapeDistance([3, 5, 5, null, null, null], [3, 5, 5, null, null, null])).toBe(0);
+ expect(shapeDistance([3, 5, null, null, null, null], [3, 5, 5, null, null, null])).toBeCloseTo(1 / 6, 9);
+ expect(shapeDistance([null, null, null, 0, 3, null], [3, 5, 5, null, null, null])).toBeCloseTo(5 / 6, 9);
+ });
+});
+
+describe('Chord Explorer target', () => {
+ it('links Drop Ghost to the selected root, not the D of its nickname', () => {
+ const ghost = find('drop-melodic.json', 'drop-melodic-1');
+ for (const root of ['G', 'C']) {
+ const resolved = resolveLibraryChord(ghost, root, TuningMode.DROP, DEFAULT_HAND_PROFILE);
+ expect(resolved.voicing!.explorer).toEqual({ root, type: 'minor' });
+ expect(buildExplorerUrl(resolved.voicing!.explorer)).toBe(`https://chords.thegridbase.com/?root=${root}&type=minor`);
+ }
+ });
+
+ it('links every card by its sounded root and never calls a sus2 sound minor', () => {
+ let sus2 = 0;
+ for (const { mode, chord } of allEntries()) {
+ for (const root of NOTES) {
+ const resolved = resolveLibraryChord(chord, root, mode, DEFAULT_HAND_PROFILE);
+ if (!resolved.voicing) continue;
+ expect(resolved.voicing.explorer.root).toBe(root);
+ if (/sus2/.test(resolved.voicing.soundsAs)) {
+ sus2++;
+ expect(resolved.voicing.explorer.type).toBe('sus2');
+ }
+ }
+ }
+ expect(sus2).toBeGreaterThan(0);
+ });
+
+ it('gives no link for sounds Chord Explorer has no type for', () => {
+ const omen = find('standard-dark.json', 'dark-1');
+ const resolved = resolveLibraryChord(omen, 'E', TuningMode.STANDARD, DEFAULT_HAND_PROFILE);
+ expect(resolved.voicing!.soundsAs).toBe('E5(b9)');
+ expect(resolved.voicing!.explorer).toEqual({ root: 'E', type: undefined });
+ expect(buildExplorerUrl(resolved.voicing!.explorer, resolved.voicing!.shape)).toBeNull();
+ expect(resolved.explorer).toBeUndefined();
+ });
+
+ it('links a card without a playable shape by its curated sound at the selected root, not its nickname', () => {
+ const ghost = find('drop-melodic.json', 'drop-melodic-1');
+ expect(curatedExplorerTarget(ghost, 'C')).toEqual({ root: 'C', type: 'minor' });
+ const omen = find('standard-dark.json', 'dark-1');
+ expect(curatedExplorerTarget(omen, 'G')).toEqual({ root: 'G', type: undefined });
+ expect(curatedExplorerTarget({ notes: ['??'], baseRoot: 'E' }, 'A')).toEqual({ root: 'A', type: undefined });
+ });
+});
diff --git a/utils/libraryVoicing.ts b/utils/libraryVoicing.ts
new file mode 100644
index 0000000..d21be36
--- /dev/null
+++ b/utils/libraryVoicing.ts
@@ -0,0 +1,183 @@
+// Turns curated library chords into engine-generated shapes. The JSON `notes` are the musical intent
+// (an interval recipe relative to `baseRoot`, in a specific register); the JSON `fretboard` is only a
+// hint for where on the neck the idea lives, because most curated tabs do not sound their own notes.
+import type { GeneratedVoicing, HandProfile, Shape, Tuning } from '../engine/types';
+import { DROP_D, E_STANDARD } from '../engine/tuning';
+import { fromLegacyNotes } from '../engine/voicingSpec';
+import { findClosestVoicing } from '../engine/generateVoicings';
+import { bassMidi, fromRiffForgeTab, shapeToMidi, toRiffForgeTab } from '../engine/shape';
+import { midiToName, parseNoteName, parsePitchClass, pitchClass } from '../engine/pitch';
+import { explainCost } from '../engine/playability';
+import { handProfileHash } from '../engine/handProfile';
+import { Chord, LibraryVoicing, TuningMode } from '../types';
+import { libraryLabels } from './musicTheory';
+import { explorerTypeForIntervals } from './chordExplorer';
+
+/** The JSON drop files are authored in Drop D, the standard files in E Standard. */
+export const tuningForMode = (mode: TuningMode): Tuning => (mode === TuningMode.DROP ? DROP_D : E_STANDARD);
+
+const CANDIDATES = 200;
+
+// A card shows the voicing that keeps its curated idea: same register, same density, same place on the
+// neck. Engine cost only decides between equally faithful shapes. These preferences are card-only; the
+// Voicing Finder ranks by the engine alone.
+const REGISTER_WEIGHT = 2; // per octave between the bass and the curated bass moved to the new root
+const REGISTER_CAP_OCTAVES = 2;
+const NOTE_COUNT_WEIGHT = 0.5; // per note more or fewer than the curated chord
+const SHAPE_WEIGHT = 1; // 0..1 distance from the curated tab moved rigidly to the new root
+const POSITION_WEIGHT = 0.08; // per fret between the lowest fretted notes of the two shapes
+const POSITION_CAP_FRETS = 10;
+
+const lowestFretOf = (shape: Shape): number => {
+ const fretted = shape.filter((f): f is number => f !== null && f > 0);
+ return fretted.length === 0 ? 0 : Math.min(...fretted);
+};
+
+export interface LibraryResolution {
+ voicing: LibraryVoicing | null;
+ unplayableReason: string | null;
+}
+
+/** Semitone move from baseRoot to targetRoot in -6..5, so the idea stays near its register. */
+const rootShift = (baseRoot: string, targetRoot: string): number => {
+ const from = parsePitchClass(baseRoot);
+ const to = parsePitchClass(targetRoot);
+ if (from === null || to === null) return 0;
+ const up = pitchClass(to - from);
+ return up > 5 ? up - 12 : up;
+};
+
+/** The curated tab moved as a rigid block (every sounding string, opens included) by `shift` frets. */
+export const moveShape = (shape: Shape, shift: number): Shape | null => {
+ const sounding = shape.filter((f): f is number => f !== null);
+ if (sounding.length === 0) return null;
+ let delta = shift;
+ while (Math.min(...sounding) + delta < 0) delta += 12;
+ return shape.map((f) => (f === null ? null : f + delta));
+};
+
+/** 0..1 difference between two absolute shapes: string sets first, then fret distance per string. */
+export const shapeDistance = (a: Shape, b: Shape): number => {
+ if (a.length !== b.length) return 1;
+ let sum = 0;
+ for (let s = 0; s < a.length; s++) {
+ const x = a[s];
+ const y = b[s];
+ if (x === null || y === null) sum += x === y ? 0 : 1;
+ else sum += Math.min(1, Math.abs(x - y) / 3);
+ }
+ return sum / a.length;
+};
+
+const toLibraryVoicing = (
+ v: GeneratedVoicing,
+ tuning: Tuning,
+ profile: HandProfile,
+ relaxed: string[] | null,
+ targetRoot: string
+): LibraryVoicing => ({
+ shape: [...v.shape],
+ tab: toRiffForgeTab(v.shape),
+ fingers: [...v.fingering.fingers],
+ degreesByString: [...v.degreesByString],
+ degrees: v.degrees,
+ soundsAs: v.name,
+ // Audio comes from the same shape as the tab
+ notes: shapeToMidi(v.shape, tuning).map(midiToName),
+ relaxed,
+ explain: explainCost(v.breakdown, v.fingering, profile),
+ // Link by what sounds, never by the curated label ("Drop Ghost" is not a D chord)
+ explorer: {
+ root: targetRoot,
+ type: explorerTypeForIntervals(v.midi.map((m) => pitchClass(m - (parsePitchClass(targetRoot) ?? 0))))
+ }
+});
+
+/** Chord Explorer target from a curated recipe (notes relative to baseRoot), moved to the selected root. */
+export const curatedExplorerTarget = (chord: Pick, targetRoot: string): LibraryVoicing['explorer'] => {
+ const base = parsePitchClass(chord.baseRoot) ?? 0;
+ const midi = chord.notes.map(parseNoteName).filter((m): m is number => m !== null);
+ return { root: targetRoot, type: midi.length > 0 ? explorerTypeForIntervals(midi.map((m) => pitchClass(m - base))) : undefined };
+};
+
+const cache = new Map();
+
+export const resolveLibraryVoicing = (
+ chord: Pick,
+ targetRoot: string,
+ tuning: Tuning,
+ profile: HandProfile
+): LibraryResolution => {
+ const key = [chord.notes.join(','), chord.baseRoot, chord.fretboard ?? '', targetRoot, tuning.id, handProfileHash(profile)].join('|');
+ const cached = cache.get(key);
+ if (cached) return cached;
+
+ let resolution: LibraryResolution;
+ try {
+ const family = fromLegacyNotes(chord.notes, chord.baseRoot);
+ const base = { root: targetRoot, tuning, profile, distortion: true, limit: CANDIDATES, diversify: false };
+ // Exact recipe first, then the engine's musical relaxation ladder (the hand profile is never relaxed)
+ const { result, relaxed, exactEmpty } = findClosestVoicing({ ...base, family });
+
+ if (result.voicings.length === 0) {
+ resolution = {
+ voicing: null,
+ unplayableReason: exactEmpty?.message ?? result.empty?.message ?? 'No playable shape for your hand profile.'
+ };
+ } else {
+ const shift = rootShift(chord.baseRoot, targetRoot);
+ const curatedMidi = chord.notes.map(parseNoteName).filter((m): m is number => m !== null);
+ let targetBass = Math.min(...curatedMidi) + shift;
+ while (targetBass < tuning.openMidi[0]) targetBass += 12;
+ const parsed = chord.fretboard ? fromRiffForgeTab(chord.fretboard) : null;
+ const moved = parsed && parsed.length === tuning.openMidi.length ? moveShape(parsed, shift) : null;
+
+ const score = (v: GeneratedVoicing) => {
+ const bass = bassMidi(v.shape, tuning) ?? targetBass;
+ return (
+ v.cost +
+ REGISTER_WEIGHT * Math.min(REGISTER_CAP_OCTAVES, Math.abs(bass - targetBass) / 12) +
+ NOTE_COUNT_WEIGHT * Math.abs(v.midi.length - curatedMidi.length) +
+ (moved
+ ? SHAPE_WEIGHT * shapeDistance(v.shape, moved) +
+ POSITION_WEIGHT * Math.min(POSITION_CAP_FRETS, Math.abs(lowestFretOf(v.shape) - lowestFretOf(moved)))
+ : 0)
+ );
+ };
+ // Candidates arrive in a deterministic order; keep the first on ties
+ const best = result.voicings.reduce((a, b) => (score(b) < score(a) ? b : a));
+ resolution = { voicing: toLibraryVoicing(best, tuning, profile, relaxed, targetRoot), unplayableReason: null };
+ }
+ } catch {
+ resolution = { voicing: null, unplayableReason: 'This library entry has no readable notes.' };
+ }
+
+ cache.set(key, resolution);
+ return resolution;
+};
+
+/**
+ * A curated chord as displayed for the selected root and tuning. The title (`name`) is the engine's name for what
+ * sounds; the curated nickname and descriptive subtext become the secondary line, so a title can never contradict
+ * the sound. Tab and audio notes come from one engine shape. Ids are untouched so favorites keep working.
+ */
+export const resolveLibraryChord = (
+ chord: Chord,
+ targetRoot: string,
+ tuningMode: TuningMode,
+ profile: HandProfile
+): Chord => {
+ const { nickname, detail } = libraryLabels(chord, targetRoot);
+ const { voicing, unplayableReason } = resolveLibraryVoicing(chord, targetRoot, tuningForMode(tuningMode), profile);
+ return {
+ ...chord,
+ name: voicing ? voicing.soundsAs : nickname,
+ subtext: voicing ? [nickname, detail].filter(Boolean).join(' · ') : detail,
+ notes: voicing ? voicing.notes : [],
+ fretboard: voicing ? voicing.tab : undefined,
+ voicing: voicing ?? undefined,
+ unplayableReason: unplayableReason ?? undefined,
+ // Without a shape, link by the curated sound at the selected root, never by the nickname
+ explorer: voicing ? undefined : curatedExplorerTarget(chord, targetRoot)
+ };
+};
diff --git a/utils/midi.test.ts b/utils/midi.test.ts
index d10c287..1410b02 100644
--- a/utils/midi.test.ts
+++ b/utils/midi.test.ts
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { noteToMidi, vlq, encodeMidi, riffToMidi, TICKS_PER_QUARTER, NOTE_ON_VELOCITY } from './midi';
+import { encodeMidiNotes, rhythmToMidi } from './midi';
+import { generateRhythm } from '../engine/rhythm/generate';
+import { defaultRhythmParams } from '../engine/rhythm/styles';
+import { E_STANDARD } from '../engine/tuning';
+import type { HarmonySlot, HitTarget, RhythmEvent, RhythmPattern } from '../engine/types';
/** Tiny test-side SMF parser: reads header fields and track events with absolute tick times. */
interface ParsedEvent {
@@ -204,3 +209,173 @@ describe('riffToMidi', () => {
expect(parsed.events.map((e) => e.type)).toEqual(['tempo', 'eot']);
});
});
+
+/** Time signature meta (FF 58 04 nn dd cc bb) of a Format 0 file, or null. Scans the track like parseMidi. */
+const readTimeSignature = (bytes: Uint8Array) => {
+ let pos = 22;
+ const readVlq = () => {
+ let value = 0;
+ let byte;
+ do {
+ byte = bytes[pos++];
+ value = (value << 7) | (byte & 0x7f);
+ } while (byte & 0x80);
+ return value;
+ };
+ while (pos < bytes.length) {
+ const delta = readVlq();
+ const status = bytes[pos++];
+ if (status !== 0xff) return null;
+ const metaType = bytes[pos++];
+ const length = readVlq();
+ if (metaType === 0x58) {
+ return { delta, length, numerator: bytes[pos], denominator: 2 ** bytes[pos + 1], clocks: bytes[pos + 2], thirtySeconds: bytes[pos + 3] };
+ }
+ if (metaType === 0x2f) return null;
+ pos += length;
+ }
+ return null;
+};
+
+/** Pairs each note-on with the next note-off of the same pitch: [onTick, duration, pitch, velocity]. */
+const notesOf = (bytes: Uint8Array) => {
+ const { events } = parseMidi(bytes);
+ const open = new Map();
+ const out: { tick: number; duration: number; pitch: number; velocity: number }[] = [];
+ for (const e of events) {
+ if (e.type === 'on') open.set(e.pitch!, e);
+ if (e.type === 'off') {
+ const on = open.get(e.pitch!);
+ expect(on).toBeDefined();
+ out.push({ tick: on!.tick, duration: e.tick - on!.tick, pitch: e.pitch!, velocity: on!.velocity! });
+ open.delete(e.pitch!);
+ }
+ }
+ expect(open.size).toBe(0);
+ return out.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
+};
+
+describe('encodeMidiNotes', () => {
+ it('writes Format 0 at 480 PPQ with the tempo first', () => {
+ const bytes = encodeMidiNotes([{ tick: 0, durationTicks: 120, pitch: 40, velocity: 100 }], 150);
+ const parsed = parseMidi(bytes);
+ expect(parsed.format).toBe(0);
+ expect(parsed.ntrks).toBe(1);
+ expect(parsed.division).toBe(480);
+ expect(parsed.trackEnd).toBe(bytes.length);
+ expect(parsed.events[0]).toMatchObject({ tick: 0, type: 'tempo', microsPerQuarter: 400000 });
+ const declaredLength = (bytes[18] << 24) | (bytes[19] << 16) | (bytes[20] << 8) | bytes[21];
+ expect(declaredLength).toBe(bytes.length - 22);
+ });
+
+ it('puts note-offs before note-ons at the same tick', () => {
+ const bytes = encodeMidiNotes(
+ [
+ { tick: 0, durationTicks: 120, pitch: 40, velocity: 100 },
+ { tick: 120, durationTicks: 120, pitch: 40, velocity: 90 }
+ ],
+ 120
+ );
+ const atBoundary = parseMidi(bytes).events.filter((e) => e.tick === 120);
+ expect(atBoundary.map((e) => e.type)).toEqual(['off', 'on']);
+ expect(notesOf(bytes)).toEqual([
+ { tick: 0, duration: 120, pitch: 40, velocity: 100 },
+ { tick: 120, duration: 120, pitch: 40, velocity: 90 }
+ ]);
+ });
+
+ it('clamps velocities to 1..127 and skips empty notes', () => {
+ const bytes = encodeMidiNotes(
+ [
+ { tick: 0, durationTicks: 60, pitch: 40, velocity: 0 },
+ { tick: 60, durationTicks: 60, pitch: 41, velocity: 300 },
+ { tick: 120, durationTicks: 60, pitch: 42, velocity: 63.6 },
+ { tick: 180, durationTicks: 0, pitch: 43, velocity: 90 }
+ ],
+ 120
+ );
+ expect(notesOf(bytes).map((n) => n.velocity)).toEqual([1, 127, 64]);
+ });
+
+ it('writes the time signature and extends the track to lengthTicks', () => {
+ const bytes = encodeMidiNotes([{ tick: 0, durationTicks: 120, pitch: 40, velocity: 90 }], 120, {
+ meter: { numerator: 7, denominator: 8 },
+ lengthTicks: 1680
+ });
+ expect(readTimeSignature(bytes)).toEqual({ delta: 0, length: 4, numerator: 7, denominator: 8, clocks: 12, thirtySeconds: 8 });
+ expect(parseMidi(bytes).events.find((e) => e.type === 'eot')?.tick).toBe(1680);
+ expect(readTimeSignature(encodeMidiNotes([], 120))).toBeNull();
+ });
+});
+
+describe('rhythmToMidi', () => {
+ const SLOTS: HarmonySlot[] = [{ shape: [0, 2, 2, null, null, null], pedalMidi: null }];
+ const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({
+ tick,
+ durationTicks,
+ target,
+ palmMute: false,
+ accent: 0,
+ pick: 'down',
+ ...extra
+ });
+ const pattern: RhythmPattern = {
+ id: 'm',
+ name: 'M',
+ seed: 'm',
+ meter: { numerator: 4, denominator: 4 },
+ bars: 1,
+ ppq: 480,
+ tags: [],
+ params: { ...defaultRhythmParams('chugEngine'), bars: 1, slotCount: 1 },
+ events: [
+ ev(0, 240, { kind: 'slot', slot: 0 }, { accent: 2 }),
+ ev(240, 120, { kind: 'pedal' }, { palmMute: true }),
+ ev(360, 120, { kind: 'pedal' }),
+ ev(480, 480, { kind: 'slot', slot: 0 }, { accent: 1 }),
+ ev(960, 120, { kind: 'pedal' }, { palmMute: true }),
+ ev(1080, 120, { kind: 'pedal' }, { palmMute: true, pick: 'up' }),
+ ev(1200, 120, { kind: 'dead' })
+ ]
+ };
+ const notes = notesOf(rhythmToMidi(pattern, SLOTS, E_STANDARD, 120));
+ const at = (tick: number) => notes.filter((n) => n.tick === tick);
+
+ it('places every voice of a chord hit on its tick', () => {
+ expect(at(0).map((n) => n.pitch)).toEqual([40, 47, 52]);
+ expect(at(240).map((n) => n.pitch)).toEqual([40]);
+ expect([...new Set(notes.map((n) => n.tick))]).toEqual([0, 240, 360, 480, 960, 1080, 1200]);
+ });
+
+ it('makes accents louder than plain hits, strong accents loudest', () => {
+ const plain = at(360)[0].velocity;
+ const accent = at(480)[0].velocity;
+ const strong = at(0)[0].velocity;
+ expect(accent).toBeGreaterThan(plain);
+ expect(strong).toBeGreaterThan(accent);
+ expect(at(240)[0].velocity).toBeLessThan(plain);
+ });
+
+ it('shortens palm-muted and dead notes', () => {
+ expect(at(360)[0].duration).toBe(120);
+ expect(at(240)[0].duration).toBeLessThan(at(360)[0].duration);
+ expect(at(240)[0].duration).toBe(42);
+ expect(at(1200)[0].duration).toBeLessThanOrEqual(30);
+ expect(at(480)[0].duration).toBe(480);
+ });
+
+ it('writes 4/4 and ends the track at the pattern end', () => {
+ const bytes = rhythmToMidi(pattern, SLOTS, E_STANDARD, 120);
+ expect(readTimeSignature(bytes)).toMatchObject({ numerator: 4, denominator: 4, clocks: 24 });
+ expect(parseMidi(bytes).events.find((e) => e.type === 'eot')?.tick).toBe(1920);
+ });
+
+ it('writes 7/8 for a seven-eight pattern', () => {
+ const seven = generateRhythm(defaultRhythmParams('sevenEight'), 'midi-7');
+ const bytes = rhythmToMidi(seven, SLOTS.concat(SLOTS), E_STANDARD, 140);
+ expect(readTimeSignature(bytes)).toMatchObject({ numerator: 7, denominator: 8, clocks: 12 });
+ expect(parseMidi(bytes).events.find((e) => e.type === 'eot')?.tick).toBe(2 * 1680);
+ expect(parseMidi(bytes).events.find((e) => e.type === 'tempo')?.microsPerQuarter).toBe(Math.round(60000000 / 140));
+ expect(notesOf(bytes).length).toBeGreaterThan(0);
+ });
+});
diff --git a/utils/midi.ts b/utils/midi.ts
index 84351f9..edc8f03 100644
--- a/utils/midi.ts
+++ b/utils/midi.ts
@@ -1,4 +1,7 @@
import { NOTES } from '../constants';
+import type { HarmonySlot, Meter, MidiNoteEvent, RhythmPattern, Tuning } from '../engine/types';
+import { rhythmToMidiNotes } from '../engine/rhythm/playback';
+import { patternLengthTicks } from '../engine/rhythm/grid';
/**
* Minimal Standard MIDI File (SMF) writer — Format 0, single track.
@@ -97,3 +100,74 @@ export const riffToMidi = (steps: { notes: string[] }[], bpm: number): Uint8Arra
}));
return encodeMidi(events, bpm);
};
+
+// ---------------------------------------------------------------------------
+// Note-level encoding (Rhythm Lab)
+// ---------------------------------------------------------------------------
+
+export interface EncodeMidiNotesOptions {
+ /** Writes a time signature meta event (FF 58) at tick 0. */
+ meter?: Meter;
+ /** Places End of Track here when it is later than the last note-off, so loops keep trailing rests. */
+ lengthTicks?: number;
+}
+
+const clampVelocity = (velocity: number): number =>
+ Number.isFinite(velocity) ? Math.min(127, Math.max(1, Math.round(velocity))) : NOTE_ON_VELOCITY;
+
+/** FF 58 04 nn dd cc bb: numerator, log2(denominator), MIDI clocks per click (quarter = 24), 8 32nds per quarter. */
+const timeSignatureMeta = (meter: Meter): number[] => {
+ const log2 = Math.round(Math.log2(meter.denominator));
+ const clocksPerClick = Math.round((24 * 4) / meter.denominator);
+ return [0xff, 0x58, 0x04, meter.numerator & 0xff, log2 & 0xff, clocksPerClick & 0xff, 0x08];
+};
+
+/**
+ * Format 0 single-track SMF from absolute-tick notes (480 PPQ): tempo meta, optional time signature, then
+ * notes sorted by tick with note-offs before note-ons at the same tick (so a re-attacked pitch is released
+ * first). Velocities are rounded and clamped to 1..127; zero-length and negative-tick notes are skipped.
+ */
+export const encodeMidiNotes = (notes: MidiNoteEvent[], bpm: number, opts: EncodeMidiNotesOptions = {}): Uint8Array => {
+ const tempoBpm = Number.isFinite(bpm) && bpm > 0 ? bpm : 120;
+ const track: number[] = [];
+ track.push(0x00, 0xff, 0x51, 0x03, ...u32(Math.round(60000000 / tempoBpm)).slice(1));
+ if (opts.meter) track.push(0x00, ...timeSignatureMeta(opts.meter));
+
+ type Message = { tick: number; on: boolean; pitch: number; velocity: number };
+ const messages: Message[] = [];
+ for (const note of notes) {
+ const tick = Math.round(note.tick);
+ const duration = Math.round(note.durationTicks);
+ if (!Number.isFinite(tick) || !Number.isFinite(duration) || tick < 0 || duration <= 0) continue;
+ const pitch = Math.min(127, Math.max(0, Math.round(note.pitch)));
+ messages.push({ tick, on: true, pitch, velocity: clampVelocity(note.velocity) });
+ messages.push({ tick: tick + duration, on: false, pitch, velocity: 0 });
+ }
+ messages.sort((a, b) => a.tick - b.tick || Number(a.on) - Number(b.on) || a.pitch - b.pitch);
+
+ let last = 0;
+ for (const m of messages) {
+ track.push(...vlq(m.tick - last), m.on ? 0x90 : 0x80, m.pitch & 0x7f, m.velocity & 0x7f);
+ last = m.tick;
+ }
+ const end = Math.max(last, Number.isFinite(opts.lengthTicks) ? Math.round(opts.lengthTicks as number) : 0);
+ track.push(...vlq(end - last), 0xff, 0x2f, 0x00);
+
+ return new Uint8Array([
+ ...ascii('MThd'),
+ ...u32(6),
+ ...u16(0),
+ ...u16(1),
+ ...u16(TICKS_PER_QUARTER),
+ ...ascii('MTrk'),
+ ...u32(track.length),
+ ...track
+ ]);
+};
+
+/** A rhythm pattern voiced by its harmony slots as an SMF, with the pattern's meter and full loop length. */
+export const rhythmToMidi = (pattern: RhythmPattern, slots: HarmonySlot[], tuning: Tuning, bpm: number): Uint8Array =>
+ encodeMidiNotes(rhythmToMidiNotes(pattern, slots, tuning), bpm, {
+ meter: pattern.meter,
+ lengthTicks: patternLengthTicks(pattern)
+ });
diff --git a/utils/musicTheory.test.ts b/utils/musicTheory.test.ts
index 20e88e0..0feeedb 100644
--- a/utils/musicTheory.test.ts
+++ b/utils/musicTheory.test.ts
@@ -1,119 +1,41 @@
import { describe, it, expect } from 'vitest';
-import { transposeNote, transposeTabs, transposeChord } from './musicTheory';
-import { Chord, TuningMode } from '../types';
+import { libraryLabels } from './musicTheory';
-describe('transposeNote', () => {
- it('transposes within an octave', () => {
- expect(transposeNote('E2', 3)).toBe('G2');
- expect(transposeNote('C3', 4)).toBe('E3');
- });
-
- it('wraps up an octave when crossing B', () => {
- expect(transposeNote('B3', 1)).toBe('C4');
- expect(transposeNote('A#2', 2)).toBe('C3');
- });
-
- it('wraps down an octave when transposing below C', () => {
- expect(transposeNote('C4', -1)).toBe('B3');
- expect(transposeNote('A2', -10)).toBe('B1');
- });
-
- it('shifts a full octave for 12 semitones', () => {
- expect(transposeNote('E2', 12)).toBe('E3');
- expect(transposeNote('F#3', -12)).toBe('F#2');
- });
+// Shapes, sounding notes and the card title come from the engine (utils/libraryVoicing.ts). The removed
+// transposeTabs codified two bugs: Drop mode always added 2 frets to the lowest string (wrong bass: E instead of
+// D), and per-string +12 wrapping turned open strings into hidden barres (Em(add9) to G became 3 5 7 3 3 3).
- it('handles sharps', () => {
- expect(transposeNote('G#2', 3)).toBe('B2');
- expect(transposeNote('C#3', 1)).toBe('D3');
- });
-
- it('returns unparseable input unchanged', () => {
- expect(transposeNote('x', 5)).toBe('x');
- expect(transposeNote('Bb3', 1)).toBe('Bb3'); // flats are not in the note table
- });
-});
-
-describe('transposeTabs', () => {
- it('preserves muted strings', () => {
- expect(transposeTabs('x 3 2 0 1 0', 2, TuningMode.STANDARD)).toBe('x 5 4 2 3 2');
- expect(transposeTabs('x x 0 2 3 1', 2, TuningMode.STANDARD)).toBe('x x 2 4 5 3');
- });
-
- it('keeps open strings open at zero semitones in standard tuning', () => {
- expect(transposeTabs('0 2 4 0 0 0', 0, TuningMode.STANDARD)).toBe('0 2 4 0 0 0');
- });
-
- it('adds 2 frets on the lowest string in drop tuning', () => {
- expect(transposeTabs('0 2 4 0 0 0', 0, TuningMode.DROP)).toBe('2 2 4 0 0 0');
- expect(transposeTabs('0 2 4 0 0 0', 3, TuningMode.DROP)).toBe('5 5 7 3 3 3');
- });
-
- it('wraps negative frets up an octave', () => {
- expect(transposeTabs('0 2 2 1 0 0', -2, TuningMode.STANDARD)).toBe('10 0 0 11 10 10');
- });
-
- it('handles empty and sentinel input', () => {
- expect(transposeTabs(undefined, 2, TuningMode.STANDARD)).toBe('');
- expect(transposeTabs('TRANSPOSED', 2, TuningMode.STANDARD)).toBe('TRANSPOSED');
- });
-});
-
-describe('transposeChord', () => {
- const ghost: Chord = {
- id: 'melodic-1',
- name: 'Em',
- subtext: 'Em(add9)',
- notes: ['E2', 'B2', 'F#3', 'G3', 'B3', 'E4'],
- description: 'The quintessential melancholy shape.',
- fretboard: '0 2 4 0 0 0',
- baseRoot: 'E'
- };
-
- it('updates name and subtext (Em -> Cm)', () => {
- const result = transposeChord(ghost, 'C', TuningMode.STANDARD);
- expect(result.name).toBe('Cm');
- expect(result.subtext).toBe('Cm(add9)');
- });
+const card = (name: string, subtext: string, baseRoot: string) => ({ name, subtext, baseRoot });
- it('shifts all notes by the root distance', () => {
- const result = transposeChord(ghost, 'G', TuningMode.STANDARD);
- expect(result.notes).toEqual(['G2', 'D3', 'A3', 'A#3', 'D4', 'G4']);
- expect(result.fretboard).toBe('3 5 7 3 3 3');
+describe('libraryLabels', () => {
+ it('strips the "Drop " prefix and moves a nickname root to the selected root', () => {
+ expect(libraryLabels(card('Drop Bb Maj7', 'Bb(VI)', 'A#'), 'G').nickname).toBe('G Maj7');
+ expect(libraryLabels(card('Drop D Power', '5th', 'D'), 'E').nickname).toBe('E Power');
+ expect(libraryLabels(card('Drop Gm Add9', 'Gm(iv)', 'G'), 'C#').nickname).toBe('C#m Add9');
+ expect(libraryLabels(card('Em Maj7', 'Em(i)', 'E'), 'G').nickname).toBe('Gm Maj7');
});
- it('keeps id stable and chord unchanged when target equals baseRoot', () => {
- const result = transposeChord(ghost, 'E', TuningMode.STANDARD);
- expect(result.id).toBe('melodic-1');
- expect(result.name).toBe('Em');
- expect(result.notes).toEqual(ghost.notes);
+ it('leaves nicknames without a root alone', () => {
+ expect(libraryLabels(card('Drop Ghost', 'Dm(add9)', 'D'), 'G').nickname).toBe('Ghost');
+ expect(libraryLabels(card('Diminished Phrygian', 'Dim/b2', 'E'), 'C').nickname).toBe('Diminished Phrygian');
+ expect(libraryLabels(card('The Omen', 'm2 Clash', 'E'), 'F').nickname).toBe('The Omen');
});
- it('transposes sharp-rooted chords (C#dim -> Ddim)', () => {
- const sharpDim: Chord = {
- id: 'test-sharp',
- name: 'C#dim',
- subtext: 'C#dim(vii)',
- notes: ['C#3', 'E3', 'G3'],
- description: 'Leading tone tension.',
- fretboard: 'x 4 5 6 x x',
- baseRoot: 'C#'
- };
- const result = transposeChord(sharpDim, 'D', TuningMode.STANDARD);
- expect(result.name).toBe('Ddim');
- expect(result.subtext).toBe('Ddim(vii)');
- expect(result.notes).toEqual(['D3', 'F3', 'G#3']);
- expect(result.fretboard).toBe('x 5 6 7 x x');
+ it('drops key-bound symbols and Roman numerals from the subtext', () => {
+ expect(libraryLabels(card('Drop Bb Maj7', 'Bb(VI)', 'A#'), 'G').detail).toBe('');
+ expect(libraryLabels(card('Am Add9', 'Am(iv)', 'A'), 'E').detail).toBe('');
+ expect(libraryLabels(card('Drop Ghost', 'Dm(add9)', 'D'), 'E').detail).toBe('');
+ expect(libraryLabels(card('C sus2', 'C(VI)sus2', 'C'), 'E').detail).toBe('');
});
- it('prefixes the target root for generic subtexts', () => {
- const generic: Chord = { ...ghost, id: 'test-generic', subtext: 'm(add9)' };
- const result = transposeChord(generic, 'C', TuningMode.STANDARD);
- expect(result.subtext).toBe('Cm(add9)');
+ it('keeps descriptive subtexts without gluing them to a root', () => {
+ expect(libraryLabels(card('The Omen', 'm2 Clash', 'E'), 'F').detail).toBe('m2 Clash');
+ expect(libraryLabels(card('Phrygian Root', 'b2', 'E'), 'E').detail).toBe('b2');
+ expect(libraryLabels(card('Diminished Phrygian', 'Dim/b2', 'E'), 'E').detail).toBe('Dim/b2');
+ expect(libraryLabels(card('E Power', '5th', 'E'), 'A').detail).toBe('5th');
});
- it('applies the drop-tuning lowest-string rule to the fretboard', () => {
- const result = transposeChord(ghost, 'E', TuningMode.DROP);
- expect(result.fretboard).toBe('2 2 4 0 0 0');
+ it('drops a subtext the nickname already says', () => {
+ expect(libraryLabels(card('E Sus2', 'Sus2', 'E'), 'G')).toEqual({ nickname: 'G Sus2', detail: '' });
});
});
diff --git a/utils/musicTheory.ts b/utils/musicTheory.ts
index b71b49e..a130ea9 100644
--- a/utils/musicTheory.ts
+++ b/utils/musicTheory.ts
@@ -1,118 +1,40 @@
-import { NOTES } from '../constants';
-import { Chord, TuningMode } from '../types';
-
-const getSemitoneDistance = (fromNote: string, toNote: string): number => {
- const fromIndex = NOTES.indexOf(fromNote);
- const toIndex = NOTES.indexOf(toNote);
- if (fromIndex === -1 || toIndex === -1) return 0;
- return toIndex - fromIndex;
-};
-
-export const transposeNote = (note: string, semitones: number): string => {
- const match = note.match(/^([A-G]#?)(-?\d+)$/);
- if (!match) return note;
-
- const [, name, octaveStr] = match;
- const octave = parseInt(octaveStr, 10);
-
- let currentIndex = NOTES.indexOf(name);
- if (currentIndex === -1) return note;
-
- let newIndex = currentIndex + semitones;
- let octaveShift = Math.floor(newIndex / 12);
-
- newIndex = ((newIndex % 12) + 12) % 12;
-
- const newName = NOTES[newIndex];
- const newOctave = octave + octaveShift;
-
- return `${newName}${newOctave}`;
-};
-
-export const transposeTabs = (originalTabs: string | undefined, semitones: number, tuningMode: TuningMode): string => {
- if (!originalTabs) return '';
- if (originalTabs === 'TRANSPOSED') return originalTabs;
-
- const strings = originalTabs.split(' ');
-
- interface FretInfo {
- fret: number;
- isOpen: boolean;
- isMuted: boolean;
- }
-
- const transposedStrings: (FretInfo | string)[] = strings.map((val, index) => {
- if (val.toLowerCase() === 'x') return 'x';
-
- const fret = parseInt(val, 10);
- if (isNaN(fret)) return val;
-
- let newFret = fret + semitones;
-
- if (tuningMode === TuningMode.DROP && index === 0) {
- newFret += 2;
- }
-
- while (newFret < 0) {
- newFret += 12;
- }
-
- return { fret: newFret, isOpen: fret === 0, isMuted: false };
- });
-
- const allFrets = transposedStrings
- .filter((s): s is FretInfo => typeof s === 'object' && !s.isMuted)
- .map(s => s.fret);
-
- if (allFrets.length > 0) {
- transposedStrings.forEach((item) => {
- if (typeof item === 'object' && !item.isMuted && item.fret < 0) {
- item.fret += 12;
- }
- });
+import { Chord } from '../types';
+import { parsePitchClass, pitchClass, pitchClassName } from '../engine/pitch';
+
+// A root at the start of a nickname: note letter, optional accidental, then a space, a digit or a chord quality
+// ("Em Maj7", "Bb Maj7", "D Power"). Words that merely start with A-G ("Diminished", "Exotic") are not roots.
+const NICKNAME_ROOT = /^([A-G](?:#|b(?![a-z]))?)(?=$|[\s(\d]|m|M|maj|min|dim|aug|sus|add)/;
+
+// A subtext is a chord symbol tied to the curated key ("Em(add9)", "Bb(VI)", "F#dim(vii)") when a note name is
+// followed by a chord quality, not a word that merely starts with A-G ("Dim/b2").
+const ROOTED_SUBTEXT = /^[A-G][#b]?(?=$|[\s(/\d]|m|maj|min|dim|aug|sus|add|M)/;
+
+export interface LibraryLabels {
+ /** Curated nickname without the "Drop " prefix, its root (if any) moved to the selected root. */
+ nickname: string;
+ /** Curated descriptive subtext ("m2 Clash", "5th"); empty when it was a key-bound chord symbol. */
+ detail: string;
+}
+
+/**
+ * Labels for a curated card on the selected root. The title of a resolved card is the engine's name for what
+ * sounds; these are the secondary labels. Key-bound symbols such as "Bb(VI)" are dropped because the card's root
+ * follows the selected root, so a Roman numeral or a stored symbol would describe a different chord.
+ */
+export const libraryLabels = (chord: Pick, targetRoot: string): LibraryLabels => {
+ const base = parsePitchClass(chord.baseRoot);
+ const target = parsePitchClass(targetRoot);
+ const shift = base === null || target === null ? 0 : target - base;
+
+ let nickname = chord.name.trim().replace(/^drop\s+/i, '');
+ const match = nickname.match(NICKNAME_ROOT);
+ if (match) {
+ const pc = parsePitchClass(match[1]);
+ if (pc !== null) nickname = `${pitchClassName(pitchClass(pc + shift))}${nickname.slice(match[1].length)}`;
}
- return transposedStrings.map(item => {
- if (typeof item === 'object') {
- return item.fret.toString();
- }
- return item;
- }).join(' ');
-};
-
-export const transposeChord = (chord: Chord, targetRoot: string, tuningMode: TuningMode): Chord => {
- const distance = getSemitoneDistance(chord.baseRoot, targetRoot);
-
- const newNotes = distance === 0
- ? chord.notes
- : chord.notes.map(note => transposeNote(note, distance));
-
- const isGenericSubtext = !chord.subtext.match(/^[A-G]/);
- const newSubtext = isGenericSubtext
- ? `${targetRoot}${chord.subtext}`
- : chord.subtext.replace(/^[A-G]#?/, targetRoot);
-
- let newName = chord.name;
- if (distance !== 0) {
- const noteMatch = chord.name.match(/^([A-G]#?)(\s|$|m|M|Major|Minor|Maj|Min|7|9|add|sus|dim|aug|maj|min)/i);
- if (noteMatch) {
- const originalNote = noteMatch[1];
- const originalIndex = NOTES.indexOf(originalNote);
- if (originalIndex !== -1) {
- const newIndex = ((originalIndex + distance % 12) + 12) % 12;
- const newNote = NOTES[newIndex];
- newName = chord.name.replace(/^[A-G]#?/i, newNote);
- }
- }
- }
-
- const newTabs = transposeTabs(chord.fretboard, distance, tuningMode);
-
- return {
- ...chord,
- name: newName,
- subtext: newSubtext,
- notes: newNotes,
- fretboard: newTabs
- };
+ const subtext = chord.subtext.trim();
+ const keyBound = ROOTED_SUBTEXT.test(subtext);
+ const redundant = subtext !== '' && nickname.toLowerCase().includes(subtext.toLowerCase());
+ return { nickname, detail: keyBound || redundant ? '' : subtext };
};
diff --git a/utils/rhythmSlots.test.ts b/utils/rhythmSlots.test.ts
new file mode 100644
index 0000000..42677a7
--- /dev/null
+++ b/utils/rhythmSlots.test.ts
@@ -0,0 +1,377 @@
+import { describe, it, expect } from 'vitest';
+import {
+ changeBeats,
+ clickTimes,
+ createRhythmSlot,
+ defaultPedalMidi,
+ describeCell,
+ displayFingering,
+ gridColumns,
+ implicitPowerSlot,
+ loopSeconds,
+ optimizeSlotShapes,
+ retuneSlot,
+ rhythmLanes,
+ sanitizeRhythmSlot,
+ slotCandidates,
+ slotsFromRiffSteps,
+ toggleTargetFor,
+ unitPosition,
+ type RhythmSlot
+} from './rhythmSlots';
+import { DROP_D, E_STANDARD } from '../engine/tuning';
+import { DEFAULT_HAND_PROFILE } from '../engine/handProfile';
+import { generateVoicings } from '../engine/generateVoicings';
+import { shapeKey, shapeToMidi } from '../engine/shape';
+import { transitionCost } from '../engine/transition';
+import { generateRhythm } from '../engine/rhythm/generate';
+import { defaultRhythmParams } from '../engine/rhythm/styles';
+import type { HitTarget, RhythmEvent, RhythmPattern } from '../engine/types';
+
+const profile = DEFAULT_HAND_PROFILE;
+
+const familySlot = (root: string, familyId: string, shape: (number | null)[], id = `${root}-${familyId}`): RhythmSlot => ({
+ id,
+ label: `${root}5`,
+ root,
+ source: { kind: 'family', familyId },
+ shape,
+ tuningId: E_STANDARD.id,
+ pedalMidi: null
+});
+
+const ev = (tick: number, durationTicks: number, target: HitTarget, extra: Partial = {}): RhythmEvent => ({
+ tick,
+ durationTicks,
+ target,
+ palmMute: false,
+ accent: 0,
+ pick: 'down',
+ ...extra
+});
+
+const onePattern = (events: RhythmEvent[], slotCount = 2): RhythmPattern => ({
+ id: 't',
+ name: 'T',
+ seed: 't',
+ meter: { numerator: 4, denominator: 4 },
+ bars: 1,
+ ppq: 480,
+ tags: [],
+ params: { ...defaultRhythmParams('chugEngine'), bars: 1, slotCount },
+ events
+});
+
+describe('defaultPedalMidi', () => {
+ it('uses the open low string when it is the root or the fifth', () => {
+ expect(defaultPedalMidi([0, 2, 2, null, null, null], E_STANDARD, 'E')).toBe(40);
+ // E is the fifth of A
+ expect(defaultPedalMidi([null, 0, 2, 2, null, null], E_STANDARD, 'A')).toBe(40);
+ expect(defaultPedalMidi([0, 0, 0, null, null, null], DROP_D, 'D')).toBe(38);
+ });
+
+ it('falls back to the shape bass otherwise', () => {
+ // C5 on the A string: E is the third of C, so the pedal is the C3 bass
+ expect(defaultPedalMidi([null, 3, 5, 5, null, null], E_STANDARD, 'C')).toBe(48);
+ });
+});
+
+describe('slotCandidates', () => {
+ it('returns diversified recipe voicings for a family slot', () => {
+ const list = slotCandidates(familySlot('E', 'power5', [0, 2, 2, null, null, null]), E_STANDARD, profile);
+ expect(list.length).toBeGreaterThan(1);
+ expect(list.length).toBeLessThanOrEqual(13);
+ for (const v of list) {
+ expect(new Set(v.midi.map((m) => m % 12))).toEqual(new Set([4, 11]));
+ expect(v.fingering.barres).toEqual([]);
+ }
+ });
+
+ it('puts a fingerable current shape first when the generator would not return it', () => {
+ // E2 B2 E3 + open E4: two interior mutes, so generation skips it, but it is easy to finger
+ const current = [0, 2, 2, null, null, 0];
+ const generated = generateVoicings({ root: 'E', family: 'power5', tuning: E_STANDARD, profile, limit: 300, diversify: false }).voicings;
+ expect(generated.some((v) => shapeKey(v.shape) === '0 2 2 x x 0')).toBe(false);
+ const list = slotCandidates(familySlot('E', 'power5', current), E_STANDARD, profile);
+ expect(shapeKey(list[0].shape)).toBe('0 2 2 x x 0');
+ expect(list[0].fingering.fingers.filter((f) => f !== null)).toHaveLength(2);
+ expect(list.slice(1).some((v) => shapeKey(v.shape) === '0 2 2 x x 0')).toBe(false);
+ });
+
+ it('keeps candidates near the slot register', () => {
+ const low = slotCandidates(familySlot('E', 'power5', [0, 2, 2, null, null, null]), E_STANDARD, profile);
+ for (const v of low) expect(Math.min(...v.midi)).toBeLessThanOrEqual(40 + 7);
+ const high = slotCandidates(familySlot('E', 'power5', [null, 7, 9, null, null, null]), E_STANDARD, profile);
+ for (const v of high) expect(Math.abs(Math.min(...v.midi) - 52)).toBeLessThanOrEqual(7);
+ });
+
+ it('ignores an unplayable current shape', () => {
+ // fret 1 to fret 13 is far outside the default reach
+ const list = slotCandidates(familySlot('F', 'power5', [1, null, null, null, null, 13]), E_STANDARD, profile);
+ expect(list.length).toBeGreaterThan(0);
+ expect(list.some((v) => shapeKey(v.shape) === '1 x x x x 13')).toBe(false);
+ });
+
+ it('builds legacy-notes candidates relative to the base root', () => {
+ const slot: RhythmSlot = {
+ id: 'n',
+ label: 'E5',
+ root: 'E',
+ source: { kind: 'notes', notes: ['E2', 'B2', 'E3'], baseRoot: 'E' },
+ shape: [0, 2, 2, null, null, null],
+ tuningId: E_STANDARD.id,
+ pedalMidi: 40
+ };
+ const list = slotCandidates(slot, E_STANDARD, profile);
+ expect(list.some((v) => shapeKey(v.shape) === '0 2 2 x x x')).toBe(true);
+ for (const v of list) expect(Math.min(...v.midi) % 12).toBe(4);
+ });
+});
+
+describe('createRhythmSlot / sanitizeRhythmSlot', () => {
+ it('fills id and the default pedal', () => {
+ const slot = createRhythmSlot({
+ label: 'C5',
+ root: 'C',
+ source: { kind: 'family', familyId: 'power5' },
+ shape: [null, 3, 5, 5, null, null],
+ tuningId: E_STANDARD.id
+ });
+ expect(slot).not.toBeNull();
+ expect(slot!.id).toMatch(/^slot-/);
+ expect(slot!.pedalMidi).toBe(48);
+ });
+
+ it('rejects unknown tunings, wrong string counts and silent shapes', () => {
+ const base = { label: 'x', root: 'E', source: { kind: 'family', familyId: 'power5' } as const };
+ expect(createRhythmSlot({ ...base, shape: [0, 2, 2, null, null, null], tuningId: 'nope' })).toBeNull();
+ expect(createRhythmSlot({ ...base, shape: [0, 2, 2], tuningId: E_STANDARD.id })).toBeNull();
+ expect(createRhythmSlot({ ...base, shape: [null, null, null, null, null, null], tuningId: E_STANDARD.id })).toBeNull();
+ expect(createRhythmSlot({ ...base, root: 'H', shape: [0, 2, 2, null, null, null], tuningId: E_STANDARD.id })).toBeNull();
+ });
+
+ it('drops malformed persisted slots and keeps valid ones', () => {
+ expect(sanitizeRhythmSlot(null)).toBeNull();
+ expect(sanitizeRhythmSlot({ id: 'a' })).toBeNull();
+ expect(sanitizeRhythmSlot({ ...familySlot('E', 'power5', [0, 2, 2, null, null, null]), shape: [0, -1, 2, null, null, null] })).toBeNull();
+ expect(sanitizeRhythmSlot({ ...familySlot('E', 'power5', [0, 2, 2, null, null, null]), source: { kind: 'notes', notes: [], baseRoot: 'E' } })).toBeNull();
+ const kept = sanitizeRhythmSlot(familySlot('E', 'power5', [0, 2, 2, null, null, null]));
+ expect(kept).not.toBeNull();
+ expect(kept!.pedalMidi).toBeNull();
+ expect(sanitizeRhythmSlot({ ...familySlot('E', 'power5', [0, 2, 2, null, null, null]), pedalMidi: 40 })!.pedalMidi).toBe(40);
+ });
+});
+
+describe('retuneSlot', () => {
+ it('keeps the sounding notes when moving E5 to Drop D', () => {
+ const slot = familySlot('E', 'power5', [0, 2, 2, null, null, null]);
+ const moved = retuneSlot(slot, DROP_D, profile);
+ expect(moved).not.toBeNull();
+ expect(moved!.tuningId).toBe(DROP_D.id);
+ expect([...shapeToMidi(moved!.shape, DROP_D)].sort((a, b) => a - b)).toEqual([40, 47, 52]);
+ });
+
+ it('leaves a slot of the same tuning alone', () => {
+ const slot = familySlot('E', 'power5', [0, 2, 2, null, null, null]);
+ expect(retuneSlot(slot, E_STANDARD, profile)).toBe(slot);
+ });
+});
+
+describe('implicitPowerSlot', () => {
+ it('voices a power chord on the root', () => {
+ const slot = implicitPowerSlot('A', E_STANDARD, profile);
+ expect(slot).not.toBeNull();
+ expect(new Set(shapeToMidi(slot!.shape, E_STANDARD).map((m) => m % 12))).toEqual(new Set([9, 4]));
+ expect(slot!.label).toBe('A5');
+ });
+});
+
+describe('slotsFromRiffSteps', () => {
+ const step = (name: string, notes: string[]) => ({ name, subtext: name, notes });
+
+ it('takes the first four distinct steps and starts on the exact sounding voicing', () => {
+ const steps = [
+ step('E5', ['E2', 'B2', 'E3']),
+ step('E5', ['E2', 'B2', 'E3']),
+ step('G5', ['G2', 'D3', 'G3']),
+ step('A5', ['A2', 'E3', 'A3']),
+ step('C5', ['C3', 'G3', 'C4']),
+ step('D5', ['D3', 'A3', 'D4'])
+ ];
+ const slots = slotsFromRiffSteps(steps, E_STANDARD, profile);
+ expect(slots.map((s) => s.label)).toEqual(['E5', 'G5', 'A5', 'C5']);
+ expect(shapeKey(slots[0].shape)).toBe('0 2 2 x x x');
+ // G2 D3 G3 sounds exactly as 3 x 0 0 x x too, and the open strings make it the cheapest exact match
+ expect([...shapeToMidi(slots[1].shape, E_STANDARD)].sort((a, b) => a - b)).toEqual([43, 50, 55]);
+ expect(slots[1].source).toEqual({ kind: 'notes', notes: ['G2', 'D3', 'G3'], baseRoot: 'G' });
+ const distinct = steps.filter((_, k) => k !== 1);
+ slots.forEach((s, i) => expect(shapeToMidi(s.shape, E_STANDARD)).toHaveLength(distinct[i].notes.length));
+ });
+
+ it('treats the same name with other notes as a different step', () => {
+ const slots = slotsFromRiffSteps([step('E5', ['E2', 'B2', 'E3']), step('E5', ['E3', 'B3'])], E_STANDARD, profile);
+ expect(slots).toHaveLength(2);
+ });
+});
+
+describe('changeBeats', () => {
+ it('measures from the last attack on a slot to the first attack on the next one, looping', () => {
+ const pattern = onePattern([
+ ev(0, 240, { kind: 'slot', slot: 0 }, { accent: 2 }),
+ ev(240, 120, { kind: 'pedal' }),
+ ev(360, 120, { kind: 'slot', slot: 0 }),
+ ev(480, 480, { kind: 'dead' }),
+ ev(960, 240, { kind: 'dyad', slot: 1 }, { accent: 1 }),
+ ev(1200, 240, { kind: 'slot', slot: 1 }),
+ ev(1440, 480, { kind: 'pedal' })
+ ]);
+ // slot 0: last attack 360 -> slot 1 at 960 = 600 ticks; slot 1: last attack 1200 -> wraps to 0 = 720 ticks
+ expect(changeBeats(pattern, 2)).toEqual([1.25, 1.5]);
+ });
+
+ it('gives slots that never change the whole pattern', () => {
+ const pattern = onePattern([ev(0, 960, { kind: 'slot', slot: 0 }), ev(960, 960, { kind: 'pedal' })], 3);
+ expect(changeBeats(pattern, 3)).toEqual([4, 4, 4]);
+ });
+
+ it('takes the shortest of several changes', () => {
+ const pattern = onePattern([
+ ev(0, 480, { kind: 'slot', slot: 0 }, { accent: 1 }),
+ ev(480, 120, { kind: 'slot', slot: 1 }, { accent: 1 }),
+ ev(600, 600, { kind: 'pedal' }),
+ ev(1200, 240, { kind: 'slot', slot: 0 }, { accent: 1 }),
+ ev(1440, 480, { kind: 'slot', slot: 1 }, { accent: 1 })
+ ]);
+ // 0 -> 1 after 480 and after 240 ticks; 1 -> 0 after 720 and (looping) 480 ticks
+ expect(changeBeats(pattern, 2)).toEqual([0.5, 1]);
+ });
+});
+
+describe('optimizeSlotShapes', () => {
+ const slots = [
+ familySlot('E', 'power5', [0, 2, 2, null, null, null], 'a'),
+ familySlot('G', 'power5', [3, 5, 5, null, null, null], 'b'),
+ familySlot('A', 'power5', [5, 7, 7, null, null, null], 'c')
+ ];
+ const pattern = generateRhythm({ ...defaultRhythmParams('gallop'), slotCount: 3 }, 'opt-seed');
+
+ it('returns one recipe voicing per slot and a readable hardest change', () => {
+ const out = optimizeSlotShapes(slots, pattern, E_STANDARD, profile, 140);
+ expect(out.shapes).toHaveLength(3);
+ expect(out.fingerings).toHaveLength(3);
+ const pcs = [[4, 11], [7, 2], [9, 4]];
+ out.shapes.forEach((shape, i) => {
+ expect(new Set(shapeToMidi(shape, E_STANDARD).map((m) => m % 12))).toEqual(new Set(pcs[i]));
+ });
+ expect(out.text).toMatch(/^Hardest change: slot \d -> \d, .* \(cost \d+\.\d\)$/);
+ expect(Number.isFinite(out.result.maxCost)).toBe(true);
+ });
+
+ it('is never worse than the best path with the candidates own fingerings (brute force)', () => {
+ const out = optimizeSlotShapes(slots, pattern, E_STANDARD, profile, 140);
+ const beats = changeBeats(pattern, 3);
+ const cands = slots.map((s) => slotCandidates(s, E_STANDARD, profile));
+ const time = (i: number) => (beats[i] * 60) / 140;
+ let best = Infinity;
+ for (const a of cands[0]) {
+ for (const b of cands[1]) {
+ for (const c of cands[2]) {
+ const scale = profile.scaleLengthMm;
+ const worst = Math.max(
+ transitionCost(a, b, { scaleLengthMm: scale, timeSec: time(0) }).cost,
+ transitionCost(b, c, { scaleLengthMm: scale, timeSec: time(1) }).cost,
+ transitionCost(c, a, { scaleLengthMm: scale, timeSec: time(2) }).cost
+ );
+ best = Math.min(best, worst);
+ }
+ }
+ }
+ expect(out.result.maxCost).toBeLessThanOrEqual(best + 1e-9);
+ });
+
+ it('describes a single slot as having no change', () => {
+ const single = optimizeSlotShapes([slots[0]], generateRhythm({ ...defaultRhythmParams('gallop'), slotCount: 1 }, 's'), E_STANDARD, profile, 120);
+ expect(single.text).toBe('Hardest change: none (one chord)');
+ });
+
+ it('throws when a slot has no playable voicing', () => {
+ const broken: RhythmSlot = { ...slots[0], source: { kind: 'family', familyId: 'no-such-family' }, shape: [1, null, null, null, null, 13] };
+ expect(() => optimizeSlotShapes([broken, slots[1]], pattern, E_STANDARD, profile, 120)).toThrow(/Slot 1/);
+ });
+});
+
+describe('displayFingering', () => {
+ it('fingers playable shapes and returns null for silent ones', () => {
+ expect(displayFingering([0, 2, 2, null, null, null], E_STANDARD, profile)?.fingers.filter((f) => f !== null)).toHaveLength(2);
+ expect(displayFingering([null, null, null, null, null, null], E_STANDARD, profile)).toBeNull();
+ });
+});
+
+describe('transport timing', () => {
+ it('clicks on quarters in 4/4 and eighths in 7/8, strong on bar starts', () => {
+ const fourFour = generateRhythm(defaultRhythmParams('gallop'), 'a');
+ const clicks = clickTimes(fourFour, 120);
+ expect(clicks).toHaveLength(8);
+ expect(clicks.filter((c) => c.strong).map((c) => c.timeSec)).toEqual([0, 2]);
+ const seven = generateRhythm(defaultRhythmParams('sevenEight'), 'a');
+ const sevenClicks = clickTimes(seven, 120);
+ expect(sevenClicks).toHaveLength(14);
+ expect(sevenClicks[1].timeSec).toBeCloseTo(0.25, 9);
+ expect(sevenClicks.filter((c) => c.strong).map((c) => c.timeSec)).toEqual([0, 1.75]);
+ expect(loopSeconds(seven, 120)).toBeCloseTo(3.5, 9);
+ });
+});
+
+describe('grid labels', () => {
+ it('counts 16ths as 1 e & a and triplets as 1 trip let', () => {
+ const fourFour = { numerator: 4, denominator: 4 } as const;
+ expect(unitPosition(5, fourFour, '16th')).toMatchObject({ bar: 1, beat: 2, sub: 'e', isBeatStart: false });
+ expect(unitPosition(16, fourFour, '16th')).toMatchObject({ bar: 2, beat: 1, sub: '', label: '1', isBarStart: true });
+ expect([0, 1, 2, 3].map((u) => unitPosition(u, fourFour, '16th').label)).toEqual(['1', 'e', '&', 'a']);
+ expect([0, 1, 2, 3, 4, 5, 6].map((u) => unitPosition(u, fourFour, '16th-triplet').label)).toEqual([
+ '1', 'trip', 'let', '&', 'trip', 'let', '2'
+ ]);
+ });
+
+ it('counts eighths in 7/8', () => {
+ const seven = { numerator: 7, denominator: 8 } as const;
+ expect(unitPosition(13, seven, '16th')).toMatchObject({ bar: 1, beat: 7, sub: '&' });
+ expect(unitPosition(14, seven, '16th')).toMatchObject({ bar: 2, beat: 1, isBarStart: true });
+ expect([0, 1, 2, 3].map((u) => unitPosition(u, seven, '16th-triplet').label)).toEqual(['1', 'trip', 'let', '2']);
+ });
+
+ it('builds lanes pedal, slots, dead', () => {
+ expect(rhythmLanes(2, ['E5']).map((l) => l.label)).toEqual(['Pedal', '1 E5', 'Slot 2', 'Dead']);
+ });
+
+ it('describes cells for screen readers', () => {
+ const pattern = onePattern([
+ ev(0, 240, { kind: 'dyad', slot: 0 }, { accent: 2 }),
+ ev(240, 120, { kind: 'pedal' }, { palmMute: true, accent: 1 }),
+ ev(360, 120, { kind: 'pedal' }, { palmMute: true, pick: 'up' }),
+ ev(480, 240, { kind: 'slot', slot: 1 }, { accent: 1 }),
+ ev(720, 240, { kind: 'slot', slot: 1 }, { tie: true })
+ ]);
+ const columns = gridColumns(pattern);
+ expect(columns).toHaveLength(16);
+ const lanes = rhythmLanes(2);
+ const meter = pattern.meter;
+ const at = (unit: number, lane: number) => describeCell(unitPosition(unit, meter, '16th'), lanes[lane], columns[unit]);
+ expect(at(0, 1)).toBe('Bar 1, beat 1, slot 1, dyad, strong accent, down');
+ expect(at(1, 1)).toBe('Bar 1, beat 1, e, slot 1, held');
+ expect(at(2, 0)).toBe('Bar 1, beat 1, &, pedal, palm muted, accent, down');
+ expect(at(3, 0)).toBe('Bar 1, beat 1, a, pedal, palm muted, up');
+ expect(at(3, 1)).toBe('Bar 1, beat 1, a, slot 1, empty');
+ expect(at(6, 2)).toBe('Bar 1, beat 2, &, slot 2, tied');
+ expect(at(12, 0)).toBe('Bar 1, beat 4, pedal, rest');
+ });
+
+ it('toggles the cell event target when the lane owns it', () => {
+ const pattern = onePattern([ev(0, 240, { kind: 'dyad', slot: 0 }, { accent: 2 })]);
+ const columns = gridColumns(pattern);
+ const lanes = rhythmLanes(2);
+ expect(toggleTargetFor(lanes[1], columns[0])).toEqual({ kind: 'dyad', slot: 0 });
+ expect(toggleTargetFor(lanes[2], columns[0])).toEqual({ kind: 'slot', slot: 1 });
+ expect(toggleTargetFor(lanes[0], columns[4])).toEqual({ kind: 'pedal' });
+ });
+});
diff --git a/utils/rhythmSlots.ts b/utils/rhythmSlots.ts
new file mode 100644
index 0000000..b43e1e2
--- /dev/null
+++ b/utils/rhythmSlots.ts
@@ -0,0 +1,604 @@
+// Pure helpers for the Rhythm Lab: harmony slots (candidates, pedal note, retuning, the RiffBar bridge), the
+// fingering optimizer over a rhythm pattern, metronome click times and grid labels. No React, no Tone.js,
+// no storage: everything here is deterministic for equal inputs.
+import type {
+ Fingering,
+ GeneratedVoicing,
+ HandProfile,
+ HarmonySlot,
+ HitTarget,
+ Meter,
+ RhythmEvent,
+ RhythmGrid,
+ RhythmPattern,
+ Shape,
+ Tuning,
+ VoicingFamily
+} from '../engine/types';
+import { generateVoicings, resolveFamilyId } from '../engine/generateVoicings';
+import { fromLegacyNotes, getFamily } from '../engine/voicingSpec';
+import { findBestFingering } from '../engine/fingering';
+import { nameVoicing } from '../engine/naming';
+import { noveltyScore, scoreVoicing } from '../engine/playability';
+import { bassMidi, isValidShape, shapeKey, shapeToMidi } from '../engine/shape';
+import { intervalFrom, parseNoteName, parsePitchClass, pitchClass } from '../engine/pitch';
+import { getTuning } from '../engine/tuning';
+import { PROFILE_LIMITS } from '../engine/handProfile';
+import { describeTransition, optimizeSequence, type OptimizedSequence } from '../engine/transition';
+import { PPQ, barTicks, barUnits, gridUnitTicks, patternLengthTicks } from '../engine/rhythm/grid';
+import { parseRoot } from './chordExplorer';
+
+// ---------------------------------------------------------------------------
+// Slots
+// ---------------------------------------------------------------------------
+
+export type RhythmSlotSource =
+ | { kind: 'family'; familyId: string }
+ | { kind: 'notes'; notes: string[]; baseRoot: string };
+
+/** One chord of the Rhythm Lab: a recipe (family or legacy notes) on a root, voiced as one engine shape. */
+export interface RhythmSlot {
+ id: string;
+ label: string;
+ /** Pitch-class name the recipe is built on ('E', 'C#'). */
+ root: string;
+ source: RhythmSlotSource;
+ /** Frets low -> high for the tuning named by tuningId. */
+ shape: (number | null)[];
+ tuningId: string;
+ /** Note for pedal hits while this slot is active; null falls back to the shape's lowest note. */
+ pedalMidi: number | null;
+}
+
+export const MAX_RHYTHM_SLOTS = 4;
+export const DEFAULT_SLOT_CANDIDATES = 12;
+
+const hasSound = (shape: Shape): boolean => shape.some((f) => f !== null);
+
+const fitsTuning = (shape: Shape, tuning: Tuning): boolean => isValidShape(shape, tuning.openMidi.length) && hasSound(shape);
+
+let slotCounter = 0;
+export const newSlotId = (): string => `slot-${Date.now().toString(36)}-${(slotCounter++).toString(36)}`;
+
+/** The recipe a slot is built from, or null when its family id is unknown or its notes do not parse. */
+export const slotFamily = (slot: Pick): VoicingFamily | null => {
+ if (slot.source.kind === 'family') return resolveFamilyId(slot.source.familyId) ?? null;
+ try {
+ return fromLegacyNotes(slot.source.notes, slot.source.baseRoot);
+ } catch {
+ return null;
+ }
+};
+
+/** Legacy note recipes are relative to their baseRoot; families to the slot root. */
+const generationRoot = (slot: Pick): string =>
+ slot.source.kind === 'notes' ? slot.source.baseRoot : slot.root;
+
+/**
+ * The lowest open string when it sounds the slot root or fifth (the classic metal pedal), otherwise the
+ * shape's bass note. Null only for a shape with no sounding string.
+ */
+export const defaultPedalMidi = (shape: Shape, tuning: Tuning, root: string): number | null => {
+ const rootPc = parsePitchClass(root);
+ const low = tuning.openMidi[0];
+ if (rootPc !== null && low !== undefined) {
+ const interval = intervalFrom(rootPc, pitchClass(low));
+ if (interval === 0 || interval === 7) return low;
+ }
+ return fitsTuning(shape, tuning) ? bassMidi(shape, tuning) : null;
+};
+
+/** A GeneratedVoicing for an arbitrary shape, scored like the generator does; null when it cannot be fingered. */
+const voicingForShape = (
+ shape: Shape,
+ tuning: Tuning,
+ profile: HandProfile,
+ family: VoicingFamily | null,
+ root: string
+): GeneratedVoicing | null => {
+ if (!fitsTuning(shape, tuning)) return null;
+ const found = findBestFingering(shape, profile);
+ if (found.ok === false) return null;
+ const rootPc = parsePitchClass(root) ?? 0;
+ const naming = nameVoicing(shape, tuning, rootPc, family ?? undefined);
+ const breakdown = scoreVoicing(shape, found.fingering, {
+ tuning,
+ profile,
+ rootPc,
+ family: family ?? undefined,
+ distortion: true,
+ context: 'strum',
+ musical: true
+ });
+ return {
+ shape: [...shape],
+ fingering: found.fingering,
+ midi: shapeToMidi(shape, tuning),
+ degrees: naming.degrees,
+ degreesByString: naming.degreesByString,
+ symbol: naming.symbol,
+ name: naming.name,
+ familyId: family?.id ?? 'custom',
+ cost: breakdown.total,
+ breakdown,
+ novelty: noveltyScore(shape),
+ tags: []
+ };
+};
+
+const generate = (family: VoicingFamily, root: string, tuning: Tuning, profile: HandProfile, limit: number, diversify: boolean) => {
+ try {
+ return generateVoicings({ root, family, tuning, profile, limit, diversify }).voicings;
+ } catch {
+ return [];
+ }
+};
+
+/** Bass windows (semitones from the slot's reference bass) tried in order; the last one is unlimited. */
+export const REGISTER_WINDOWS: readonly number[] = [5, 7, Infinity];
+
+/**
+ * The bass the slot is heard at: the current shape's bass in this tuning, else (legacy notes) the lowest
+ * written note, else null (no register preference).
+ */
+const referenceBass = (slot: RhythmSlot, tuning: Tuning): number | null => {
+ if (slot.tuningId === tuning.id && fitsTuning(slot.shape, tuning)) return bassMidi(slot.shape, tuning);
+ const oldTuning = getTuning(slot.tuningId);
+ if (oldTuning && fitsTuning(slot.shape, oldTuning)) return bassMidi(slot.shape, oldTuning);
+ if (slot.source.kind === 'notes') {
+ const midi = slot.source.notes.map(parseNoteName).filter((m): m is number => m !== null);
+ return midi.length > 0 ? Math.min(...midi) : null;
+ }
+ return null;
+};
+
+/** The narrowest register window that leaves at least two voicings (riffs keep their register), first k. */
+const nearRegister = (voicings: GeneratedVoicing[], bass: number | null, k: number): GeneratedVoicing[] => {
+ if (bass === null) return voicings.slice(0, k);
+ for (const window of REGISTER_WINDOWS) {
+ const near = voicings.filter((v) => Math.abs(Math.min(...v.midi) - bass) <= window);
+ if (near.length >= 2 || window === Infinity) return near.slice(0, k);
+ }
+ return voicings.slice(0, k);
+};
+
+/** Pool searched before the register filter, so k voicings usually survive it. */
+const CANDIDATE_POOL_FACTOR = 4;
+
+/**
+ * Up to k diversified engine voicings of the slot's recipe for this tuning and hand, kept near the slot's
+ * register: the bass stays within a fourth of the slot's current bass (a fifth, then anywhere, when fewer than
+ * two voicings qualify), so the optimizer never trades a low chug chord for an easier high dyad. The slot's
+ * current shape is put first when it is valid for the tuning and fingerable but not among them.
+ */
+export const slotCandidates = (
+ slot: RhythmSlot,
+ tuning: Tuning,
+ profile: HandProfile,
+ k: number = DEFAULT_SLOT_CANDIDATES
+): GeneratedVoicing[] => {
+ const family = slotFamily(slot);
+ const root = generationRoot(slot);
+ const pool = family ? generate(family, root, tuning, profile, k * CANDIDATE_POOL_FACTOR, true) : [];
+ const voicings = nearRegister(pool, referenceBass(slot, tuning), k);
+ if (slot.tuningId !== tuning.id || !fitsTuning(slot.shape, tuning)) return voicings;
+ const key = shapeKey(slot.shape);
+ if (voicings.some((v) => shapeKey(v.shape) === key)) return voicings;
+ const current = voicingForShape(slot.shape, tuning, profile, family, root);
+ return current ? [current, ...voicings] : voicings;
+};
+
+/** Widest profile the engine accepts: used only to show finger numbers for a shape outside the user's hand. */
+const permissiveProfile = (profile: HandProfile): HandProfile => ({
+ ...profile,
+ stretchTolerance: PROFILE_LIMITS.stretchTolerance[1],
+ noBarre: false,
+ allowTwoStringPartialBarre: true,
+ maxFret: PROFILE_LIMITS.maxFret[1],
+ allowOpenStrings: true,
+ maxInteriorMutes: PROFILE_LIMITS.maxInteriorMutes[1]
+});
+
+/** Best fingering for display: the user's profile first, then a permissive one; null when none exists. */
+export const displayFingering = (shape: Shape, tuning: Tuning, profile: HandProfile): Fingering | null => {
+ if (!fitsTuning(shape, tuning)) return null;
+ const own = findBestFingering(shape, profile);
+ if (own.ok === true) return own.fingering;
+ const loose = findBestFingering(shape, permissiveProfile(profile));
+ return loose.ok === true ? loose.fingering : null;
+};
+
+export interface SlotInput {
+ id?: string;
+ label: string;
+ root: string;
+ source: RhythmSlotSource;
+ shape: readonly (number | null)[];
+ tuningId: string;
+ pedalMidi?: number | null;
+}
+
+const isMidi = (x: unknown): x is number => Number.isInteger(x) && (x as number) >= 0 && (x as number) <= 127;
+
+const validSource = (source: unknown): RhythmSlotSource | null => {
+ if (typeof source !== 'object' || source === null) return null;
+ const s = source as Record;
+ if (s.kind === 'family') {
+ return typeof s.familyId === 'string' && s.familyId.length > 0 ? { kind: 'family', familyId: s.familyId } : null;
+ }
+ if (s.kind === 'notes') {
+ if (!Array.isArray(s.notes) || typeof s.baseRoot !== 'string' || parsePitchClass(s.baseRoot) === null) return null;
+ const notes = s.notes.filter((n): n is string => typeof n === 'string' && parseNoteName(n) !== null);
+ return notes.length > 0 ? { kind: 'notes', notes, baseRoot: s.baseRoot } : null;
+ }
+ return null;
+};
+
+/**
+ * A complete, validated slot (fresh id unless given; default pedal unless a MIDI note is given), or null when
+ * the tuning is unknown, the shape does not fit it, the root does not parse or the source is malformed.
+ */
+export const createRhythmSlot = (input: SlotInput): RhythmSlot | null => {
+ const tuning = getTuning(input.tuningId);
+ const source = validSource(input.source);
+ if (!tuning || !source || parsePitchClass(input.root) === null) return null;
+ const shape = Array.isArray(input.shape) ? [...input.shape] : [];
+ if (!fitsTuning(shape, tuning)) return null;
+ return {
+ id: typeof input.id === 'string' && input.id.length > 0 ? input.id : newSlotId(),
+ label: typeof input.label === 'string' && input.label.trim().length > 0 ? input.label.trim() : input.root,
+ root: input.root,
+ source,
+ shape,
+ tuningId: tuning.id,
+ pedalMidi: isMidi(input.pedalMidi) ? input.pedalMidi : defaultPedalMidi(shape, tuning, input.root)
+ };
+};
+
+/** Validates a persisted slot; null drops it. A stored null pedal stays null (playback uses the bass). */
+export const sanitizeRhythmSlot = (raw: unknown): RhythmSlot | null => {
+ if (typeof raw !== 'object' || raw === null) return null;
+ const r = raw as Record;
+ if (typeof r.id !== 'string' || typeof r.label !== 'string' || typeof r.root !== 'string' || typeof r.tuningId !== 'string') {
+ return null;
+ }
+ if (!Array.isArray(r.shape) || !r.shape.every((f) => f === null || (Number.isInteger(f) && (f as number) >= 0))) return null;
+ const slot = createRhythmSlot({
+ id: r.id,
+ label: r.label,
+ root: r.root,
+ source: r.source as RhythmSlotSource,
+ shape: r.shape as (number | null)[],
+ tuningId: r.tuningId,
+ pedalMidi: isMidi(r.pedalMidi) ? r.pedalMidi : undefined
+ });
+ if (!slot) return null;
+ return r.pedalMidi === null ? { ...slot, pedalMidi: null } : slot;
+};
+
+export const toHarmonySlots = (slots: readonly RhythmSlot[]): HarmonySlot[] =>
+ slots.map((slot) => ({ shape: [...slot.shape], pedalMidi: slot.pedalMidi }));
+
+const sortedMidi = (notes: readonly number[]): string => [...notes].sort((a, b) => a - b).join(',');
+
+const cheapest = (voicings: readonly GeneratedVoicing[]): GeneratedVoicing | null =>
+ voicings.reduce((best, v) => (best === null || v.cost < best.cost ? v : best), null);
+
+/** Search size used to find an exact sounding match before falling back to the cheapest voicing. */
+const EXACT_MATCH_SEARCH = 300;
+
+/** The cheapest voicing whose sounding notes equal `midi` (as a multiset), searching beyond the top k. */
+const exactMatch = (family: VoicingFamily, root: string, tuning: Tuning, profile: HandProfile, midi: readonly number[]) => {
+ const target = sortedMidi(midi);
+ return cheapest(generate(family, root, tuning, profile, EXACT_MATCH_SEARCH, false).filter((v) => sortedMidi(v.midi) === target));
+};
+
+/**
+ * Re-voices a slot for another tuning: the voicing that sounds the same notes when one exists, otherwise the
+ * cheapest one of its recipe. Unchanged when it already belongs to `tuning`; null when nothing is playable.
+ */
+export const retuneSlot = (slot: RhythmSlot, tuning: Tuning, profile: HandProfile): RhythmSlot | null => {
+ if (slot.tuningId === tuning.id && fitsTuning(slot.shape, tuning)) return slot;
+ const family = slotFamily(slot);
+ if (!family) return null;
+ const root = generationRoot(slot);
+ const oldTuning = getTuning(slot.tuningId);
+ const oldMidi = oldTuning && fitsTuning(slot.shape, oldTuning) ? shapeToMidi(slot.shape, oldTuning) : null;
+ const pick =
+ (oldMidi ? exactMatch(family, root, tuning, profile, oldMidi) : null) ??
+ cheapest(generate(family, root, tuning, profile, DEFAULT_SLOT_CANDIDATES, true));
+ if (!pick) return null;
+ const shape = [...pick.shape];
+ return { ...slot, shape, tuningId: tuning.id, pedalMidi: defaultPedalMidi(shape, tuning, slot.root) };
+};
+
+/** The implicit slot used while the lab has none: the cheapest power chord on `root`. */
+export const implicitPowerSlot = (root: string, tuning: Tuning, profile: HandProfile): RhythmSlot | null => {
+ const family = getFamily('power5');
+ if (!family || parsePitchClass(root) === null) return null;
+ const pick = cheapest(generate(family, root, tuning, profile, DEFAULT_SLOT_CANDIDATES, true));
+ if (!pick) return null;
+ return {
+ id: 'implicit-power5',
+ label: `${root}5`,
+ root,
+ source: { kind: 'family', familyId: 'power5' },
+ shape: [...pick.shape],
+ tuningId: tuning.id,
+ pedalMidi: defaultPedalMidi(pick.shape, tuning, root)
+ };
+};
+
+export interface RiffStepLike {
+ name: string;
+ subtext: string;
+ notes: readonly string[];
+}
+
+/**
+ * RiffBar bridge: the first `max` distinct steps (same name and notes count once) become slots with a
+ * legacy-notes recipe on the step's root. Each starts on the voicing that sounds exactly the step's notes
+ * when one exists, else on the cheapest voicing; steps with no playable voicing are skipped.
+ */
+export const slotsFromRiffSteps = (
+ steps: readonly RiffStepLike[],
+ tuning: Tuning,
+ profile: HandProfile,
+ max: number = MAX_RHYTHM_SLOTS
+): RhythmSlot[] => {
+ const seen = new Set();
+ const slots: RhythmSlot[] = [];
+ for (const step of steps) {
+ if (slots.length >= max) break;
+ const key = `${step.name}|${step.notes.join(',')}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const notes = step.notes.filter((n) => parseNoteName(n) !== null);
+ if (notes.length === 0) continue;
+ const baseRoot = parseRoot(step.name, step.subtext);
+ const source: RhythmSlotSource = { kind: 'notes', notes: [...notes], baseRoot };
+ const family = slotFamily({ source });
+ if (!family) continue;
+ const midi = notes.map((n) => parseNoteName(n) as number);
+ const pick =
+ exactMatch(family, baseRoot, tuning, profile, midi) ??
+ cheapest(generate(family, baseRoot, tuning, profile, DEFAULT_SLOT_CANDIDATES, true));
+ if (!pick) continue;
+ const slot = createRhythmSlot({ label: step.name, root: baseRoot, source, shape: pick.shape, tuningId: tuning.id });
+ if (slot) slots.push(slot);
+ }
+ return slots;
+};
+
+export const slotShapesKey = (slots: readonly Pick[]): string =>
+ slots.map((s) => `${s.tuningId}:${shapeKey(s.shape)}`).join('|');
+
+// ---------------------------------------------------------------------------
+// Change timing and the fingering optimizer
+// ---------------------------------------------------------------------------
+
+const isChordAttack = (e: RhythmEvent): e is RhythmEvent & { target: Extract } =>
+ !e.tie && (e.target.kind === 'slot' || e.target.kind === 'dyad');
+
+/**
+ * Time available for each slot's chord change, in quarter-note beats (the unit optimizeSequence scales by
+ * 60 / bpm). Walking the slot and dyad attacks of the looping pattern, every point where the next chord
+ * attack is on a different slot is a change: its time runs from the last attack on the old slot to the first
+ * attack on the new one, so pedal hits, dead notes, rests and ties in between all count as time to move.
+ * Each slot gets its shortest change (the hardest moment to leave it). Slots that are never left, including
+ * slots the pattern never plays, get the whole pattern length. The optimizer chains slots in index order
+ * (1 -> 2 -> ... -> 1), so this is the worst case of leaving slot i for whichever slot actually follows.
+ */
+export const changeBeats = (pattern: RhythmPattern, slotCount: number): number[] => {
+ const count = Math.max(0, Math.floor(slotCount));
+ const length = patternLengthTicks(pattern);
+ const best: number[] = Array.from({ length: count }, () => Infinity);
+ const attacks = pattern.events.filter(isChordAttack);
+ if (attacks.length > 1) {
+ for (let i = 0; i < attacks.length; i++) {
+ const from = attacks[i];
+ const next = attacks[(i + 1) % attacks.length];
+ if (from.target.slot === next.target.slot) continue;
+ const gap = i + 1 < attacks.length ? next.tick - from.tick : length - from.tick + next.tick;
+ const slot = from.target.slot;
+ if (slot >= 0 && slot < count && gap < best[slot]) best[slot] = gap;
+ }
+ }
+ return best.map((ticks) => (Number.isFinite(ticks) ? ticks : length) / PPQ);
+};
+
+export interface SlotShapeOptimization {
+ /** Chosen shape per slot. */
+ shapes: (number | null)[][];
+ /** Fingering per slot chosen together with its neighbours (show these, not the candidates' own). */
+ fingerings: Fingering[];
+ result: OptimizedSequence;
+ /** describeTransition of the hardest change plus its cost rounded to 0.1. */
+ text: string;
+}
+
+/**
+ * Picks one voicing per slot so the hardest chord change of the looping pattern is as easy as possible at
+ * this tempo (minimax Viterbi over slotCandidates, timed by changeBeats), with the user's hand profile so the
+ * fingerings are chosen with their neighbours. Throws when a slot has no playable voicing at all.
+ */
+export const optimizeSlotShapes = (
+ slots: readonly RhythmSlot[],
+ pattern: RhythmPattern,
+ tuning: Tuning,
+ profile: HandProfile,
+ bpm: number
+): SlotShapeOptimization => {
+ const candidates = slots.map((slot, i) => {
+ const list = slotCandidates(slot, tuning, profile);
+ if (list.length === 0) throw new Error(`Slot ${i + 1} (${slot.label}) has no playable voicing for your hand profile.`);
+ return list;
+ });
+ const result = optimizeSequence(
+ candidates,
+ { bpm, beatsPerSlot: changeBeats(pattern, slots.length), cyclic: true },
+ { profile }
+ );
+ const shapes = result.path.map((index, slot) => [...candidates[slot][index].shape]);
+ const text =
+ slots.length < 2
+ ? describeTransition(null)
+ : `${describeTransition(result.hardest)} (cost ${(Math.round(result.maxCost * 10) / 10).toFixed(1)})`;
+ return { shapes, fingerings: result.fingerings, result, text };
+};
+
+// ---------------------------------------------------------------------------
+// Transport timing
+// ---------------------------------------------------------------------------
+
+export const secondsPerTick = (bpm: number): number => 60 / (PPQ * bpm);
+
+/** Loop length of the whole pattern in seconds at this (quarter-note) BPM. */
+export const loopSeconds = (pattern: Pick, bpm: number): number =>
+ bpm > 0 ? patternLengthTicks(pattern) * secondsPerTick(bpm) : 0;
+
+/** Metronome pulse in ticks: quarters in x/4, eighths in x/8. */
+export const clickStepTicks = (meter: Meter): number => (meter.denominator === 8 ? PPQ / 2 : PPQ);
+
+/** Click times over the pattern: one per pulse (see clickStepTicks), strong on every bar start. */
+export const clickTimes = (pattern: Pick, bpm: number): { timeSec: number; strong: boolean }[] => {
+ if (!(bpm > 0)) return [];
+ const step = clickStepTicks(pattern.meter);
+ const bar = barTicks(pattern.meter);
+ const length = patternLengthTicks(pattern);
+ const out: { timeSec: number; strong: boolean }[] = [];
+ for (let tick = 0; tick < length; tick += step) out.push({ timeSec: tick * secondsPerTick(bpm), strong: tick % bar === 0 });
+ return out;
+};
+
+// ---------------------------------------------------------------------------
+// Grid model and labels
+// ---------------------------------------------------------------------------
+
+const SUBDIVISION_NAMES: Readonly> = {
+ 2: ['', '&'],
+ 3: ['', 'trip', 'let'],
+ 4: ['', 'e', '&', 'a'],
+ 6: ['', 'trip', 'let', '&', 'trip', 'let']
+};
+
+export interface UnitPosition {
+ /** 1-based bar number. */
+ bar: number;
+ /** 1-based count within the bar: quarters in x/4, eighths in x/8. */
+ beat: number;
+ /** '' on the beat, else 'e', '&', 'a' (16ths) or 'trip', 'let' (16th triplets). */
+ sub: string;
+ /** Header text: the beat number on the beat, else the syllable. */
+ label: string;
+ isBarStart: boolean;
+ isBeatStart: boolean;
+}
+
+export const unitPosition = (unit: number, meter: Meter, grid: RhythmGrid): UnitPosition => {
+ const perBar = barUnits(meter, grid);
+ const perBeat = clickStepTicks(meter) / gridUnitTicks(grid);
+ const inBar = ((unit % perBar) + perBar) % perBar;
+ const beat = Math.floor(inBar / perBeat) + 1;
+ const index = inBar % perBeat;
+ const sub = SUBDIVISION_NAMES[perBeat]?.[index] ?? (index === 0 ? '' : '.');
+ return {
+ bar: Math.floor(unit / perBar) + 1,
+ beat,
+ sub,
+ label: sub === '' ? String(beat) : sub,
+ isBarStart: inBar === 0,
+ isBeatStart: index === 0
+ };
+};
+
+export type LaneId = string;
+
+export interface RhythmLane {
+ id: LaneId;
+ label: string;
+ /** Spoken name ("pedal", "slot 2", "dead note"). */
+ spoken: string;
+ /** Target a fresh hit in this lane gets. */
+ target: HitTarget;
+}
+
+export const laneIdOf = (target: HitTarget): LaneId =>
+ target.kind === 'slot' || target.kind === 'dyad' ? `slot-${target.slot}` : target.kind;
+
+/** Pedal, Slot 1..N, Dead (top to bottom). */
+export const rhythmLanes = (slotCount: number, slotLabels: readonly string[] = []): RhythmLane[] => [
+ { id: 'pedal', label: 'Pedal', spoken: 'pedal', target: { kind: 'pedal' } },
+ ...Array.from({ length: Math.max(1, slotCount) }, (_, i): RhythmLane => ({
+ id: `slot-${i}`,
+ label: slotLabels[i] ? `${i + 1} ${slotLabels[i]}` : `Slot ${i + 1}`,
+ spoken: slotLabels[i] ? `slot ${i + 1}, ${slotLabels[i]}` : `slot ${i + 1}`,
+ target: { kind: 'slot', slot: i }
+ })),
+ { id: 'dead', label: 'Dead', spoken: 'dead note', target: { kind: 'dead' } }
+];
+
+export interface GridColumn {
+ unit: number;
+ /** Events whose onset lies in this cell (two in a 32nd tremolo cell). */
+ events: RhythmEvent[];
+ /** Lane of the cell's first event, null when nothing starts here. */
+ lane: LaneId | null;
+ /** Lane of an earlier event still sounding through this cell (grid duration), when nothing starts here. */
+ heldLane: LaneId | null;
+}
+
+/** One column per grid unit, in order. */
+export const gridColumns = (pattern: RhythmPattern): GridColumn[] => {
+ const unitTicks = gridUnitTicks(pattern.params.grid);
+ const units = patternLengthTicks(pattern) / unitTicks;
+ const columns: GridColumn[] = [];
+ let e = 0;
+ let last: RhythmEvent | null = null;
+ for (let unit = 0; unit < units; unit++) {
+ const start = unit * unitTicks;
+ const end = start + unitTicks;
+ const events: RhythmEvent[] = [];
+ while (e < pattern.events.length && pattern.events[e].tick < end) {
+ if (pattern.events[e].tick >= start) events.push(pattern.events[e]);
+ last = pattern.events[e];
+ e++;
+ }
+ const heldLane =
+ events.length === 0 && last && last.tick < start && last.tick + last.durationTicks > start ? laneIdOf(last.target) : null;
+ columns.push({ unit, events, lane: events.length > 0 ? laneIdOf(events[0].target) : null, heldLane });
+ }
+ return columns;
+};
+
+/** Target for a toggle in `lane`: the cell's own event target when it is in this lane (so it clears). */
+export const toggleTargetFor = (lane: RhythmLane, column: GridColumn): HitTarget =>
+ column.lane === lane.id && column.events[0] ? { ...column.events[0].target } : { ...lane.target };
+
+const ACCENT_WORDS = ['', 'accent', 'strong accent'] as const;
+
+/**
+ * Screen-reader label for one cell, e.g. "Bar 1, beat 2, e, pedal, palm muted, accent, down",
+ * "Bar 1, beat 1, slot 1, dyad, strong accent, down" or "Bar 1, beat 2, e, pedal, rest".
+ */
+export const describeCell = (position: UnitPosition, lane: RhythmLane, column: GridColumn): string => {
+ const parts = [`Bar ${position.bar}`, `beat ${position.beat}`];
+ if (position.sub) parts.push(position.sub);
+ parts.push(lane.spoken);
+ const first = column.events[0];
+ if (!first || column.lane !== lane.id) {
+ if (column.heldLane === lane.id) parts.push('held');
+ else parts.push(column.lane === null && column.heldLane === null ? 'rest' : 'empty');
+ return parts.join(', ');
+ }
+ if (first.target.kind === 'dyad') parts.push('dyad');
+ if (first.tie) {
+ parts.push('tied');
+ } else {
+ if (first.palmMute) parts.push('palm muted');
+ if (first.accent > 0) parts.push(ACCENT_WORDS[first.accent]);
+ parts.push(first.pick);
+ }
+ if (column.events.length > 1) parts.push(`${column.events.length} notes`);
+ return parts.join(', ');
+};
diff --git a/utils/urlState.test.ts b/utils/urlState.test.ts
new file mode 100644
index 0000000..49f7f7d
--- /dev/null
+++ b/utils/urlState.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { parseUrlState, syncUrlState } from './urlState';
+import { TuningMode, VibeMode } from '../types';
+
+// Minimal window.location + history for the node test environment
+const stubWindow = (search: string) => {
+ const location = { pathname: '/', search };
+ const replaceState = vi.fn((_state: unknown, _title: string, url: string) => {
+ location.search = url.slice(url.indexOf('?'));
+ });
+ vi.stubGlobal('window', { location, history: { replaceState } });
+ return { location, replaceState };
+};
+
+const queryOf = (search: string) => Object.fromEntries(new URLSearchParams(search));
+
+beforeEach(() => {
+ vi.unstubAllGlobals();
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('parseUrlState', () => {
+ it('returns nothing without a window', () => {
+ expect(parseUrlState()).toEqual({});
+ });
+
+ it('parses root, tuning, vibe and the finder view', () => {
+ stubWindow('?root=e&tuning=drop&vibe=dark&view=finder');
+ expect(parseUrlState()).toEqual({ root: 'E', tuning: TuningMode.DROP, vibe: VibeMode.DARK, view: 'finder' });
+ });
+
+ it('accepts the rhythm view and ignores case', () => {
+ stubWindow('?view=Rhythm');
+ expect(parseUrlState()).toEqual({ view: 'rhythm' });
+ });
+
+ it('drops invalid values, including unknown views and an explicit library view', () => {
+ stubWindow('?root=H&tuning=open&vibe=happy&view=settings');
+ expect(parseUrlState()).toEqual({});
+ stubWindow('?view=library');
+ expect(parseUrlState()).toEqual({});
+ });
+
+ it('keeps the old behaviour without a view param', () => {
+ stubWindow('?root=C%23&tuning=standard&vibe=melodic');
+ expect(parseUrlState()).toEqual({ root: 'C#', tuning: TuningMode.STANDARD, vibe: VibeMode.MELODIC });
+ });
+});
+
+describe('syncUrlState', () => {
+ it('writes root, tuning and vibe exactly as before and omits the library view', () => {
+ const { location, replaceState } = stubWindow('');
+ syncUrlState('F#', TuningMode.DROP, VibeMode.ENERGETIC);
+ expect(replaceState).toHaveBeenCalledTimes(1);
+ expect(queryOf(location.search)).toEqual({ root: 'F#', tuning: 'drop', vibe: 'energetic' });
+ });
+
+ it('writes view=finder for the finder', () => {
+ const { location } = stubWindow('?root=E&tuning=drop');
+ syncUrlState('E', TuningMode.DROP, VibeMode.MELODIC, 'finder');
+ expect(queryOf(location.search)).toEqual({ root: 'E', tuning: 'drop', vibe: 'melodic', view: 'finder' });
+ });
+
+ it('removes the view param when switching back to the library', () => {
+ const { location } = stubWindow('?root=E&tuning=drop&view=finder');
+ syncUrlState('E', TuningMode.DROP, VibeMode.MELODIC, 'library');
+ expect(queryOf(location.search)).toEqual({ root: 'E', tuning: 'drop', vibe: 'melodic' });
+ });
+
+ it('keeps unrelated params and round-trips through parseUrlState', () => {
+ const { location } = stubWindow('?utm_source=forum');
+ syncUrlState('A#', TuningMode.STANDARD, VibeMode.DARK, 'finder');
+ expect(queryOf(location.search).utm_source).toBe('forum');
+ expect(parseUrlState()).toEqual({ root: 'A#', tuning: TuningMode.STANDARD, vibe: VibeMode.DARK, view: 'finder' });
+ });
+});
diff --git a/utils/urlState.ts b/utils/urlState.ts
index 40b719a..db1ec15 100644
--- a/utils/urlState.ts
+++ b/utils/urlState.ts
@@ -12,13 +12,19 @@ const VIBE_PARAMS: Record = {
energetic: VibeMode.ENERGETIC
};
+/** Top-level workbench views. 'library' is the default and never written to the URL. */
+export type AppView = 'library' | 'finder' | 'rhythm';
+
+const VIEW_PARAMS: readonly AppView[] = ['finder', 'rhythm'];
+
export interface UrlState {
root?: string;
tuning?: TuningMode;
vibe?: VibeMode;
+ view?: AppView;
}
-/** Parses ?root=C&tuning=drop&vibe=dark from the current URL. Invalid values are dropped. */
+/** Parses ?root=C&tuning=drop&vibe=dark&view=finder from the current URL. Invalid values are dropped. */
export const parseUrlState = (): UrlState => {
if (typeof window === 'undefined') return {};
@@ -40,17 +46,27 @@ export const parseUrlState = (): UrlState => {
state.vibe = VIBE_PARAMS[vibe];
}
+ const view = params.get('view')?.toLowerCase();
+ if (view && (VIEW_PARAMS as readonly string[]).includes(view)) {
+ state.view = view as AppView;
+ }
+
return state;
};
-/** Writes the current selection to the query string without a navigation. */
-export const syncUrlState = (root: string, tuning: TuningMode, vibe: VibeMode): void => {
+/** Writes the current selection to the query string without a navigation. The library view omits `view`. */
+export const syncUrlState = (root: string, tuning: TuningMode, vibe: VibeMode, view: AppView = 'library'): void => {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
params.set('root', root);
params.set('tuning', tuning === TuningMode.DROP ? 'drop' : 'standard');
params.set('vibe', vibe.toLowerCase());
+ if (view === 'library') {
+ params.delete('view');
+ } else {
+ params.set('view', view);
+ }
window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
};