mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Compare commits
8 Commits
fe153a777a
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 71271387ab | |||
| 56ace9efac | |||
| 05d5548d0c | |||
| 7a030f2728 | |||
| e43a17efb3 | |||
| c1f8f46cfe | |||
| ef96258e53 | |||
| baa66c0dc7 |
@@ -1,5 +1,7 @@
|
||||
# Local deployment (nyx server) — beta channel
|
||||
- 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`
|
||||
- Tailnet URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081)
|
||||
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image`
|
||||
- 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
|
||||
|
||||
38
src/App.css
38
src/App.css
@@ -1041,29 +1041,53 @@ button {
|
||||
|
||||
.phrase-setup {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||
padding-bottom: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-text-main) 18%, transparent);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--color-text-main) 4%, transparent);
|
||||
}
|
||||
|
||||
.phrase-setup summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-main);
|
||||
padding: 12px 14px;
|
||||
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 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px 14px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
.phrase-setup-grid label {
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -277,11 +277,19 @@ function App() {
|
||||
|
||||
<div className="header-controls">
|
||||
<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
|
||||
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
|
||||
title="Learn to read notes from the staff"
|
||||
>
|
||||
Sight Reading
|
||||
Learn Notes
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
|
||||
@@ -296,13 +304,6 @@ function App() {
|
||||
>
|
||||
Phrases
|
||||
</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>
|
||||
|
||||
<button
|
||||
@@ -373,13 +374,17 @@ function App() {
|
||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||
</button>
|
||||
|
||||
<LiveNoteStaff
|
||||
midi={displayedPitch ? displayedPitch.midi : null}
|
||||
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
||||
keySignature={settings.keySignature}
|
||||
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
{/* Live staff omitted in Assist mode: the Assist card already
|
||||
shows the played note large above the fretboard. */}
|
||||
{!isAssist && (
|
||||
<LiveNoteStaff
|
||||
midi={displayedPitch ? displayedPitch.midi : null}
|
||||
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' : ''}`}>
|
||||
{displayedPitch ? (
|
||||
<>
|
||||
|
||||
@@ -61,6 +61,7 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
||||
keySignature={settings.keySignature}
|
||||
clef={clef === 'grand' ? undefined : clef}
|
||||
width={Math.min(240, staffWidth)}
|
||||
height={88}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -68,8 +68,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
{shouldShowFilters && <TargetNoteControls />}
|
||||
|
||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||
{/* Bottom Section: Tools Grid (stacked in all modes) */}
|
||||
<div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
|
||||
{/* 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', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
|
||||
@@ -21,14 +21,19 @@ interface LiveNoteStaffProps {
|
||||
* clef auto-switches treble/bass around middle C (piano-style). */
|
||||
clef?: 'treble' | 'bass';
|
||||
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';
|
||||
}
|
||||
|
||||
const BOX_H = 64; // mic button height — vertical centering via the bar
|
||||
// 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
|
||||
// the 64px box the constructor y must be negative.
|
||||
const STAVE_Y = -28;
|
||||
// (default space_above_staff_ln = 4 line-spaces). To center the ~38px staff
|
||||
// in a box of height h the constructor y must be negative. For h = 64 this
|
||||
// yields -27.5 ≈ -28 (the original hardcoded value).
|
||||
const staveYFor = (h: number) => (h - 38) / 2 - 40.5;
|
||||
|
||||
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
midi,
|
||||
@@ -36,6 +41,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
keySignature = 'C',
|
||||
clef: clefOverride,
|
||||
width = 130,
|
||||
height: boxH = BOX_H,
|
||||
theme = 'auto',
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -53,7 +59,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
container.innerHTML = '';
|
||||
|
||||
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
||||
renderer.resize(width, BOX_H);
|
||||
renderer.resize(width, boxH);
|
||||
const context = renderer.getContext();
|
||||
|
||||
const resolvedColor =
|
||||
@@ -63,7 +69,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
context.setFillStyle(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 });
|
||||
const written0 = midi !== null ? midi + transpose : null;
|
||||
// Instrument-fixed clef when provided (guitar always treble, bass
|
||||
@@ -119,7 +125,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="live-note-staff"
|
||||
style={{ width, height: BOX_H }}
|
||||
style={{ width, height: boxH }}
|
||||
role="img"
|
||||
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
|
||||
/>
|
||||
|
||||
@@ -55,13 +55,22 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
|
||||
const isSync = syncMode === 'option' && !!rhythm.syncToExercise;
|
||||
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
|
||||
// is the click source; the widget's own scheduler stays off).
|
||||
// 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({
|
||||
bpm: rhythm.bpm,
|
||||
bpm: effectiveBpm,
|
||||
volume: rhythm.sound ? rhythm.volume : 0,
|
||||
playing: running && !isSync,
|
||||
onTick: handleTick,
|
||||
@@ -77,8 +86,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||
});
|
||||
}, [isSync, runActive]);
|
||||
|
||||
const beatMs = 60000 / Math.max(1, rhythm.bpm);
|
||||
const armed = rhythm.active;
|
||||
const beatMs = 60000 / Math.max(1, effectiveBpm);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
|
||||
@@ -85,13 +85,15 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
|
||||
phrase.pace = pace;
|
||||
// Click decision — ONE source: the metronome widget's sound checkbox.
|
||||
// - Sync (tempo): run clicks when armed && sound (count-in incl.);
|
||||
// widget silent while idle.
|
||||
// - Free metronome: widget clicks freely; the run stays silent to
|
||||
// avoid doubling (sync off in tempo = free widget during runs).
|
||||
phrase.clickSound = pace === 'tempo'
|
||||
? settings.rhythm.active && settings.rhythm.sound
|
||||
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
|
||||
// Phrase mode has no metronome on/off switch (the sync checkbox
|
||||
// replaces it, see MetronomeWidget), so rhythm.active must NOT gate
|
||||
// clicks here — with the default active:false a synced run would
|
||||
// stay silent with no way to fix it from this mode.
|
||||
// - Sync (tempo): run clicks when sound is on (count-in included);
|
||||
// the widget stays silent while idle.
|
||||
// - 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>) => {
|
||||
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
|
||||
}, [updateSettings]);
|
||||
|
||||
@@ -149,38 +149,57 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
|
||||
)}
|
||||
|
||||
{!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
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||
className="hint-button"
|
||||
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
||||
title="Keyboard Shortcut: P or R"
|
||||
>
|
||||
<Guitar size={18} />
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
<Volume2 size={18} />
|
||||
Play Note
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||
title="Keyboard Shortcut: H"
|
||||
>
|
||||
<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>
|
||||
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
||||
<SkipForward size={18} />
|
||||
Skip Note
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -85,7 +85,11 @@ export const useGameLogic = (
|
||||
|
||||
const { restart: restartMetronome } = useMetronome({
|
||||
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,
|
||||
onTick: handleTick
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
|
||||
|
||||
const playClick = useCallback((time: number) => {
|
||||
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 gainNode = audioContext.current.createGain();
|
||||
|
||||
@@ -134,6 +134,11 @@ export interface AppSettings {
|
||||
instrument: string;
|
||||
rhythm: RhythmSettings;
|
||||
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';
|
||||
customMinFret?: number;
|
||||
customMaxFret?: number;
|
||||
|
||||
Reference in New Issue
Block a user