9 Commits

Author SHA1 Message Date
71271387ab Fix double metronome click in single-note modes
useGameLogic's own scheduler and the footer widget's scheduler both
played audible clicks while rhythm.active — assist->learn-notes switch
made the second one mount. Now the widget is the single click source in
non-zen modes; useGameLogic's scheduler is a silent tick source for
auto-advance (full sound only in zen mode, where the widget isn't
rendered). Also: widget honors Timer mode (60/seconds) like the trainer,
and useMetronome skips audio nodes at volume 0.
2026-09-20 00:27:59 +02:00
56ace9efac Assist mode: taller live-note staff box (88px)
The Assist card wrapped the LiveNoteStaff in .sheet-music-container,
which clips (overflow: hidden) — extreme ledger lines poked out of the
64px box by a few px. LiveNoteStaff now takes a height prop; stave y is
computed as (h-38)/2 - 40.5 (equals the old -28 for h=64). Assist uses
88px; mic bar keeps 64px.
2026-09-20 00:25:36 +02:00
05d5548d0c Phrase setup panel: prominent collapsed header
The collapsed Setup row was a small muted 13px text link (plus the
default disclosure triangle next to our chevron). Now a proper bar:
bordered/rounded, 15px semibold, hover feedback, chevron rotates on
open, default marker hidden.
2026-09-20 00:20:58 +02:00
7a030f2728 Single-note modes: switch-style display toggles (like phrase mode)
Guitar/Piano and Hint show/hide controls in Learn Notes / Ear Training
now use the small switch buttons in a .display-toggles row instead of
the big hint-buttons. Play Note / Skip Note stay as action buttons.
2026-09-20 00:15:55 +02:00
e43a17efb3 Footer tools stacked in all modes; fix silent phrase-mode metronome
Layout: tuner + metronome now stack vertically everywhere (was phrase-only).

Phrase metronome bug: clicks were gated on rhythm.active, but phrase mode
has no on/off switch (the sync checkbox replaces it) and the default is
active:false — so a synced run stayed silent with no in-mode remedy.
Now: phrase mode is always armed (sync checkbox = free vs synced,
sound checkbox = clicks), and phrase clickSound reads rhythm.sound only.
Single-note modes keep the rhythm.active on/off semantics.
2026-09-20 00:10:37 +02:00
c1f8f46cfe Assist mode: drop duplicate live-note staff from mic bar
The Assist card already shows the played note large (staff + fretboard);
the pitch-monitor-bar staff duplicated it. Text readout (note + Hz) stays.
2026-09-20 00:04:16 +02:00
ef96258e53 Docs: beta now also on tailnet IP:8081 (avoids Chrome HTTPS-upgrade) 2026-09-20 00:02:40 +02:00
baa66c0dc7 UI: reorder game modes (Assist first), rename Sight Reading -> Learn Notes
Internal key 'sight_reading' kept for localStorage compatibility;
TODO added at gameMode type def for future rename + migration.
2026-09-19 23:45:53 +02:00
OpenClaw
fe153a777a Merge feature/ui-reorg: UI reorg, setup panel, transport controls, preview pause/stepping 2026-09-18 14:49:53 +00:00
12 changed files with 151 additions and 73 deletions

View File

@@ -1,5 +1,7 @@
# Local deployment (nyx server) — beta channel # Local deployment (nyx server) — beta channel
- 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)
- Container: `smt-beta` (nginx:alpine, 127.0.0.1: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 URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081) - 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://100.64.0.4:8081/ (preferred: Chrome never HTTPS-upgrades IP:port URLs, no MagicDNS needed)**
- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only - Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only

View File

