4 Commits

Author SHA1 Message Date
dfd8682500 Assist: drop flickering hint paragraph; center tuner in its frame
The 'Play a note' paragraph toggled with every detection gap and shifted
the layout — removed (incl. unused .assist-hint rule). TuningMeter is a
fixed-width 260px block; the tuner tool's body row now centers it.
2026-09-20 00:56:15 +02:00
751c3eec2f 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).
2026-09-20 00:51:35 +02:00
cc75141430 Docs: smt-beta deploys main (production state) by default 2026-09-20 00:32:09 +02:00
206d4d63cc Merge feature/mode-labels-reorder: mode reorder/rename, UI tidy-ups, metronome fixes 2026-09-20 00:31:31 +02:00
5 changed files with 74 additions and 31 deletions

View File

@@ -1,5 +1,5 @@
# Local deployment (nyx server) — beta channel # Local deployment (nyx server) — beta channel (tracks main = production state by default)
- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart) - Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart; **default branch: main**)
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image` - Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image`
- Tailnet URLs: - Tailnet URLs:
- http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081; needs MagicDNS; Chrome history may autocomplete a broken https:// variant — delete it from history) - http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081; needs MagicDNS; Chrome history may autocomplete a broken https:// variant — delete it from history)

View File

@@ -1190,13 +1190,6 @@ button {
overflow-x: auto; overflow-x: auto;
} }
.assist-hint {
font-size: 12px;
color: var(--color-text-muted);
margin: 0;
text-align: center;
}
/* ---- Transport step group (preview stepping) ---------------------------- */ /* ---- Transport step group (preview stepping) ---------------------------- */
.transport-step-group { .transport-step-group {
display: flex; display: flex;

View File

@@ -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,19 +91,12 @@ 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}
/> />
</div> </div>
)} )}
{!pitchData && (
<p className="assist-hint">
Play a note — it shows here. Mic is{' '}
{settings.showTuningMeter ? 'on' : 'controlled by the mic button below'}.
</p>
)}
</div> </div>
); );
}; };

View File

@@ -86,7 +86,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
</button> </button>
</div> </div>
{settings.showTuningMeter && ( {settings.showTuningMeter && (
<div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)' }}> <div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)', display: 'flex', justifyContent: 'center' }}>
<TuningMeter <TuningMeter
cents={currentPitch ? currentPitch.cents : null} cents={currentPitch ? currentPitch.cents : null}
noteName={currentPitch ? currentPitch.note : null} noteName={currentPitch ? currentPitch.note : null}

View File

@@ -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,13 +119,17 @@ 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 (clef === 'treble') { if (!range) {
while (written < 58) { written += 12; octaveMark = '8vb'; } if (clef === 'treble') {
while (written > 81) { written -= 12; octaveMark = '8va'; } while (written < 58) { written += 12; octaveMark = '8vb'; }
} else { while (written > 81) { written -= 12; octaveMark = '8va'; }
while (written < 40) { written += 12; octaveMark = '8vb'; } } else {
while (written > 61) { written -= 12; octaveMark = '8va'; } while (written < 40) { written += 12; octaveMark = '8vb'; }
while (written > 61) { written -= 12; octaveMark = '8va'; }
}
} }
const spec = getNoteInKey(written, keySignature); const spec = getNoteInKey(written, keySignature);
@@ -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