mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Add Instruments
This commit is contained in:
56
src/App.tsx
56
src/App.tsx
@@ -8,10 +8,9 @@ import {
|
|||||||
} from './music/NoteUtils';
|
} from './music/NoteUtils';
|
||||||
import {
|
import {
|
||||||
TUNINGS,
|
TUNINGS,
|
||||||
getOpenStringNotes,
|
|
||||||
getFirstPositionNotes,
|
|
||||||
getFretboardPositions
|
getFretboardPositions
|
||||||
} from './music/Tunings';
|
} from './music/Tunings';
|
||||||
|
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
||||||
import { Mic, MicOff, SkipForward } from 'lucide-react';
|
import { Mic, MicOff, SkipForward } from 'lucide-react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import './styles/skip-button.css';
|
import './styles/skip-button.css';
|
||||||
@@ -24,10 +23,11 @@ function App() {
|
|||||||
|
|
||||||
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
|
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
const [settings, setSettings] = useState<AppSettings>({
|
||||||
difficulty: 'first_pos',
|
difficulty: 'first_pos', // Will be dynamic, but initial default needed
|
||||||
showHint: false,
|
showHint: false,
|
||||||
tuningId: 'standard',
|
tuningId: 'standard',
|
||||||
keySignature: 'C'
|
keySignature: 'C',
|
||||||
|
instrument: 'guitar'
|
||||||
});
|
});
|
||||||
|
|
||||||
const [streak, setStreak] = useState(0);
|
const [streak, setStreak] = useState(0);
|
||||||
@@ -35,30 +35,34 @@ function App() {
|
|||||||
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
||||||
|
|
||||||
const currentTuning = TUNINGS[settings.tuningId];
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||||
|
|
||||||
// Generate valid notes based on difficulty
|
// Generate valid notes based on difficulty
|
||||||
const validNotes = useMemo(() => {
|
const validNotes = useMemo(() => {
|
||||||
switch (settings.difficulty) {
|
// Find range config
|
||||||
case 'open':
|
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||||
return getOpenStringNotes(currentTuning);
|
|
||||||
case 'first_pos':
|
if (rangeConfig) {
|
||||||
return getFirstPositionNotes(currentTuning);
|
return Array.from({ length: rangeConfig.max - rangeConfig.min + 1 }, (_, i) => rangeConfig.min + i);
|
||||||
case 'e_string':
|
|
||||||
// Low E string: 40 to 40+12 (E2 to E3)
|
|
||||||
const lowE = currentTuning.strings[0];
|
|
||||||
return Array.from({ length: 13 }, (_, i) => lowE + i);
|
|
||||||
case 'all':
|
|
||||||
default:
|
|
||||||
// Range from Low E (40) to High E 12th fret (64+12=76)
|
|
||||||
return Array.from({ length: 37 }, (_, i) => 40 + i);
|
|
||||||
}
|
}
|
||||||
}, [settings.difficulty, currentTuning]);
|
|
||||||
|
// Fallback if difficulty ID doesn't match current instrument (e.g. after switch)
|
||||||
|
// Return first range of current instrument
|
||||||
|
const fallbackRange = currentInstrumentDef.ranges[0];
|
||||||
|
return Array.from({ length: fallbackRange.max - fallbackRange.min + 1 }, (_, i) => fallbackRange.min + i);
|
||||||
|
|
||||||
|
}, [settings.difficulty, currentInstrumentDef]);
|
||||||
|
|
||||||
const generateNewNote = useCallback(() => {
|
const generateNewNote = useCallback(() => {
|
||||||
const newNote = getRandomNote(40, 76, validNotes);
|
// Determine min/max based on available notes to avoid infinite loops if validNotes empty
|
||||||
|
if (validNotes.length === 0) return;
|
||||||
|
const min = validNotes[0];
|
||||||
|
const max = validNotes[validNotes.length - 1];
|
||||||
|
|
||||||
|
const newNote = getRandomNote(min, max, validNotes);
|
||||||
if (newNote === targetMidi && validNotes.length > 1) {
|
if (newNote === targetMidi && validNotes.length > 1) {
|
||||||
// Try once to get a different note
|
// Try once to get a different note
|
||||||
const retry = getRandomNote(40, 76, validNotes);
|
const retry = getRandomNote(min, max, validNotes);
|
||||||
setTargetMidi(retry);
|
setTargetMidi(retry);
|
||||||
} else {
|
} else {
|
||||||
setTargetMidi(newNote);
|
setTargetMidi(newNote);
|
||||||
@@ -108,8 +112,10 @@ function App() {
|
|||||||
// Hint text construction
|
// Hint text construction
|
||||||
const hintPositions = useMemo(() => {
|
const hintPositions = useMemo(() => {
|
||||||
if (!settings.showHint) return [];
|
if (!settings.showHint) return [];
|
||||||
|
if (!currentInstrumentDef.showTuning || !currentTuning) return []; // No fretboard hints for piano/voice
|
||||||
|
|
||||||
return getFretboardPositions(targetMidi, currentTuning);
|
return getFretboardPositions(targetMidi, currentTuning);
|
||||||
}, [settings.showHint, targetMidi, currentTuning]);
|
}, [settings.showHint, targetMidi, currentTuning, currentInstrumentDef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
@@ -124,8 +130,10 @@ function App() {
|
|||||||
targetMidi={targetMidi}
|
targetMidi={targetMidi}
|
||||||
playedMidi={pitchData?.midi}
|
playedMidi={pitchData?.midi}
|
||||||
keySignature={settings.keySignature}
|
keySignature={settings.keySignature}
|
||||||
|
clef={currentInstrumentDef.clefMode}
|
||||||
|
transpose={currentInstrumentDef.transpose}
|
||||||
width={Math.min(window.innerWidth - 40, 500)}
|
width={Math.min(window.innerWidth - 40, 500)}
|
||||||
height={250}
|
height={currentInstrumentDef.clefMode === 'grand' ? 300 : 250}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="feedback-area">
|
<div className="feedback-area">
|
||||||
@@ -136,10 +144,10 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.showHint && (
|
{settings.showHint && currentInstrumentDef.showTuning && (
|
||||||
<div className="hint-card">
|
<div className="hint-card">
|
||||||
<div className="hint-note">
|
<div className="hint-note">
|
||||||
{getNoteDetails(targetMidi + 12).scientific} (Written)
|
{getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific} (Written)
|
||||||
</div>
|
</div>
|
||||||
<div className="hint-positions">
|
<div className="hint-positions">
|
||||||
{hintPositions.map((p, i) => (
|
{hintPositions.map((p, i) => (
|
||||||
|
|||||||
@@ -62,12 +62,8 @@ export class PitchAnalyzer {
|
|||||||
|
|
||||||
const pitch = this.detector(this.buffer);
|
const pitch = this.detector(this.buffer);
|
||||||
|
|
||||||
// Guitar range filtering:
|
// Widen range for Bass (E1 ~41Hz) and Whistle (C8 ~4186Hz)
|
||||||
// Low E (E2) is ~82Hz. Drop D is ~73Hz.
|
if (pitch && (pitch < 30 || pitch > 5000)) return null;
|
||||||
// High E (E4) is ~330Hz. 12th fret E5 is ~660Hz.
|
|
||||||
// Harmonics can go higher, but unlikely above 1500Hz for fundamental training.
|
|
||||||
// 19kHz (user reported D#10) is definitely noise.
|
|
||||||
if (pitch && (pitch < 70 || pitch > 1500)) return null;
|
|
||||||
|
|
||||||
return pitch;
|
return pitch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Settings, HelpCircle, Guitar } from 'lucide-react';
|
import { Settings, HelpCircle, Guitar, Music } from 'lucide-react';
|
||||||
import { TUNINGS } from '../music/Tunings';
|
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
|
||||||
|
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||||
|
|
||||||
export type Difficulty = 'all' | 'first_pos' | 'open' | 'e_string';
|
export type Difficulty = 'all' | 'first_pos' | 'open' | 'e_string';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ export interface AppSettings {
|
|||||||
showHint: boolean;
|
showHint: boolean;
|
||||||
tuningId: string;
|
tuningId: string;
|
||||||
keySignature: string;
|
keySignature: string;
|
||||||
|
instrument: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ControlsProps {
|
interface ControlsProps {
|
||||||
@@ -25,6 +27,31 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
|||||||
onUpdateSettings({ ...settings, tuningId: e.target.value });
|
onUpdateSettings({ ...settings, tuningId: e.target.value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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)
|
||||||
|
// If instrument has tunings, pick first.
|
||||||
|
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, // flexible casting
|
||||||
|
tuningId: newTuningId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
|
||||||
const toggleHint = () => {
|
const toggleHint = () => {
|
||||||
onUpdateSettings({ ...settings, showHint: !settings.showHint });
|
onUpdateSettings({ ...settings, showHint: !settings.showHint });
|
||||||
};
|
};
|
||||||
@@ -33,20 +60,38 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
|||||||
<div className="controls-container">
|
<div className="controls-container">
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<Guitar size={18} />
|
<Music size={18} />
|
||||||
<span>Tuning</span>
|
<span>Instrument</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={settings.tuningId}
|
value={settings.instrument}
|
||||||
onChange={handleTuningChange}
|
onChange={handleInstrumentChange}
|
||||||
className="control-select"
|
className="control-select"
|
||||||
>
|
>
|
||||||
{Object.entries(TUNINGS).map(([id, tuning]) => (
|
{Object.values(INSTRUMENT_DEFINITIONS).map(def => (
|
||||||
<option key={id} value={id}>{tuning.name}</option>
|
<option key={def.id} value={def.id}>{def.displayName}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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 className="control-group">
|
<div className="control-group">
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
@@ -57,10 +102,9 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
|||||||
onChange={handleDifficultyChange}
|
onChange={handleDifficultyChange}
|
||||||
className="control-select"
|
className="control-select"
|
||||||
>
|
>
|
||||||
<option value="open">Open Strings (Beginner)</option>
|
{currentInstrumentDef.ranges.map(r => (
|
||||||
<option value="first_pos">First Position</option>
|
<option key={r.id} value={r.id}>{r.label}</option>
|
||||||
<option value="e_string">E String Only</option>
|
))}
|
||||||
<option value="all">All Notes</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter } from 'vexflow';
|
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector } from 'vexflow';
|
||||||
import { getNoteInKey } from '../music/NoteUtils';
|
import { getNoteInKey } from '../music/NoteUtils';
|
||||||
|
|
||||||
|
|
||||||
interface SheetMusicProps {
|
interface SheetMusicProps {
|
||||||
targetMidi: number;
|
targetMidi: number;
|
||||||
playedMidi?: number | null;
|
playedMidi?: number | null;
|
||||||
clef?: 'treble' | 'bass';
|
clef?: 'treble' | 'bass' | 'grand';
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
|
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
|
||||||
@@ -18,7 +18,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
playedMidi,
|
playedMidi,
|
||||||
clef = 'treble',
|
clef = 'treble',
|
||||||
width = 300,
|
width = 300,
|
||||||
height = 200,
|
height = 250, // Increased default height for Grand Staff
|
||||||
transpose = 12, // Default to +1 octave (Guitar Notation)
|
transpose = 12, // Default to +1 octave (Guitar Notation)
|
||||||
keySignature = 'C'
|
keySignature = 'C'
|
||||||
}) => {
|
}) => {
|
||||||
@@ -31,77 +31,236 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
|
|||||||
containerRef.current.innerHTML = '';
|
containerRef.current.innerHTML = '';
|
||||||
|
|
||||||
const renderer = new Renderer(containerRef.current, Renderer.Backends.SVG);
|
const renderer = new Renderer(containerRef.current, Renderer.Backends.SVG);
|
||||||
|
|
||||||
renderer.resize(width, height);
|
renderer.resize(width, height);
|
||||||
const context = renderer.getContext();
|
const context = renderer.getContext();
|
||||||
|
|
||||||
// Create Stave
|
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
|
||||||
const stave = new Stave(10, 40, width - 20);
|
// For Grand Staff: usually Split at Middle C (C4 / Midi 60).
|
||||||
stave.addClef(clef);
|
// >= 60 -> Treble, < 60 -> Bass.
|
||||||
stave.addKeySignature(keySignature);
|
const getGrandStaffClef = (midi: number): 'treble' | 'bass' => {
|
||||||
stave.setContext(context).draw();
|
return midi >= 60 ? 'treble' : 'bass';
|
||||||
|
|
||||||
// Helper to create keys for VexFlow using key signature logic
|
|
||||||
const getVexFlowKey = (midi: number) => {
|
|
||||||
const visualMidi = midi + transpose;
|
|
||||||
return getNoteInKey(visualMidi, keySignature);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create Target Note
|
let staves: Record<string, Stave> = {};
|
||||||
const targetData = getVexFlowKey(targetMidi);
|
|
||||||
|
|
||||||
const targetStaveNote = new StaveNote({
|
if (clef === 'grand') {
|
||||||
keys: targetData.keys,
|
// Create Treble Stave
|
||||||
duration: "w",
|
const topStave = new Stave(20, 40, width - 30);
|
||||||
clef: clef
|
topStave.addClef('treble').addKeySignature(keySignature);
|
||||||
});
|
topStave.setContext(context).draw();
|
||||||
|
|
||||||
if (targetData.accidental) {
|
// Create Bass Stave
|
||||||
targetStaveNote.addModifier(new Accidental(targetData.accidental));
|
const bottomStave = new Stave(20, 150, width - 30);
|
||||||
}
|
bottomStave.addClef('bass').addKeySignature(keySignature);
|
||||||
|
bottomStave.setContext(context).draw();
|
||||||
|
|
||||||
if (playedMidi) {
|
// Connect them
|
||||||
const playedData = getVexFlowKey(playedMidi);
|
const brace = new StaveConnector(topStave, bottomStave);
|
||||||
|
brace.setType(StaveConnector.type.BRACE);
|
||||||
|
brace.setContext(context).draw();
|
||||||
|
|
||||||
const playedStaveNote = new StaveNote({
|
const leftLine = new StaveConnector(topStave, bottomStave);
|
||||||
keys: playedData.keys,
|
leftLine.setType(StaveConnector.type.SINGLE_LEFT);
|
||||||
duration: "h",
|
leftLine.setContext(context).draw();
|
||||||
clef: clef
|
|
||||||
});
|
|
||||||
|
|
||||||
// Target note as half note to match measure
|
const rightLine = new StaveConnector(topStave, bottomStave);
|
||||||
const targetNoteHalf = new StaveNote({
|
rightLine.setType(StaveConnector.type.SINGLE_RIGHT);
|
||||||
keys: targetData.keys,
|
rightLine.setContext(context).draw();
|
||||||
duration: "h",
|
|
||||||
clef: clef
|
|
||||||
});
|
|
||||||
if (targetData.accidental) targetNoteHalf.addModifier(new Accidental(targetData.accidental));
|
|
||||||
|
|
||||||
|
staves = { treble: topStave, bass: bottomStave };
|
||||||
if (playedMidi === targetMidi) {
|
|
||||||
playedStaveNote.setStyle({ fillStyle: "var(--color-success)", strokeStyle: "var(--color-success)" });
|
|
||||||
} else {
|
|
||||||
playedStaveNote.setStyle({ fillStyle: "var(--color-error)", strokeStyle: "var(--color-error)" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playedData.accidental) {
|
|
||||||
playedStaveNote.addModifier(new Accidental(playedData.accidental));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use camelCase properties as fixed previously
|
|
||||||
const voiceCombined = new Voice({ numBeats: 4, beatValue: 4 });
|
|
||||||
voiceCombined.addTickables([targetNoteHalf, playedStaveNote]);
|
|
||||||
|
|
||||||
new Formatter().joinVoices([voiceCombined]).format([voiceCombined], width - 50);
|
|
||||||
voiceCombined.draw(context, stave);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
// Single Stave
|
||||||
voice.addTickables([targetStaveNote]);
|
const stave = new Stave(10, 80, width - 20); // Centered vertically
|
||||||
new Formatter().joinVoices([voice]).format([voice], width - 50);
|
stave.addClef(clef).addKeySignature(keySignature);
|
||||||
voice.draw(context, stave);
|
stave.setContext(context).draw();
|
||||||
|
// Map the single clef to the key matching the 'clef' prop so logic below works
|
||||||
|
staves = { [clef]: stave };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helper: Create VexFlow Note ---
|
||||||
|
const createStaveNote = (midi: number, duration: string, type: 'target' | 'played') => {
|
||||||
|
const visualMidi = midi + transpose;
|
||||||
|
const data = getNoteInKey(visualMidi, keySignature);
|
||||||
|
|
||||||
|
// Determine Clef for THIS note
|
||||||
|
let noteClef = clef;
|
||||||
|
if (clef === 'grand') {
|
||||||
|
noteClef = getGrandStaffClef(visualMidi);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staveNote = new StaveNote({
|
||||||
|
keys: data.keys,
|
||||||
|
duration: duration,
|
||||||
|
clef: noteClef as 'treble' | 'bass'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.accidental) {
|
||||||
|
staveNote.addModifier(new Accidental(data.accidental));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'played') {
|
||||||
|
if (midi === targetMidi) {
|
||||||
|
staveNote.setStyle({ fillStyle: "var(--color-success)", strokeStyle: "var(--color-success)" });
|
||||||
|
} else {
|
||||||
|
staveNote.setStyle({ fillStyle: "var(--color-error)", strokeStyle: "var(--color-error)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { note: staveNote, clef: noteClef };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Create Notes ---
|
||||||
|
const targetObj = createStaveNote(targetMidi, "w", 'target');
|
||||||
|
|
||||||
|
const voicesToDraw: { stave: Stave, voice: Voice }[] = [];
|
||||||
|
|
||||||
|
// Helper to push voice
|
||||||
|
const addVoice = (stave: Stave, notes: StaveNote[]) => {
|
||||||
|
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
|
voice.addTickables(notes);
|
||||||
|
new Formatter().joinVoices([voice]).format([voice], width - 60);
|
||||||
|
voicesToDraw.push({ stave, voice });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (playedMidi) {
|
||||||
|
const playedObj = createStaveNote(playedMidi, "h", 'played');
|
||||||
|
const targetHalfObj = createStaveNote(targetMidi, "h", 'target');
|
||||||
|
|
||||||
|
// If Grand Staff: Notes might be on DIFFERENT staves.
|
||||||
|
// We need to group notes by Stave.
|
||||||
|
|
||||||
|
const groupedNotes: Record<string, StaveNote[]> = {};
|
||||||
|
|
||||||
|
// Initialize relevant keys
|
||||||
|
if (clef === 'grand') {
|
||||||
|
groupedNotes['treble'] = [];
|
||||||
|
groupedNotes['bass'] = [];
|
||||||
|
} else {
|
||||||
|
groupedNotes[clef] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logic:
|
||||||
|
// If Single Stave: Both notes go on that stave.
|
||||||
|
// If Grand Staff: Target goes on its clef. Played goes on its clef.
|
||||||
|
// BUT: If they are on the SAME clef, we render them in one voice (or same stave).
|
||||||
|
// VexFlow requires Formatter to format voices.
|
||||||
|
|
||||||
|
// Target Half
|
||||||
|
// If clef is grand, use targetHalfObj.clef. If single, use prop clef.
|
||||||
|
const targetClefKey = clef === 'grand' ? targetHalfObj.clef : clef;
|
||||||
|
// Played
|
||||||
|
const playedClefKey = clef === 'grand' ? playedObj.clef : clef;
|
||||||
|
|
||||||
|
// Wait, if we want them to align in time (same measure), we need to put them in the same voice OR separate voices in same Context?
|
||||||
|
// VexFlow: To draw notes side-by-side (sequentially), they are in the same Voice.
|
||||||
|
// The prompt implies "Target note as half / Played as half".
|
||||||
|
|
||||||
|
// COMPLEXITY: In Grand Staff, if Target is Treble and Played is Bass, they are on different staves.
|
||||||
|
// They are distinct events visually.
|
||||||
|
// If both are Treble, they are side-by-side.
|
||||||
|
|
||||||
|
// Let's create a map of Voice-per-Stave.
|
||||||
|
|
||||||
|
// We need to fill "rests" or manage timing if they are split across staves?
|
||||||
|
// Simplified: Just draw them. If they are on different staves, they won't align horizontally perfectly unless we coordinate formatters.
|
||||||
|
// For this app, simply drawing them on their respective staves is fine.
|
||||||
|
|
||||||
|
// However, to ensure they look like a "measure", we should probably put Rests?
|
||||||
|
// Let's keep it simple: Just draw the notes.
|
||||||
|
|
||||||
|
// Problem: If I play C3 (Bass) and Target is C5 (Treble).
|
||||||
|
// Treble Stave: [C5 (h), Rest (h)] ? Or just C5 at pos 0?
|
||||||
|
// If we just addtickables, they render at start.
|
||||||
|
|
||||||
|
// To align them:
|
||||||
|
// Ideally:
|
||||||
|
// Target (h) -> Beat 1
|
||||||
|
// Played (h) -> Beat 3
|
||||||
|
// So:
|
||||||
|
// Voice 1 (Target's Stave): Note(h) + Rest(h) (if played is elsewhere?)
|
||||||
|
// Actually, existing code did: [Target(h), Played(h)]. Sequence.
|
||||||
|
|
||||||
|
// Scenario 1: Both on same stave.
|
||||||
|
if (targetClefKey === playedClefKey) {
|
||||||
|
// Same stave. Add both to voice.
|
||||||
|
const stv = staves[targetClefKey as string];
|
||||||
|
addVoice(stv, [targetHalfObj.note, playedObj.note]);
|
||||||
|
} else {
|
||||||
|
// Different staves (Grand Staff split).
|
||||||
|
// Target on Stave A. Played on Stave B.
|
||||||
|
// Stave A: Target(h) + Rest(h) (invisible?)
|
||||||
|
// Stave B: Rest(h) + Played(h)
|
||||||
|
|
||||||
|
// Constructing invisible rests is tedious in Vexflow without dedicated Rest classes.
|
||||||
|
// Let's try separate Voices?
|
||||||
|
// Visual separation might be okay.
|
||||||
|
|
||||||
|
const staveT = staves[targetClefKey as string];
|
||||||
|
const staveP = staves[playedClefKey as string];
|
||||||
|
|
||||||
|
// Just draw them.
|
||||||
|
// Note: They will both appear at the start (Beat 1) if we don't padding.
|
||||||
|
// We want Played to be "next" to Target.
|
||||||
|
|
||||||
|
// Let's stick to the "Sequence": Target then Played.
|
||||||
|
// If separate staves, we lose the "sequence" visual left-to-right if we just draw them at beat 1.
|
||||||
|
|
||||||
|
// SOLUTION: Use a "Ghost Note" (Invisible) of Half duration on the other stave?
|
||||||
|
// Or proper VexFlow StaveGhostNote?
|
||||||
|
|
||||||
|
// Simpler hack:
|
||||||
|
// Render Target at Beat 1.
|
||||||
|
// Render Played at Beat 3.
|
||||||
|
|
||||||
|
// For Stave A (Target): Note(h), Rest(h)
|
||||||
|
// For Stave B (Played): Rest(h), Note(h)
|
||||||
|
|
||||||
|
// Vexflow `StaveNote({ keys: ["b/4"], duration: "hqr" })` for rest?
|
||||||
|
|
||||||
|
const createRest = (clefStr: string) => new StaveNote({ keys: ["b/4"], duration: "hr", clef: clefStr });
|
||||||
|
|
||||||
|
// Stave T (Target's stave)
|
||||||
|
const voiceT = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
|
voiceT.addTickables([targetHalfObj.note, createRest(targetClefKey as string)]);
|
||||||
|
|
||||||
|
// Stave P (Played's stave)
|
||||||
|
const voiceP = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
|
voiceP.addTickables([createRest(playedClefKey as string), playedObj.note]);
|
||||||
|
|
||||||
|
// We need to format them together to align beats?
|
||||||
|
// Yes, formatters can take multiple voices to align specific ticks.
|
||||||
|
|
||||||
|
new Formatter().joinVoices([voiceT]).format([voiceT], width - 60);
|
||||||
|
new Formatter().joinVoices([voiceP]).format([voiceP], width - 60);
|
||||||
|
|
||||||
|
// But wait, if we format separately, they might not align vertically across staves?
|
||||||
|
// Actually they will if width is same.
|
||||||
|
// But generally `joinVoices([v1, v2])` is better.
|
||||||
|
|
||||||
|
// But vT and vP are on different staves!
|
||||||
|
// VexFlow Formatter doesn't care about staves, just X alignment.
|
||||||
|
// So we can format them together!
|
||||||
|
|
||||||
|
new Formatter().joinVoices([voiceT, voiceP]).format([voiceT, voiceP], width - 60);
|
||||||
|
|
||||||
|
voicesToDraw.push({ stave: staveT, voice: voiceT });
|
||||||
|
voicesToDraw.push({ stave: staveP, voice: voiceP });
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Target Only (Whole note)
|
||||||
|
const stave = staves[targetObj.clef as string];
|
||||||
|
addVoice(stave, [targetObj.note]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw all voices
|
||||||
|
voicesToDraw.forEach(({ stave, voice }) => {
|
||||||
|
voice.draw(context, stave);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
}, [targetMidi, playedMidi, clef, width, height, transpose, keySignature]);
|
}, [targetMidi, playedMidi, clef, width, height, transpose, keySignature]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="sheet-music-container" />;
|
return <div ref={containerRef} className="sheet-music-container" />;
|
||||||
|
|||||||
86
src/music/InstrumentConfigs.ts
Normal file
86
src/music/InstrumentConfigs.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Instrument } from './Tunings';
|
||||||
|
|
||||||
|
export type ClefMode = 'treble' | 'bass' | 'grand';
|
||||||
|
|
||||||
|
export interface RangeConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
min: number; // MIDI number
|
||||||
|
max: number; // MIDI number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstrumentDefinition {
|
||||||
|
id: Instrument | 'piano' | 'voice' | 'whistle';
|
||||||
|
displayName: string;
|
||||||
|
clefMode: ClefMode;
|
||||||
|
transpose: number; // Semitones to add to MIDI to get written note (e.g. +12 for guitar)
|
||||||
|
ranges: RangeConfig[];
|
||||||
|
showTuning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INSTRUMENT_DEFINITIONS: Record<string, InstrumentDefinition> = {
|
||||||
|
guitar: {
|
||||||
|
id: 'guitar',
|
||||||
|
displayName: 'Guitar',
|
||||||
|
clefMode: 'treble',
|
||||||
|
transpose: 12, // Guitar sounds octave lower than written, so we add 12 to played midi to show it
|
||||||
|
showTuning: true,
|
||||||
|
ranges: [
|
||||||
|
{ id: 'open', label: 'Open Strings', min: 40, max: 64 }, // Dynamic logic handled in App for specific strings, but this is fallback
|
||||||
|
{ id: 'first_pos', label: 'First Position', min: 40, max: 44 + 12 }, // Approx
|
||||||
|
{ id: 'all', label: 'All Notes', min: 40, max: 76 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bass: {
|
||||||
|
id: 'bass',
|
||||||
|
displayName: 'Bass Guitar',
|
||||||
|
clefMode: 'bass',
|
||||||
|
transpose: 12,
|
||||||
|
showTuning: true,
|
||||||
|
ranges: [
|
||||||
|
// Bass Standard: E1 (28) -> G3 approx (55)
|
||||||
|
{ id: 'all', label: 'All Notes', min: 28, max: 55 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
piano: {
|
||||||
|
id: 'piano',
|
||||||
|
displayName: 'Piano',
|
||||||
|
clefMode: 'grand',
|
||||||
|
transpose: 0,
|
||||||
|
showTuning: false,
|
||||||
|
ranges: [
|
||||||
|
{ id: 'middle_c', label: 'Middle C Area', min: 53, max: 67 }, // F3 to G4
|
||||||
|
{ id: 'two_octave', label: 'Two Octaves', min: 48, max: 72 }, // C3 to C5
|
||||||
|
{ id: 'grand_staff', label: 'Grand Staff Wide', min: 36, max: 84 } // C2 to C6
|
||||||
|
]
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
id: 'voice',
|
||||||
|
displayName: 'Voice',
|
||||||
|
clefMode: 'treble', // Dynamic? Usually vocal music is specific clef OR treble w/ 8va. Let's stick to Treble/Bass per range?
|
||||||
|
// Actually, let's keep simple static clef or make App logic handle it.
|
||||||
|
// For now, Voice is usually Treble unless Bass/Baritone.
|
||||||
|
// Let's use Treble by default and maybe switch if range is low.
|
||||||
|
transpose: 0,
|
||||||
|
showTuning: false,
|
||||||
|
ranges: [
|
||||||
|
{ id: 'soprano', label: 'Soprano (C4-A5)', min: 60, max: 81 },
|
||||||
|
{ id: 'alto', label: 'Alto (G3-E5)', min: 55, max: 76 },
|
||||||
|
{ id: 'tenor', label: 'Tenor (C3-A4)', min: 48, max: 69 },
|
||||||
|
{ id: 'bass_voice', label: 'Bass (E2-E4)', min: 40, max: 64 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
whistle: {
|
||||||
|
id: 'whistle',
|
||||||
|
displayName: 'Whistle',
|
||||||
|
clefMode: 'treble',
|
||||||
|
transpose: -12, // Reads 8va (written C4 = sounding C5). E5 (76) sounds -> reads as E4. C8 (108) -> C7.
|
||||||
|
showTuning: false,
|
||||||
|
ranges: [
|
||||||
|
// "starts around E5 for men" -> E5 = 76.
|
||||||
|
{ id: 'basic', label: 'Basic (E5-E6)', min: 76, max: 88 },
|
||||||
|
{ id: 'extended', label: 'Extended (E5-E7)', min: 76, max: 100 },
|
||||||
|
{ id: 'extreme', label: 'Whistle Register (C6-C8)', min: 84, max: 108 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,21 +5,35 @@ export interface Tuning {
|
|||||||
strings: number[]; // MIDI numbers for strings, usually low to high (6th to 1st)
|
strings: number[]; // MIDI numbers for strings, usually low to high (6th to 1st)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Instrument = 'guitar' | 'bass';
|
||||||
|
|
||||||
// Low E2 (40), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64)
|
// Low E2 (40), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64)
|
||||||
export const STANDARD_TUNING: Tuning = {
|
export const STANDARD_TUNING: Tuning = {
|
||||||
name: "Standard",
|
name: "Standard (Guitar)",
|
||||||
strings: [40, 45, 50, 55, 59, 64]
|
strings: [40, 45, 50, 55, 59, 64]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Low D2 (38), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64)
|
// Low D2 (38), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64)
|
||||||
export const DROP_D_TUNING: Tuning = {
|
export const DROP_D_TUNING: Tuning = {
|
||||||
name: "Drop D",
|
name: "Drop D (Guitar)",
|
||||||
strings: [38, 45, 50, 55, 59, 64]
|
strings: [38, 45, 50, 55, 59, 64]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Bass Standard: E1 (28), A1 (33), D2 (38), G2 (43)
|
||||||
|
export const BASS_STANDARD_TUNING: Tuning = {
|
||||||
|
name: "Standard (Bass)",
|
||||||
|
strings: [28, 33, 38, 43]
|
||||||
|
};
|
||||||
|
|
||||||
export const TUNINGS: Record<string, Tuning> = {
|
export const TUNINGS: Record<string, Tuning> = {
|
||||||
"standard": STANDARD_TUNING,
|
"standard": STANDARD_TUNING,
|
||||||
"drop_d": DROP_D_TUNING,
|
"drop_d": DROP_D_TUNING,
|
||||||
|
"bass_standard": BASS_STANDARD_TUNING,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INSTRUMENT_TUNINGS: Record<Instrument, string[]> = {
|
||||||
|
'guitar': ['standard', 'drop_d'],
|
||||||
|
'bass': ['bass_standard']
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FretPosition {
|
export interface FretPosition {
|
||||||
|
|||||||
Reference in New Issue
Block a user