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:
OpenClaw
2026-09-15 21:11:21 +00:00
parent b7c8187aef
commit 1ec1353c0b
5 changed files with 173 additions and 5 deletions

View File

@@ -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;
}

View File

@@ -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}

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

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

View File

@@ -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;