diff --git a/src/components/AssistMode.tsx b/src/components/AssistMode.tsx index 1100ab0..b8d82a8 100644 --- a/src/components/AssistMode.tsx +++ b/src/components/AssistMode.tsx @@ -30,6 +30,10 @@ export const AssistMode: React.FC = ({ pitchData, windowWidth } const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning; const pf = getPracticeFilter(settings); + const { clef, transpose } = useMemo(() => + resolveClefTranspose(currentInstrumentDef, settings.difficulty), + [currentInstrumentDef, settings.difficulty]); + // Alternative positions for the detected note (respect fret window filter) const positions = useMemo(() => { if (!fretted || !currentTuning || !pitchData) return []; @@ -38,9 +42,23 @@ export const AssistMode: React.FC = ({ pitchData, windowWidth } return all.filter(p => p.fret >= Math.max(0, pf.fretMin) && p.fret <= Math.min(24, pf.fretMax)); }, [fretted, currentTuning, pitchData, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]); - const { clef, transpose } = useMemo(() => - resolveClefTranspose(currentInstrumentDef, settings.difficulty), - [currentInstrumentDef, settings.difficulty]); + // Full written range of the currently configured pool (strings × frets, + // respecting the same filter as the position markers). The staff box is + // sized to fit it, so notes render at their TRUE written position with + // real ledger lines — no 8va/8vb octave shifting. + const range = useMemo(() => { + if (!fretted || !currentTuning) return undefined; + const strings = pf.strings.length > 0 + ? currentTuning.strings.filter((_, i) => pf.strings.includes(i)) + : currentTuning.strings; + if (strings.length === 0) return undefined; + const lo = pf.fretWindowEnabled ? Math.max(0, pf.fretMin) : 0; + const hi = pf.fretWindowEnabled ? Math.min(24, pf.fretMax) : 24; + return { + min: Math.min(...strings) + lo + transpose, + max: Math.max(...strings) + hi + transpose, + }; + }, [fretted, currentTuning, pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, transpose]); const staffWidth = Math.min(windowWidth - 40, 860); @@ -62,6 +80,7 @@ export const AssistMode: React.FC = ({ pitchData, windowWidth } clef={clef === 'grand' ? undefined : clef} width={Math.min(240, staffWidth)} height={88} + range={range} theme={settings.theme} /> @@ -72,7 +91,7 @@ export const AssistMode: React.FC = ({ pitchData, windowWidth } tuning={currentTuning} positions={positions} maxFrets={15} - showHints={false} + showHints={true} interactive={false} displayTranspose={transpose} /> diff --git a/src/components/LiveNoteStaff.tsx b/src/components/LiveNoteStaff.tsx index ee104d7..c2e2080 100644 --- a/src/components/LiveNoteStaff.tsx +++ b/src/components/LiveNoteStaff.tsx @@ -23,8 +23,13 @@ interface LiveNoteStaffProps { width?: number; /** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in * Assist mode) give ledger lines room inside instead of being clipped - * by the parent's overflow: hidden. */ + * by the parent's overflow: hidden. Ignored when `range` is set. */ height?: number; + /** Written-pitch range the box must fit WITHOUT octave shifting. + * When set, notes render at their true staff position with real ledger + * lines (no 8va/8vb marker) and the box height is computed from the + * range so every note in it stays inside the box. */ + range?: { min: number; max: number }; theme?: 'light' | 'dark' | 'auto'; } @@ -35,16 +40,45 @@ const BOX_H = 64; // mic button height — vertical centering via the bar // yields -27.5 ≈ -28 (the original hardcoded value). const staveYFor = (h: number) => (h - 38) / 2 - 40.5; +// Diatonic index (C0 = 0, one step per letter name) for ledger-line math. +const DI_STEP = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const diFromMidi = (m: number) => Math.floor(m / 12 - 1) * 7 + DI_STEP[m % 12]; +// Top/bottom staff-line diatonic index per clef (treble F5/E4, bass A3/G2). +const CLEF_LINES: Record<'treble' | 'bass', { top: number; bottom: number }> = { + treble: { top: 38, bottom: 30 }, + bass: { top: 26, bottom: 18 }, +}; +// One diatonic step = half a staff space = 5px (standard 10px spaces). +// Box that fits a whole written range: staff anchored below the top +// headroom so the highest note has room for its ledger lines, lowest note +// the same at the bottom. +const rangeLayout = (range: { min: number; max: number }, clef: 'treble' | 'bass') => { + const lines = CLEF_LINES[clef]; + const aboveLines = Math.max(0, Math.ceil((diFromMidi(range.max) - lines.top) / 2)); + const belowLines = Math.max(0, Math.ceil((lines.bottom - diFromMidi(range.min)) / 2)); + const abovePx = aboveLines * 10 + 10; // + notehead margin + const belowPx = belowLines * 10 + 10; + return { height: abovePx + 38 + belowPx, y: abovePx - 40.5 }; +}; + export const LiveNoteStaff: React.FC = ({ midi, transpose = 0, keySignature = 'C', clef: clefOverride, width = 130, - height: boxH = BOX_H, + height = BOX_H, + range, theme = 'auto', }) => { const containerRef = useRef(null); + + // Box geometry: range-fit wins over the fixed height prop. + const fit = range + ? rangeLayout(range, clefOverride ?? 'treble') + : { height, y: staveYFor(height) }; + const boxH = fit.height; + const staveY = fit.y; const [schemeVersion, setSchemeVersion] = useState(0); useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)'); @@ -69,7 +103,7 @@ export const LiveNoteStaff: React.FC = ({ context.setFillStyle(resolvedColor); context.setStrokeStyle(resolvedColor); - const stave = new Stave(0, staveYFor(boxH), width - 2); + const stave = new Stave(0, staveY, width - 2); stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 }); const written0 = midi !== null ? midi + transpose : null; // Instrument-fixed clef when provided (guitar always treble, bass @@ -85,13 +119,17 @@ export const LiveNoteStaff: React.FC = ({ // treble: staff E4..F5 (+ up to two ledger lines each way) // bass: staff G2..A3 (+ up to two ledger lines each way) let written = written0!; + // Range-fit mode renders the TRUE written position with real + // ledger lines — no 8va/8vb shifting. let octaveMark: '8va' | '8vb' | null = null; - if (clef === 'treble') { - while (written < 58) { written += 12; octaveMark = '8vb'; } - while (written > 81) { written -= 12; octaveMark = '8va'; } - } else { - while (written < 40) { written += 12; octaveMark = '8vb'; } - while (written > 61) { written -= 12; octaveMark = '8va'; } + if (!range) { + if (clef === 'treble') { + while (written < 58) { written += 12; octaveMark = '8vb'; } + while (written > 81) { written -= 12; octaveMark = '8va'; } + } else { + while (written < 40) { written += 12; octaveMark = '8vb'; } + while (written > 61) { written -= 12; octaveMark = '8va'; } + } } const spec = getNoteInKey(written, keySignature); @@ -119,7 +157,7 @@ export const LiveNoteStaff: React.FC = ({ new Formatter().joinVoices([voice]).format([voice], width - 40); voice.draw(context, stave); } - }, [midi, transpose, keySignature, clefOverride, width, theme, schemeVersion]); + }, [midi, transpose, keySignature, clefOverride, width, boxH, staveY, range, theme, schemeVersion]); return (