5 Commits

Author SHA1 Message Date
OpenClaw
6b52a049e8 Restore text-entry gate for keyboard shortcuts (cleanup) 2026-09-15 21:33:33 +00:00
OpenClaw
97a9a83f68 Fix Next/New no-op for seeded material + keyboard shortcuts after button clicks
- nextAction: giuliani and single-chord arpeggios now roll a new seed
  too (they consume melodySeed); previously the handler silently did
  nothing for them. Scale drills keep no Next (deterministic).
- New/Next button visibility matches the same condition, and the label
  is 'Next' for fixed material only.
- Keyboard shortcuts: HTMLButtonElement removed from the interactive
  gate — after any mouse click the button keeps focus and previously
  swallowed N/R/P/H/L/V/M. Space/Enter on a focused button still
  activate it natively and no longer double-fire the global
  pauseToggle/skip handlers.
2026-09-15 21:32:27 +00:00
OpenClaw
b4ba788d8e Move Setup panel above notation; host note filter inside it; fix key signature semantics
- Phrase mode: Setup panel now sits between the title row and the
  sheet music (was below the transport). It hosts the material options
  plus the shared TargetNoteControls (key/strings/fret window).
- TargetNoteControls: in Phrase Mode the Key section binds to the
  generator key (tonic+mode always applied, no enable toggle) and the
  display-signature sync checkbox is hidden — phrase notation takes
  its signature from the score's key, so a display-signature select
  there would be a no-op.
- Footer filter button hidden in Phrase Mode (lives in Setup now);
  still present in single-note modes.
- Remove leftover '&& null' placeholder blocks.
2026-09-15 21:24:09 +00:00
OpenClaw
1ec1353c0b Add Assist mode: primitive note display for practicing from paper
New game mode 'assist' — no trainer, no scoring:
- Large display of the currently mic-detected note
- Live notation staff (same pipeline as the pitch monitor)
- Live fretboard showing alternative positions for the played note
  (respects the fret-window practice filter)
- Tuner and Metronome remain the shared footer tools, identical in
  all modes; user can practice own-pace from paper with a free
  metronome

Header mode toggle gains 'Assist'; 'm' shortcut cycles back to sight
reading from assist/phrase. Export PitchData type.
2026-09-15 21:11:21 +00:00
OpenClaw
b7c8187aef UI reorg: settings grouped by usage frequency
- Filter (key/strings/fret window) collapsed behind Filter button with
  active-count badge; opens the unified TargetNoteControls panel
- Display key signature moved into the filter panel (all modes)
- Instrument + Tuning selects moved into Settings modal (Instrument
  section); removed from footer
- Settings modal restructured into collapsible sections: Instrument,
  Appearance, Audio, Microphone
- Phrase setup panel: removed cross-reference hint texts
- PracticeFilter is now the single source of truth: removed mirrored
  keyTonic/keyMode/fretWindow fields from PhraseSettings
- Dark mode: neutral gray with slight blue tint (was warm brown)
2026-09-15 21:05:44 +00:00
10 changed files with 497 additions and 358 deletions

View File