@@ -1041,29 +1041,53 @@ button {
.phrase-setup { .phrase-setup {
margin-bottom: 16px; margin-bottom: 16px;
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); border: 1px solid color-mix(in srgb, var(--color-text-main) 18%, transparent);
padding-bottom: 12px; border-radius: 10px;
background: color-mix(in srgb, var(--color-text-main) 4%, transparent);
} }
.phrase-setup summary { .phrase-setup summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 15px;
color: var(--color-text-muted); font-weight: 600;
color: var(--color-text-main);
padding: 12px 14px;
user-select: none; user-select: none;
list-style: none;
border-radius: 10px;
transition: background 0.15s ease;
}
/* Hide the default disclosure triangle — the chevron icon does the job */
.phrase-setup summary::-webkit-details-marker {
display: none;
} }
.phrase-setup summary:hover { .phrase-setup summary:hover {
color: var(--color-text-main); background: color-mix(in srgb, var(--color-text-main) 7%, transparent);
}
.phrase-setup[open] summary {
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
border-radius: 10px 10px 0 0;
}
.phrase-setup summary svg {
transition: transform 0.15s ease;
}
.phrase-setup[open] summary svg {
transform: rotate(180deg);
} }
.phrase-setup-grid { .phrase-setup-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px 14px; gap: 10px 14px;
margin-top: 12px; padding: 12px 14px 14px;
} }
.phrase-setup-grid label { .phrase-setup-grid label {

View File

@@ -277,11 +277,19 @@ function App() {
<div className="header-controls"> <div className="header-controls">
<div className="game-mode-toggle"> <div className="game-mode-toggle">
<button
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
title="Practice companion: shows played notes while you work from paper"
>
Assist
</button>
<button <button
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`} className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))} onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
title="Learn to read notes from the staff"
> >
Sight Reading Learn Notes
</button> </button>
<button <button
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`} className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
@@ -296,13 +304,6 @@ function App() {
> >
Phrases Phrases
</button> </button>
<button
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
title="Practice companion: shows played notes while you work from paper"
>
Assist
</button>
</div> </div>
<button <button
@@ -373,13 +374,17 @@ function App() {
{listening ? <Mic size={28} /> : <MicOff size={28} />} {listening ? <Mic size={28} /> : <MicOff size={28} />}
</button> </button>
<LiveNoteStaff {/* Live staff omitted in Assist mode: the Assist card already
midi={displayedPitch ? displayedPitch.midi : null} shows the played note large above the fretboard. */}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} {!isAssist && (
keySignature={settings.keySignature} <LiveNoteStaff
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined} midi={displayedPitch ? displayedPitch.midi : null}
theme={settings.theme} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
/> keySignature={settings.keySignature}
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
theme={settings.theme}
/>
)}
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}> <div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
{displayedPitch ? ( {displayedPitch ? (
<> <>

View File

@@ -61,6 +61,7 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
keySignature={settings.keySignature} keySignature={settings.keySignature}
clef={clef === 'grand' ? undefined : clef} clef={clef === 'grand' ? undefined : clef}
width={Math.min(240, staffWidth)} width={Math.min(240, staffWidth)}
height={88}
theme={settings.theme} theme={settings.theme}
/> />
</div> </div>

View File

@@ -68,8 +68,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)} )}
{shouldShowFilters && <TargetNoteControls />} {shouldShowFilters && <TargetNoteControls />}
{/* Bottom Section: Tools Grid (2 Columns now) */} {/* Bottom Section: Tools Grid (stacked in all modes) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}> <div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
{/* Tool 1: Tuner */} {/* Tool 1: Tuner */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>

View File

@@ -21,14 +21,19 @@ interface LiveNoteStaffProps {
* clef auto-switches treble/bass around middle C (piano-style). */ * clef auto-switches treble/bass around middle C (piano-style). */
clef?: 'treble' | 'bass'; clef?: 'treble' | 'bass';
width?: number; 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. */
height?: number;
theme?: 'light' | 'dark' | 'auto'; theme?: 'light' | 'dark' | 'auto';
} }
const BOX_H = 64; // mic button height — vertical centering via the bar const BOX_H = 64; // mic button height — vertical centering via the bar
// VexFlow draws the first stave line ~40.5px BELOW the constructor y // VexFlow draws the first stave line ~40.5px BELOW the constructor y
// (default space_above_staff_ln = 4 line-spaces). To center a 38px staff in // (default space_above_staff_ln = 4 line-spaces). To center the ~38px staff
// the 64px box the constructor y must be negative. // in a box of height h the constructor y must be negative. For h = 64 this
const STAVE_Y = -28; // yields -27.5 ≈ -28 (the original hardcoded value).
const staveYFor = (h: number) => (h - 38) / 2 - 40.5;
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
midi, midi,
@@ -36,6 +41,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
keySignature = 'C', keySignature = 'C',
clef: clefOverride, clef: clefOverride,
width = 130, width = 130,
height: boxH = BOX_H,
theme = 'auto', theme = 'auto',
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -53,7 +59,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
container.innerHTML = ''; container.innerHTML = '';
const renderer = new Renderer(container, Renderer.Backends.SVG); const renderer = new Renderer(container, Renderer.Backends.SVG);
renderer.resize(width, BOX_H); renderer.resize(width, boxH);
const context = renderer.getContext(); const context = renderer.getContext();
const resolvedColor = const resolvedColor =
@@ -63,7 +69,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
context.setFillStyle(resolvedColor); context.setFillStyle(resolvedColor);
context.setStrokeStyle(resolvedColor); context.setStrokeStyle(resolvedColor);
const stave = new Stave(0, STAVE_Y, width - 2); const stave = new Stave(0, staveYFor(boxH), 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
@@ -119,7 +125,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
<div <div
ref={containerRef} ref={containerRef}
className="live-note-staff" className="live-note-staff"
style={{ width, height: BOX_H }} style={{ width, height: boxH }}
role="img" role="img"
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'} aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
/> />

View File

@@ -55,13 +55,22 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
const isSync = syncMode === 'option' && !!rhythm.syncToExercise; const isSync = syncMode === 'option' && !!rhythm.syncToExercise;
const runActive = gate === true; const runActive = gate === true;
// Phrase mode (syncMode 'option') has no on/off switch — the sync
// checkbox replaces it (unchecked = free metronome, checked = synced to
// the exercise), so the metronome is always armed there. Gating on
// rhythm.active would leave phrase users with no way to enable clicks.
const armed = syncMode === 'option' ? true : rhythm.active;
// Sync: pendulum/click only while the run is active (the run's scheduler // Sync: pendulum/click only while the run is active (the run's scheduler
// is the click source; the widget's own scheduler stays off). // is the click source; the widget's own scheduler stays off).
// Free/plain: the widget's own scheduler runs whenever armed. // Free/plain: the widget's own scheduler runs whenever armed.
const running = isSync ? runActive : rhythm.active; const running = isSync ? runActive : armed;
// Timer mode = one beat per N seconds: effective BPM 60/seconds so
// clicks and pendulum match the trainer's auto-advance cadence.
const effectiveBpm = rhythm.mode === 'bpm' ? rhythm.bpm : 60 / Math.max(1, rhythm.seconds);
const { restart } = useMetronome({ const { restart } = useMetronome({
bpm: rhythm.bpm, bpm: effectiveBpm,
volume: rhythm.sound ? rhythm.volume : 0, volume: rhythm.sound ? rhythm.volume : 0,
playing: running && !isSync, playing: running && !isSync,
onTick: handleTick, onTick: handleTick,
@@ -77,8 +86,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
}); });
}, [isSync, runActive]); }, [isSync, runActive]);
const beatMs = 60000 / Math.max(1, rhythm.bpm); const beatMs = 60000 / Math.max(1, effectiveBpm);
const armed = rhythm.active;
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>

