mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Assist: true staff positions with ledger lines + fretboard note markers
- LiveNoteStaff: new range prop. With a written range set, notes render at their TRUE staff position with real ledger lines (no 8va/8vb shifting) and the box height is computed to fit the whole range (treble F5/E4, bass A3/G2 diatonic math; staff anchored asymmetrically below the top headroom). Mic bar keeps compact octave-marker behavior. - AssistMode: passes the written range of the current pool (strings x frets, honoring the note filter) — guitar high notes now show with ledger lines instead of being octave-shifted. - Assist fretboard bug: positions (played-note markers) were gated on showHints which Assist passed as false — they never rendered. Now always on (non-interactive, no tooltips).
This commit is contained in:
@@ -30,6 +30,10 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
|
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
|
||||||
const pf = getPracticeFilter(settings);
|
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)
|
// Alternative positions for the detected note (respect fret window filter)
|
||||||
const positions = useMemo(() => {
|
const positions = useMemo(() => {
|
||||||
if (!fretted || !currentTuning || !pitchData) return [];
|
if (!fretted || !currentTuning || !pitchData) return [];
|
||||||
@@ -38,9 +42,23 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
return all.filter(p => p.fret >= Math.max(0, pf.fretMin) && p.fret <= Math.min(24, pf.fretMax));
|
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]);
|
}, [fretted, currentTuning, pitchData, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
|
||||||
|
|
||||||
const { clef, transpose } = useMemo(() =>
|
// Full written range of the currently configured pool (strings × frets,
|
||||||
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
// respecting the same filter as the position markers). The staff box is
|
||||||
[currentInstrumentDef, settings.difficulty]);
|
// 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);
|
const staffWidth = Math.min(windowWidth - 40, 860);
|
||||||
|
|
||||||
@@ -62,6 +80,7 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
clef={clef === 'grand' ? undefined : clef}
|
clef={clef === 'grand' ? undefined : clef}
|
||||||
width={Math.min(240, staffWidth)}
|
width={Math.min(240, staffWidth)}
|
||||||
height={88}
|
height={88}
|
||||||
|
range={range}
|
||||||
theme={settings.theme}
|
theme={settings.theme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +91,7 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
tuning={currentTuning}
|
tuning={currentTuning}
|
||||||
positions={positions}
|
positions={positions}
|
||||||
maxFrets={15}
|
maxFrets={15}
|
||||||
showHints={false}
|
showHints={true}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
displayTranspose={transpose}
|
displayTranspose={transpose}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ interface LiveNoteStaffProps {
|
|||||||
width?: number;
|
width?: number;
|
||||||
/** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in
|
/** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in
|
||||||
* Assist mode) give ledger lines room inside instead of being clipped
|
* 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;
|
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';
|
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).
|
// yields -27.5 ≈ -28 (the original hardcoded value).
|
||||||
const staveYFor = (h: number) => (h - 38) / 2 - 40.5;
|
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<LiveNoteStaffProps> = ({
|
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||||
midi,
|
midi,
|
||||||
transpose = 0,
|
transpose = 0,
|
||||||
keySignature = 'C',
|
keySignature = 'C',
|
||||||
clef: clefOverride,
|
clef: clefOverride,
|
||||||
width = 130,
|
width = 130,
|
||||||
height: boxH = BOX_H,
|
height = BOX_H,
|
||||||
|
range,
|
||||||
theme = 'auto',
|
theme = 'auto',
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(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);
|
const [schemeVersion, setSchemeVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
@@ -69,7 +103,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
context.setFillStyle(resolvedColor);
|
context.setFillStyle(resolvedColor);
|
||||||
context.setStrokeStyle(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 });
|
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
|
||||||
const written0 = midi !== null ? midi + transpose : null;
|
const written0 = midi !== null ? midi + transpose : null;
|
||||||
// Instrument-fixed clef when provided (guitar always treble, bass
|
// Instrument-fixed clef when provided (guitar always treble, bass
|
||||||
@@ -85,7 +119,10 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
// treble: staff E4..F5 (+ up to two ledger lines each way)
|
// treble: staff E4..F5 (+ up to two ledger lines each way)
|
||||||
// bass: staff G2..A3 (+ up to two ledger lines each way)
|
// bass: staff G2..A3 (+ up to two ledger lines each way)
|
||||||
let written = written0!;
|
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;
|
let octaveMark: '8va' | '8vb' | null = null;
|
||||||
|
if (!range) {
|
||||||
if (clef === 'treble') {
|
if (clef === 'treble') {
|
||||||
while (written < 58) { written += 12; octaveMark = '8vb'; }
|
while (written < 58) { written += 12; octaveMark = '8vb'; }
|
||||||
while (written > 81) { written -= 12; octaveMark = '8va'; }
|
while (written > 81) { written -= 12; octaveMark = '8va'; }
|
||||||
@@ -93,6 +130,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
while (written < 40) { written += 12; octaveMark = '8vb'; }
|
while (written < 40) { written += 12; octaveMark = '8vb'; }
|
||||||
while (written > 61) { written -= 12; octaveMark = '8va'; }
|
while (written > 61) { written -= 12; octaveMark = '8va'; }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const spec = getNoteInKey(written, keySignature);
|
const spec = getNoteInKey(written, keySignature);
|
||||||
const note = new StaveNote({
|
const note = new StaveNote({
|
||||||
@@ -119,7 +157,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
||||||
voice.draw(context, stave);
|
voice.draw(context, stave);
|
||||||
}
|
}
|
||||||
}, [midi, transpose, keySignature, clefOverride, width, theme, schemeVersion]);
|
}, [midi, transpose, keySignature, clefOverride, width, boxH, staveY, range, theme, schemeVersion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user