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: 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 { resolveClefTranspose } from './music/InstrumentConfigs';
|
||||
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
|
||||
import { AssistMode } from './components/AssistMode';
|
||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||
import { useSettings } from './context/useSettings';
|
||||
import { audioEngine } from './audio/AudioEngine';
|
||||
@@ -26,6 +27,7 @@ function App() {
|
||||
const setSettings = updateSettings;
|
||||
|
||||
const isPhrase = settings.gameMode === 'phrase';
|
||||
const isAssist = settings.gameMode === 'assist';
|
||||
|
||||
const currentTuning = TUNINGS[settings.tuningId];
|
||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||
@@ -175,6 +177,25 @@ function App() {
|
||||
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()) {
|
||||
case 'h':
|
||||
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||
@@ -207,16 +228,16 @@ function App() {
|
||||
|
||||
window.addEventListener('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(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() !== 'm') return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
|
||||
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' }));
|
||||
}
|
||||
};
|
||||
@@ -263,6 +284,13 @@ 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
|
||||
@@ -305,6 +333,11 @@ function App() {
|
||||
micError={error}
|
||||
windowWidth={windowWidth}
|
||||
/>
|
||||
) : isAssist ? (
|
||||
<AssistMode
|
||||
pitchData={displayedPitch}
|
||||
windowWidth={windowWidth}
|
||||
/>
|
||||
) : (
|
||||
<SingleNoteTrainer
|
||||
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,
|
||||
} from '../AppConfig';
|
||||
|
||||
interface PitchData {
|
||||
export interface PitchData {
|
||||
frequency: number;
|
||||
midi: number;
|
||||
note: string;
|
||||
|
||||
@@ -134,7 +134,7 @@ export interface AppSettings {
|
||||
instrument: string;
|
||||
rhythm: RhythmSettings;
|
||||
zenMode: boolean;
|
||||
gameMode: 'sight_reading' | 'ear_training' | 'phrase';
|
||||
gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
|
||||
customMinFret?: number;
|
||||
customMaxFret?: number;
|
||||
autoPlaySightReading?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user