View File

@@ -85,13 +85,15 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step'; const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
phrase.pace = pace; phrase.pace = pace;
// Click decision — ONE source: the metronome widget's sound checkbox. // Click decision — ONE source: the metronome widget's sound checkbox.
// - Sync (tempo): run clicks when armed && sound (count-in incl.); // Phrase mode has no metronome on/off switch (the sync checkbox
// widget silent while idle. // replaces it, see MetronomeWidget), so rhythm.active must NOT gate
// - Free metronome: widget clicks freely; the run stays silent to // clicks here — with the default active:false a synced run would
// avoid doubling (sync off in tempo = free widget during runs). // stay silent with no way to fix it from this mode.
phrase.clickSound = pace === 'tempo' // - Sync (tempo): run clicks when sound is on (count-in included);
? settings.rhythm.active && settings.rhythm.sound // the widget stays silent while idle.
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise; // - Free metronome (step): the widget clicks freely; the run stays
// silent to avoid doubling.
phrase.clickSound = settings.rhythm.sound;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => { const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
}, [updateSettings]); }, [updateSettings]);

View File

@@ -149,38 +149,57 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
)} )}
{!settings.zenMode && ( {!settings.zenMode && (
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}> <>
{currentInstrumentDef.showTuning && ( {/* Display toggles — switch style (like phrase mode):
pure show/hide, no trainer actions. */}
<div className="display-toggles" style={{ marginTop: '24px' }}>
{currentInstrumentDef.showTuning && (
<div className="display-toggle">
<span className="display-toggle-label">
<Guitar size={15} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</span>
<button
className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
>
<div className="switch-thumb" />
</button>
</div>
)}
<div className="display-toggle">
<span className="display-toggle-label">
<HelpCircle size={15} />
Hint
</span>
<button
className={`switch-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
aria-label="Show hint"
>
<div className="switch-thumb" />
</button>
</div>
</div>
<div className="action-row" style={{ marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<button <button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`} className="hint-button"
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))} onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`} title="Keyboard Shortcut: P or R"
> >
<Guitar size={18} /> <Volume2 size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'} Play Note
</button> </button>
)} <button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<button <SkipForward size={18} />
className={`hint-button ${settings.showHint ? 'active' : ''}`} Skip Note
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))} </button>
title="Keyboard Shortcut: H" </div>
> </>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
<button
className="hint-button"
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
title="Keyboard Shortcut: P or R"
>
<Volume2 size={18} />
Play Note
</button>
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<SkipForward size={18} />
Skip Note
</button>
</div>
)} )}
</div> </div>
); );

View File

@@ -85,7 +85,11 @@ export const useGameLogic = (
const { restart: restartMetronome } = useMetronome({ const { restart: restartMetronome } = useMetronome({
bpm: effectiveBpm, bpm: effectiveBpm,
volume: settings.rhythm.sound ? settings.rhythm.volume : 0, // Audible clicks come from the shared footer widget (Controls) in
// non-zen single-note modes — this scheduler is only the tick source
// for auto-advance. Sounding it here too doubles every beat. In zen
// mode the widget isn't rendered, so this scheduler IS the click.
volume: settings.zenMode && settings.rhythm.sound ? settings.rhythm.volume : 0,
playing: settings.rhythm.active, playing: settings.rhythm.active,
onTick: handleTick onTick: handleTick
}); });

View File

@@ -34,6 +34,8 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
const playClick = useCallback((time: number) => { const playClick = useCallback((time: number) => {
if (!audioContext.current) return; if (!audioContext.current) return;
onTickRef.current(currentBeat.current);
if (volume <= 0) return; // silent tick source — no audio nodes needed
const osc = audioContext.current.createOscillator(); const osc = audioContext.current.createOscillator();
const gainNode = audioContext.current.createGain(); const gainNode = audioContext.current.createGain();

View File

@@ -134,6 +134,11 @@ export interface AppSettings {
instrument: string; instrument: string;
rhythm: RhythmSettings; rhythm: RhythmSettings;
zenMode: boolean; zenMode: boolean;
/**
* TODO: Rename 'sight_reading' to 'learn_notes' internally (UI label is
* already "Learn Notes"). Deferred because the value is persisted in
* localStorage — needs a settings migration for existing users.
*/
gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist'; gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;