@@ -1040,9 +1040,9 @@ button {
} }
.phrase-setup { .phrase-setup {
margin-top: 16px; margin-bottom: 16px;
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
padding-top: 12px; padding-bottom: 12px;
} }
.phrase-setup summary { .phrase-setup summary {
@@ -1126,3 +1126,49 @@ button {
justify-content: safe center; justify-content: safe center;
justify-content: center; justify-content: center;
} }
/* ---- Assist Mode ------------------------------------------------------- */
/* Primitive practice companion: big detected note + live staff + optional
fretboard with alternative positions. No trainer actions. */
.assist-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
.assist-note-area {
display: flex;
align-items: center;
justify-content: center;
min-height: 110px;
}
.assist-note-name {
font-size: 5rem;
font-weight: 800;
line-height: 1;
color: var(--color-text-main);
font-variant-emoji: text;
}
.assist-note-name.placeholder {
color: var(--color-text-muted);
opacity: 0.5;
}
.assist-fretboard {
width: 100%;
display: flex;
justify-content: center;
overflow-x: auto;
}
.assist-hint {
font-size: 12px;
color: var(--color-text-muted);
margin: 0;
text-align: center;
}

View File

@@ -8,6 +8,7 @@ import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNot
import { LiveNoteStaff } from './components/LiveNoteStaff'; import { LiveNoteStaff } from './components/LiveNoteStaff';
import { resolveClefTranspose } from './music/InstrumentConfigs'; import { resolveClefTranspose } from './music/InstrumentConfigs';
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer'; import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
import { AssistMode } from './components/AssistMode';
import { usePitchDetector } from './hooks/usePitchDetector'; import { usePitchDetector } from './hooks/usePitchDetector';
import { useSettings } from './context/useSettings'; import { useSettings } from './context/useSettings';
import { audioEngine } from './audio/AudioEngine'; import { audioEngine } from './audio/AudioEngine';
@@ -26,6 +27,7 @@ function App() {
const setSettings = updateSettings; const setSettings = updateSettings;
const isPhrase = settings.gameMode === 'phrase'; const isPhrase = settings.gameMode === 'phrase';
const isAssist = settings.gameMode === 'assist';
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
@@ -121,24 +123,28 @@ function App() {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
const onInteractive = // Text inputs / selects keep their native keys — shortcuts must not
// fire while typing. FOCUSED BUTTONS however stay eligible: after a
// click the button keeps focus, and gating there made N/R/P/L
// silently dead until the user clicked elsewhere. Space/Enter on a
// focused button activate it natively (guarded below), so no
// double-fire.
const onTextEntry =
target instanceof HTMLInputElement || target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement || target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement ||
target instanceof HTMLSelectElement || target instanceof HTMLSelectElement ||
!!target?.isContentEditable; !!target?.isContentEditable;
if (onTextEntry) return;
if (e.code === 'Escape') { if (e.code === 'Escape') {
if (showHelp) setShowHelp(false); if (showHelp) setShowHelp(false);
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
return; return;
} }
// Focused buttons handle their own keys (native activation); // Prevent default for space to stop scrolling (Space on a focused
// global shortcuts must not double-fire or block them. // button activates it natively — preventDefault only applies when the
if (onInteractive) return; // focus is on the body, where the shortcut below takes over).
if (e.code === 'Space' && !(target instanceof HTMLButtonElement)) {
// Prevent default for space to stop scrolling
if (e.code === 'Space') {
e.preventDefault(); e.preventDefault();
} }
@@ -169,12 +175,33 @@ function App() {
setListening(l => !l); setListening(l => !l);
break; break;
} }
if (e.code === 'Space' || e.code === 'Enter') { // Space/Enter on a focused button natively activate that button
// (e.g. Start); only treat them as pause-toggle when NOT on a button.
if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) {
phraseRef.current?.pauseToggle(); phraseRef.current?.pauseToggle();
} }
return; return;
} }
if (isAssist) {
// Assist mode: only display shortcuts — no trainer actions.
switch (e.key.toLowerCase()) {
case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint }));
break;
case 'z':
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
break;
case 'v':
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
break;
case 'l':
setListening(l => !l);
break;
}
return;
}
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case 'h': case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint })); setSettings(s => ({ ...s, showHint: !s.showHint }));
@@ -200,23 +227,23 @@ function App() {
break; break;
} }
if (e.code === 'Space' || e.code === 'Enter') { if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) {
singleNoteRef.current?.skipNote(); singleNoteRef.current?.skipNote();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [settings, showHelp, isPhrase, setSettings]); }, [settings, showHelp, isPhrase, isAssist, setSettings]);
// 'm' cycles all three modes from phrase mode too. // 'm' cycles back to sight reading from phrase/assist modes.
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== 'm') return; if (e.key.toLowerCase() !== 'm') return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return; target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
if (settings.gameMode === 'phrase') { if (settings.gameMode === 'phrase' || settings.gameMode === 'assist') {
setSettings(s => ({ ...s, gameMode: 'sight_reading' })); setSettings(s => ({ ...s, gameMode: 'sight_reading' }));
} }
}; };
@@ -263,6 +290,13 @@ 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
@@ -305,6 +339,11 @@ function App() {
micError={error} micError={error}
windowWidth={windowWidth} windowWidth={windowWidth}
/> />
) : isAssist ? (
<AssistMode
pitchData={displayedPitch}
windowWidth={windowWidth}
/>
) : ( ) : (
<SingleNoteTrainer <SingleNoteTrainer
ref={singleNoteRef} ref={singleNoteRef}

View File

@@ -0,0 +1,89 @@
/**
* Assist Mode — primitive practice companion for working from paper.
*
* No trainer, no scoring: shows the currently played (mic-detected) note
* large, optionally with a live fretboard of alternative positions for that
* note. Tuner and Metronome come from the shared footer tools (Controls),
* so they stay identical across all modes.
*/
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
import { useMemo } from 'react';
import { useSettings } from '../context/useSettings';
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
import { isFrettedInstrument } from '../music/playableRange';
import { getPracticeFilter } from '../types/SettingsTypes';
import { Fretboard } from './Fretboard';
import { LiveNoteStaff } from './LiveNoteStaff';
import type { PitchData } from '../hooks/usePitchDetector';
interface AssistModeProps {
pitchData: PitchData | null;
/** True while the app's own audio is audible (mic gate) — caller decides. */
windowWidth: number;
}
export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }) => {
const { settings } = useSettings();
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
const pf = getPracticeFilter(settings);
// Alternative positions for the detected note (respect fret window filter)
const positions = useMemo(() => {
if (!fretted || !currentTuning || !pitchData) return [];
const all = getFretboardPositions(pitchData.midi, currentTuning, 20);
if (!pf.fretWindowEnabled) return all;
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]);
const staffWidth = Math.min(windowWidth - 40, 860);
return (
<div className="card sheet-music-card assist-card">
<div className="assist-note-area">
{pitchData ? (
<span className="assist-note-name">{pitchData.note}</span>
) : (
<span className="assist-note-name placeholder">—</span>
)}
</div>
<div className="sheet-music-container">
<LiveNoteStaff
midi={pitchData ? pitchData.midi : null}
transpose={transpose}
keySignature={settings.keySignature}
clef={clef === 'grand' ? undefined : clef}
width={Math.min(240, staffWidth)}
theme={settings.theme}
/>
</div>
{fretted && currentTuning && (
<div className="assist-fretboard">
<Fretboard
tuning={currentTuning}
positions={positions}
maxFrets={15}
showHints={false}
interactive={false}
displayTranspose={transpose}
/>
</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>
);
};

View File

@@ -1,14 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Guitar, Music, Gauge } from 'lucide-react'; import { Gauge, SlidersHorizontal } from 'lucide-react';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { TuningMeter } from './TuningMeter'; import { TuningMeter } from './TuningMeter';
import { TargetNoteControls } from './TargetNoteControls'; import { TargetNoteControls } from './TargetNoteControls';
import { MetronomeWidget } from './MetronomeWidget'; import { MetronomeWidget } from './MetronomeWidget';
import { phraseRunBus } from '../hooks/phraseRunBus'; import { phraseRunBus } from '../hooks/phraseRunBus';
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; import { getPracticeFilter, type RhythmSettings } from '../types/SettingsTypes';
@@ -18,35 +15,13 @@ interface ControlsProps {
export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => { export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
// Alias to minimize refactor, or just use updateSettings.
const onUpdateSettings = updateSettings; const onUpdateSettings = updateSettings;
// Phrase run gate: the metronome only ticks while the phrase trainer is // Phrase run gate: the metronome only ticks while the phrase trainer is
// counting in / playing (armed by the user toggle, fired by the run). // counting in / playing (armed by the user toggle, fired by the run).
const [phraseRunActive, setPhraseRunActive] = useState(false); const [phraseRunActive, setPhraseRunActive] = useState(false);
useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []); useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []);
const handleTuningChange = (e: React.ChangeEvent<HTMLSelectElement>) => { // Note-filter panel: hidden by default (minimal UI), toggled here.
onUpdateSettings({ ...settings, tuningId: e.target.value }); const [filtersOpen, setFiltersOpen] = useState(false);
};
const handleInstrumentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newInstrumentId = e.target.value;
const instDef = INSTRUMENT_DEFINITIONS[newInstrumentId];
const defaultRange = instDef.ranges[0].id;
// Reset tuning if applicable, or just keep as is (it won't be shown/used)
let newTuningId = settings.tuningId;
if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) {
newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0];
}
onUpdateSettings({
...settings,
instrument: newInstrumentId,
difficulty: defaultRange as Difficulty,
tuningId: newTuningId
});
};
const updateRhythm = (updates: Partial<RhythmSettings>) => { const updateRhythm = (updates: Partial<RhythmSettings>) => {
onUpdateSettings({ onUpdateSettings({
@@ -55,98 +30,43 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
}); });
}; };
// Cast instrument to specific key if needed, or use generic record access
const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || [];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
// Phrase Mode brings its own key/tempo machinery; the single-note
// key-signature select and metronome tool would be competing controls.
const inPhraseMode = settings.gameMode === 'phrase'; const inPhraseMode = settings.gameMode === 'phrase';
const pf = getPracticeFilter(settings);
const activeFilterCount =
(pf.keyEnabled ? 1 : 0) +
(pf.strings.length > 0 ? 1 : 0) +
(pf.fretWindowEnabled ? 1 : 0);
// Phrase Mode hosts the filter inside its Setup panel (above the
// notation) — no footer filter button there.
const showFilterButton = !inPhraseMode;
const shouldShowFilters = showFilterButton && filtersOpen;
return ( return (
<div className="controls-container"> <div className="controls-container">
{/* Top Section: Settings Grid (2 Columns) */} {/* Note filter: collapsed behind a button — visible only when needed */}
<div className="settings-grid"> {showFilterButton && (
{/* Row 1, Col 1 */} <div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<div className="control-group"> <button
<label className="control-label"> className={`hint-button ${filtersOpen ? 'active' : ''}`}
<Music size={18} /> onClick={() => setFiltersOpen(o => !o)}
<span>Instrument</span> title="Limit the notes used for exercises (key, strings, fret window)"
</label> >
<select <SlidersHorizontal size={16} />
value={settings.instrument} Filter
onChange={handleInstrumentChange} {activeFilterCount > 0 && (
className="control-select" <span style={{
> display: 'inline-block', minWidth: '18px', padding: '1px 5px',
{Object.values(INSTRUMENT_DEFINITIONS).map(def => ( marginLeft: '4px', borderRadius: '9px', fontSize: '11px',
<option key={def.id} value={def.id}>{def.displayName}</option> background: 'var(--color-primary)', color: 'var(--color-surface)',
))} fontWeight: 600, textAlign: 'center'
</select> }}>{activeFilterCount}</span>
</div> )}
</button>
{/* Row 1, Col 2 (Conditional) */}
{currentInstrumentDef.showTuning ? (
<div className="control-group">
<label className="control-label">
<Guitar size={18} />
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={handleTuningChange}
className="control-select"
>
{availableTunings.map(id => (
<option key={id} value={id}>{TUNINGS[id].name}</option>
))}
</select>
</div>
) : <div />} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows.
If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows?
The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item?
I'll output an empty div if I want to FORCE strict placement, but generally flow is better.
However, "Second row: note set..." suggests structure.
I will use an empty div if tuning is hidden to push Note Set to next row?
Actually, grid-template-columns: 1fr 1fr.
If Tuning is hidden, Note Set becomes Item 2.
I'll forgo complexity and just render what's available. */}
{/* Row 2, Col 2 */}
{!inPhraseMode && (
<div className="control-group">
<label className="control-label">
<span>Key signature</span>
</label>
<select
value={settings.keySignature}
onChange={(e) => onUpdateSettings({ ...settings, keySignature: e.target.value })}
className="control-select"
>
<optgroup label="Major Keys">
<option value="C">C Major</option>
<option value="G">G Major</option>
<option value="D">D Major</option>
<option value="A">A Major</option>
<option value="E">E Major</option>
<option value="F">F Major</option>
<option value="Bb">Bb Major</option>
<option value="Eb">Eb Major</option>
</optgroup>
<optgroup label="Minor Keys">
<option value="Am">A Minor</option>
<option value="Em">E Minor</option>
<option value="Dm">D Minor</option>
</optgroup>
</select>
</div>
)}
</div> </div>
)}
{/* Target-note filter: unified controls, all modes */} {shouldShowFilters && <TargetNoteControls />}
<TargetNoteControls />
{/* Bottom Section: Tools Grid (2 Columns now) */} {/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}> <div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>

View File

@@ -13,7 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { useAudioPlayer } from '../hooks/useAudioPlayer';
import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer';
import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; import { getPhraseSettings, getPracticeFilter, type PhraseSettings } from '../types/SettingsTypes';
import { rawFrameCount } from '../hooks/rawFrameBus'; import { rawFrameCount } from '../hooks/rawFrameBus';
import { audioEngine } from '../audio/AudioEngine'; import { audioEngine } from '../audio/AudioEngine';
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange';
@@ -28,6 +28,7 @@ import { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library'; import { EXERCISES, loadExercise } from '../exercises/library';
import { parseAbc } from '../exercises/abcParser'; import { parseAbc } from '../exercises/abcParser';
import { PhraseSheetMusic } from './PhraseSheetMusic'; import { PhraseSheetMusic } from './PhraseSheetMusic';
import { TargetNoteControls } from './TargetNoteControls';
import { Fretboard } from './Fretboard'; import { Fretboard } from './Fretboard';
import { PianoKeys } from './PianoKeys'; import { PianoKeys } from './PianoKeys';
import { TUNINGS, getFretboardPositions } from '../music/Tunings'; import { TUNINGS, getFretboardPositions } from '../music/Tunings';
@@ -71,14 +72,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const setSettings = updateSettings; const setSettings = updateSettings;
const { playNote } = useAudioPlayer(); const { playNote } = useAudioPlayer();
const phrase = getPhraseSettings(settings); const phrase = getPhraseSettings(settings);
// Unified target-note controls: Phrase Mode reads/writes the shared // Unified target-note controls: Phrase Mode reads the shared practice
// practice filter so all modes expose identical key/fret-window state. // filter (single source of truth across all modes).
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
phrase.keyTonic = pf.keyTonic;
phrase.keyMode = pf.keyMode;
phrase.fretWindowEnabled = pf.fretWindowEnabled;
phrase.fretMin = pf.fretMin;
phrase.fretMax = pf.fretMax;
// Unified metronome: phrase tempo reads from the shared rhythm BPM // Unified metronome: phrase tempo reads from the shared rhythm BPM
// (single source of truth across modes; the phrase-local BPM control // (single source of truth across modes; the phrase-local BPM control
// was replaced by the common metronome widget in Controls). // was replaced by the common metronome widget in Controls).
@@ -96,18 +92,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
? settings.rhythm.active && settings.rhythm.sound ? settings.rhythm.active && settings.rhythm.sound
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise; : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => { const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => { updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
// Mirror shared filter fields into the unified practice filter.
const shared: Partial<PracticeFilter> = {};
if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic;
if ('keyMode' in updates) shared.keyMode = updates.keyMode;
if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled;
if ('fretMin' in updates) shared.fretMin = updates.fretMin;
if ('fretMax' in updates) shared.fretMax = updates.fretMax;
if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared };
return next;
});
}, [updateSettings]); }, [updateSettings]);
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
@@ -140,7 +125,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
customMaxFret: settings.customMaxFret, customMaxFret: settings.customMaxFret,
}); });
}, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning, }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning,
phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax,
settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]); settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]);
// ---- Imported score (session-only) -------------------------------------- // ---- Imported score (session-only) --------------------------------------
@@ -173,8 +157,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const baseScore: Result<Score> | null = useMemo(() => { const baseScore: Result<Score> | null = useMemo(() => {
if (phrase.material === 'melody') { if (phrase.material === 'melody') {
const result = generateMelody({ const result = generateMelody({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
bars: phrase.bars, bars: phrase.bars,
meter: { numerator: phrase.meterNumerator, denominator: 4 }, meter: { numerator: phrase.meterNumerator, denominator: 4 },
rhythmLevel: phrase.rhythmLevel, rhythmLevel: phrase.rhythmLevel,
@@ -184,8 +168,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} }
if (phrase.material === 'scale') { if (phrase.material === 'scale') {
return generateScaleDrill({ return generateScaleDrill({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
direction: phrase.scaleDirection, direction: phrase.scaleDirection,
coverage: phrase.scaleCoverage, coverage: phrase.scaleCoverage,
meter: { numerator: phrase.meterNumerator, denominator: 4 }, meter: { numerator: phrase.meterNumerator, denominator: 4 },
@@ -196,8 +180,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// Meter is auto-derived inside the generator from the figure // Meter is auto-derived inside the generator from the figure
// length (always eighths) — the user no longer picks a meter. // length (always eighths) — the user no longer picks a meter.
return generateGiulianiStudy({ return generateGiulianiStudy({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
pattern: phrase.giulianiPattern, pattern: phrase.giulianiPattern,
tuningId: settings.tuningId, tuningId: settings.tuningId,
bars: phrase.giulianiBars, bars: phrase.giulianiBars,
@@ -209,8 +193,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} }
if (phrase.material === 'arpeggio') { if (phrase.material === 'arpeggio') {
return generateArpeggio({ return generateArpeggio({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I',
pattern: phrase.arpeggioPattern, pattern: phrase.arpeggioPattern,
coverage: phrase.arpeggioCoverage, coverage: phrase.arpeggioCoverage,
@@ -231,7 +215,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
return { ok: true, value: importedScore }; return { ok: true, value: importedScore };
} }
return null; return null;
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, }, [phrase.material, pf.keyTonic, pf.keyMode, phrase.bars, phrase.meterNumerator,
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioBars, phrase.arpeggioProgression,
@@ -323,9 +307,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
}, [scoreOk, fixedMaterial, pool, fullScore]); }, [scoreOk, fixedMaterial, pool, fullScore]);
const nextAction = useCallback(() => { const nextAction = useCallback(() => {
if (phrase.material === 'melody' || (phrase.material === 'arpeggio' && phrase.arpeggioBars > 1)) { if (fixedMaterial && scoreOk) {
setMelodySeed(s => (s + 1) % 999983);
} else if (fixedMaterial && scoreOk) {
const total = fullScore!.value.measures.length; const total = fullScore!.value.measures.length;
const nextStart = phrase.startBar + phrase.barCount; const nextStart = phrase.startBar + phrase.barCount;
if (nextStart > total) { if (nextStart > total) {
@@ -333,6 +315,12 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} else { } else {
setPhrase({ startBar: nextStart }); setPhrase({ startBar: nextStart });
} }
return;
}
// Seeded material: roll a new variation (giuliani and single-chord
// arpeggios included — they take melodySeed too).
if (phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani') {
setMelodySeed(s => (s + 1) % 999983);
} }
}, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]); }, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]);
@@ -355,11 +343,11 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const hintPositions = useMemo(() => { const hintPositions = useMemo(() => {
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return []; if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return [];
if (phrase.fretWindowEnabled) { if (pf.fretWindowEnabled) {
return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax); return positionsWithinWindow(currentMidi, currentTuning, pf.fretMin, pf.fretMax);
} }
return getFretboardPositions(currentMidi, currentTuning); return getFretboardPositions(currentMidi, currentTuning);
}, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax]); }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
const handleVirtualPlay = useCallback((midi: number) => { const handleVirtualPlay = useCallback((midi: number) => {
if (!settings.virtualGuitarMute) { if (!settings.virtualGuitarMute) {
@@ -452,118 +440,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</span> </span>
</div> </div>
<div className="sheet-music-container"> {/* Setup panel — above the notation, holds material options
{scoreOk ? ( plus the shared note filter (key/strings/fret window) */}
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
</>
)}
{!settings.zenMode && currentInstrumentDef.showTuning && (
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
>
<Guitar size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button>
)}
{!settings.zenMode && (
<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>
)}
</div>
{/* Setup panel */}
{!settings.zenMode && ( {!settings.zenMode && (
<details className="phrase-setup"> <details className="phrase-setup">
<summary><ChevronDown size={16} /> Setup</summary> <summary><ChevronDown size={16} /> Setup</summary>
@@ -579,12 +457,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Key/mode: use the "Target key" controls below the sheet — shared across modes.
</span>
)}
{phrase.material === 'melody' && ( {phrase.material === 'melody' && (
<> <>
<label>Bars <label>Bars
@@ -799,12 +671,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
)} )}
{fretted && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Fret window: use the "Fret window" controls below the sheet — shared across modes.
</span>
)}
{/* Pace lives in the metronome widget now: {/* Pace lives in the metronome widget now:
'Sync to exercise' checked = tempo pace, 'Sync to exercise' checked = tempo pace,
unchecked = at-your-pace (step). */} unchecked = at-your-pace (step). */}
@@ -816,11 +682,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
/> />
Virtual input only Virtual input only
</label> </label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
BPM + click: on the metronome widget below.
</span>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -839,15 +700,128 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
</div> </div>
{phrase.pace === 'tempo' && ( {phrase.pace === 'tempo' && (
<p className="phrase-note">The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.</p> <p className="phrase-note">Tempo and click sound live on the metronome tool (Sync checked = the run sets the pace; unchecked = play at your own pace with a free metronome).</p>
)} )}
<p className="phrase-note"> <p className="phrase-note">
Matching checks pitch, not fingering — several fretboard positions produce the same pitch. Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
For repeated identical notes, briefly release between notes so each attack is detected. For repeated identical notes, briefly release between notes so each attack is detected.
</p> </p>
<TargetNoteControls />
</details> </details>
)} )}
<div className="sheet-music-container">
{scoreOk ? (
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{(fixedMaterial || phrase.material === 'scale') ? 'Next' : 'New'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{(fixedMaterial || phrase.material === 'scale') ? 'Next' : 'New'}
</button>
)}
</>
)}
{!settings.zenMode && currentInstrumentDef.showTuning && (
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
>
<Guitar size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button>
)}
{!settings.zenMode && (
<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>
)}
</div>
{/* Hint instruments */} {/* Hint instruments */}
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && ( {(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
<div className="hint-card"> <div className="hint-card">
@@ -878,7 +852,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
onPlayNote={handleVirtualPlay} onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi} onHover={setHoveredMidi}
showHints={settings.showHint} showHints={settings.showHint}
maxFrets={Math.max(15, phrase.fretWindowEnabled ? phrase.fretMax + 1 : 0)} maxFrets={Math.max(15, pf.fretWindowEnabled ? pf.fretMax + 1 : 0)}
/> />
) )
)} )}

View File

@@ -1,8 +1,32 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock } from 'lucide-react'; import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock, Guitar } from 'lucide-react';
import type { AppSettings } from '../types/SettingsTypes'; import type { AppSettings } from '../types/SettingsTypes';
import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
/** Collapsible settings section. First section open by default. */
const Section: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = false, children }) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div style={{ borderBottom: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', background: 'transparent', border: 'none', color: 'inherit',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', cursor: 'pointer', font: 'inherit'
}}
aria-expanded={open}
>
<span style={{ fontSize: '15px', fontWeight: 600 }}>{title}</span>
{open ? <ChevronUp size={16} style={{ opacity: 0.6 }} /> : <ChevronDown size={16} style={{ opacity: 0.6 }} />}
</button>
{open && <div style={{ paddingBottom: '20px' }}>{children}</div>}
</div>
);
};
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -78,7 +102,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
<div className="help-popup-overlay" onClick={onClose}> <div className="help-popup-overlay" onClick={onClose}>
<div className="help-popup" onClick={e => e.stopPropagation()}> <div className="help-popup" onClick={e => e.stopPropagation()}>
<button className="help-close" onClick={onClose}><X size={20} /></button> <button className="help-close" onClick={onClose}><X size={20} /></button>
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Settings</h2> <h2 style={{ marginTop: 0, marginBottom: '12px' }}>Settings</h2>
{/* Instrument & Tuning (rarely changed — lives here now) */}
<Section title="Instrument" defaultOpen>
<div className="control-group" style={{ marginBottom: '16px' }}>
<label className="control-label" style={{ marginBottom: '8px', fontSize: '16px' }}>
<Guitar size={18} />
<span>Instrument</span>
</label>
<select
value={settings.instrument}
onChange={(e) => {
const id = e.target.value;
const def = INSTRUMENT_DEFINITIONS[id];
let tuningId = settings.tuningId;
if (def.showTuning && INSTRUMENT_TUNINGS[id as 'guitar' | 'bass']) {
tuningId = INSTRUMENT_TUNINGS[id as 'guitar' | 'bass'][0];
}
onUpdateSettings({ ...settings, instrument: id, difficulty: def.ranges[0].id, tuningId });
}}
className="control-select"
>
{Object.values(INSTRUMENT_DEFINITIONS).map(def => (
<option key={def.id} value={def.id}>{def.displayName}</option>
))}
</select>
</div>
{INSTRUMENT_DEFINITIONS[settings.instrument]?.showTuning && INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] && (
<div className="control-group">
<label className="control-label" style={{ marginBottom: '8px', fontSize: '16px' }}>
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={(e) => onUpdateSettings({ ...settings, tuningId: e.target.value })}
className="control-select"
>
{INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'].map(id => (
<option key={id} value={id}>{TUNINGS[id]?.name ?? id}</option>
))}
</select>
</div>
)}
</Section>
<Section title="Appearance">
{/* Theme Settings */} {/* Theme Settings */}
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
@@ -119,8 +188,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</div> </div>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
{/* Animation Settings */} {/* Animation Settings */}
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -140,7 +207,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</p> </p>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} /> </Section>
<Section title="Audio" defaultOpen>
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -261,7 +330,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</p> </p>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} /> </Section>
<Section title="Microphone" defaultOpen>
{/* Microphone Toggle */} {/* Microphone Toggle */}
<div className="control-group" style={{ marginBottom: '16px' }}> <div className="control-group" style={{ marginBottom: '16px' }}>
@@ -434,6 +505,8 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
)} )}
</div> </div>
)} )}
</Section>
</div> </div>
</div> </div>

