mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
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.
This commit is contained in:
46
src/App.css
46
src/App.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
39
src/App.tsx
39
src/App.tsx
@@ -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];
|
||||||
@@ -175,6 +177,25 @@ function App() {
|
|||||||
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 }));
|
||||||
@@ -207,16 +228,16 @@ function App() {
|
|||||||
|
|
||||||
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 +284,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 +333,11 @@ function App() {
|
|||||||
micError={error}
|
micError={error}
|
||||||
windowWidth={windowWidth}
|
windowWidth={windowWidth}
|
||||||
/>
|
/>
|
||||||
|
) : isAssist ? (
|
||||||
|
<AssistMode
|
||||||
|
pitchData={displayedPitch}
|
||||||
|
windowWidth={windowWidth}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SingleNoteTrainer
|
<SingleNoteTrainer
|
||||||
ref={singleNoteRef}
|
ref={singleNoteRef}
|
||||||
|
|||||||
89
src/components/AssistMode.tsx
Normal file
89
src/components/AssistMode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -134,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user