View File

@@ -6,8 +6,10 @@
* - Strings: pick which strings contribute target notes (fretted instruments). * - Strings: pick which strings contribute target notes (fretted instruments).
* - Fret window: restrict target notes to a fret range (fretted instruments). * - Fret window: restrict target notes to a fret range (fretted instruments).
* *
* In Phrase Mode the same controls bind to the shared practice filter * In Phrase Mode the Key section binds to the generator key (tonic+mode are
* (mirrored into phrase settings), so state stays consistent across modes. * always applied; no toggle), and the display-signature sync checkbox is
* hidden (phrase notation takes its signature from the score's key).
* Rendered inside the per-mode Setup panel above the notation.
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
@@ -46,6 +48,8 @@ const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
export const TargetNoteControls: React.FC = () => { export const TargetNoteControls: React.FC = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
// Phrase Mode: key tonic/mode are the generator key — always in effect.
const inPhraseMode = settings.gameMode === 'phrase';
const setFilter = (updates: Partial<PracticeFilter>) => { const setFilter = (updates: Partial<PracticeFilter>) => {
updateSettings(s => { updateSettings(s => {
@@ -81,7 +85,8 @@ export const TargetNoteControls: React.FC = () => {
const allSelected = pf.strings.length === 0; const allSelected = pf.strings.length === 0;
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0; const anyActive =
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
return ( return (
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}> <div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
{/* Section header: unified with Tuner/Metronome style */} {/* Section header: unified with Tuner/Metronome style */}
@@ -108,15 +113,17 @@ export const TargetNoteControls: React.FC = () => {
<Key size={18} /> <Key size={18} />
<span>Key</span> <span>Key</span>
</label> </label>
<button {!inPhraseMode && (
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`} <button
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })} className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'} onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
> title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
<div className="switch-thumb" /> >
</button> <div className="switch-thumb" />
</button>
)}
</div> </div>
{pf.keyEnabled && ( {(pf.keyEnabled || inPhraseMode) && (
<> <>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<select <select
@@ -140,23 +147,25 @@ export const TargetNoteControls: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}> {!inPhraseMode && (
<input <label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
type="checkbox" <input
checked={settings.keyFollowsTarget ?? false} type="checkbox"
onChange={e => updateSettings(s => { checked={settings.keyFollowsTarget ?? false}
const next = { ...s, keyFollowsTarget: e.target.checked }; onChange={e => updateSettings(s => {
// When enabling sync, snap the display signature now. const next = { ...s, keyFollowsTarget: e.target.checked };
if (e.target.checked) { // When enabling sync, snap the display signature now.
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE; if (e.target.checked) {
const sig = table[pf.keyTonic]; const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
if (sig) next.keySignature = sig; const sig = table[pf.keyTonic];
} if (sig) next.keySignature = sig;
return next; }
})} return next;
/> })}
Sync display key signature with target key />
</label> Sync display key signature with target key
</label>
)}
</> </>
)} )}
</div> </div>

View File

@@ -9,7 +9,7 @@ import {
NOTE_DISPLAY_STABLE_FRAMES, NOTE_DISPLAY_STABLE_FRAMES,
} from '../AppConfig'; } from '../AppConfig';
interface PitchData { export interface PitchData {
frequency: number; frequency: number;
midi: number; midi: number;
note: string; note: string;

View File

@@ -59,24 +59,24 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
color-scheme: dark; color-scheme: dark;
--color-bg: hsl(30, 20%, 10%); --color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(30, 20%, 15%); --color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(30, 10%, 90%); --color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(30, 10%, 60%); --color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(0, 0%, 90%); --color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(0, 0%, 100%); --color-primary-dark: hsl(220, 4%, 100%);
} }
} }
/* Manual Dark Mode Override */ /* Manual Dark Mode Override */
html[data-theme="dark"] { html[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--color-bg: hsl(30, 20%, 10%); --color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(30, 20%, 15%); --color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(30, 10%, 90%); --color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(30, 10%, 60%); --color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(0, 0%, 90%); --color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(0, 0%, 100%); --color-primary-dark: hsl(220, 4%, 100%);
} }
/* Manual Light Mode Override */ /* Manual Light Mode Override */

View File

@@ -49,8 +49,6 @@ export interface PhraseSettings {
material: PhraseMaterial; material: PhraseMaterial;
libraryId: string; libraryId: string;
/** Imported score id (the parsed score lives in component state, session-only). */ /** Imported score id (the parsed score lives in component state, session-only). */
keyTonic: string;
keyMode: string; // ModeId
bars: number; // 1–8 (melodies) bars: number; // 1–8 (melodies)
meterNumerator: 3 | 4 | 6; meterNumerator: 3 | 4 | 6;
rhythmLevel: 1 | 2 | 3; rhythmLevel: 1 | 2 | 3;
@@ -69,10 +67,6 @@ export interface PhraseSettings {
/** Giuliani study material (own category — voicing-enforced). */ /** Giuliani study material (own category — voicing-enforced). */
giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim'; giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim';
giulianiBars: number; giulianiBars: number;
/** Phrase-local fret window (guitar/bass) — overrides the note set. */
fretWindowEnabled: boolean;
fretMin: number;
fretMax: number;
/** Practice range for library/import material (1-based, inclusive). */ /** Practice range for library/import material (1-based, inclusive). */
startBar: number; startBar: number;
barCount: number; barCount: number;
@@ -95,8 +89,6 @@ export interface PhraseSettings {
export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
material: 'melody', material: 'melody',
libraryId: '', libraryId: '',
keyTonic: 'C',
keyMode: 'major',
bars: 2, bars: 2,
meterNumerator: 4, meterNumerator: 4,
rhythmLevel: 1, rhythmLevel: 1,
@@ -111,9 +103,6 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
arpeggioProgression: 'functional', arpeggioProgression: 'functional',
giulianiPattern: 'pima', giulianiPattern: 'pima',
giulianiBars: 4, giulianiBars: 4,
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
startBar: 1, startBar: 1,
barCount: 2, barCount: 2,
pace: 'step', pace: 'step',
@@ -145,7 +134,7 @@ export interface AppSettings {
instrument: string; instrument: string;
rhythm: RhythmSettings; rhythm: RhythmSettings;
zenMode: boolean; zenMode: boolean;
gameMode: 'sight_reading' | 'ear_training' | 'phrase'; gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;
autoPlaySightReading?: boolean; autoPlaySightReading?: boolean;