diff --git a/App.tsx b/App.tsx index a3e0837..414d725 100644 --- a/App.tsx +++ b/App.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { audioEngine } from './services/audioEngine'; import { DistortionSwitch } from './components/DistortionSwitch'; @@ -10,13 +10,87 @@ import { RiffBar } from './components/RiffBar'; import { useChordStore } from './stores/chordStore'; import { useRiffStore, MAX_RIFF_STEPS } from './stores/riffStore'; import { Chord, TuningMode, VibeMode } from './types'; -import { transposeChord } from './utils/musicTheory'; -import { syncUrlState } from './utils/urlState'; +import { resolveLibraryChord, tuningForMode } from './utils/libraryVoicing'; +import { handProfileHash } from './engine/handProfile'; +import { useHandProfileStore } from './stores/handProfileStore'; +import { parseUrlState, syncUrlState, type AppView } from './utils/urlState'; import { getChords } from './constants'; const CHORDS_PER_BATCH = 6; const RELATED_CHORDS_COUNT = 6; +const loadVoicingFinder = () => import('./components/VoicingFinder'); +const loadHandProfilePanel = () => import('./components/HandProfilePanel'); +const loadRhythmLab = () => import('./components/RhythmLab'); +const VoicingFinder = lazy(() => loadVoicingFinder().then((m) => ({ default: m.VoicingFinder }))); +const HandProfilePanel = lazy(() => loadHandProfilePanel().then((m) => ({ default: m.HandProfilePanel }))); +const RhythmLab = lazy(() => loadRhythmLab().then((m) => ({ default: m.RhythmLab }))); + +const RHYTHM_LAB_ENABLED = true; + +const initialView = (): AppView => { + const view = parseUrlState().view ?? 'library'; + return view === 'rhythm' && !RHYTHM_LAB_ENABLED ? 'library' : view; +}; + +const LazyFallback: React.FC = () => ( +

Loading...

+); + +interface ViewSwitchProps { + view: AppView; + onChange: (view: AppView) => void; + isDistorted: boolean; + showRhythmLab: boolean; +} + +const ViewSwitch: React.FC = ({ view, onChange, isDistorted, showRhythmLab }) => { + const options: { id: AppView; label: string; prefetch?: () => void }[] = [ + { id: 'library', label: 'LIBRARY' }, + { id: 'finder', label: 'VOICING FINDER', prefetch: () => void loadVoicingFinder() }, + ...(showRhythmLab ? [{ id: 'rhythm' as const, label: 'RHYTHM LAB', prefetch: () => void loadRhythmLab() }] : []) + ]; + + return ( +
+
+ + WORKBENCH + +
+
+ {options.map((option) => { + const active = option.id === view; + return ( + + ); + })} +
+
+ ); +}; + const App: React.FC = () => { const { isDistorted, @@ -53,6 +127,23 @@ const App: React.FC = () => { const relatedSectionRef = useRef(null); const [favoriteChords, setFavoriteChords] = useState([]); + const storedProfile = useHandProfileStore((state) => state.profile); + // Cards depend only on the fretting-hand fields: a tempo change must not reload the grid + const voicingProfileKey = handProfileHash({ ...storedProfile, comfortableSixteenthBpm: 0 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + const handProfile = useMemo(() => storedProfile, [voicingProfileKey]); + const tuning = tuningForMode(tuningMode); + + const [view, setView] = useState(initialView); + const [handProfileOpen, setHandProfileOpen] = useState(false); + const [handProfileMounted, setHandProfileMounted] = useState(false); + + const openHandProfile = useCallback(() => { + setHandProfileMounted(true); + setHandProfileOpen(true); + }, []); + + const closeHandProfile = useCallback(() => setHandProfileOpen(false), []); const riffSteps = useRiffStore((state) => state.steps); const metronomeOn = useRiffStore((state) => state.metronomeOn); @@ -74,10 +165,10 @@ const App: React.FC = () => { resetLockState(); }, [tuningMode, vibeMode, resetLockState]); - // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark + // Keep the query string shareable: ?root=C&tuning=drop&vibe=dark&view=finder useEffect(() => { - syncUrlState(selectedRoot, tuningMode, vibeMode); - }, [selectedRoot, tuningMode, vibeMode]); + syncUrlState(selectedRoot, tuningMode, vibeMode, view); + }, [selectedRoot, tuningMode, vibeMode, view]); const handleUserInteraction = useCallback(async () => { if (!isAudioReady) { @@ -94,6 +185,8 @@ const App: React.FC = () => { }, [isDistorted, isAudioReady, handleUserInteraction, setIsDistorted]); const playChord = useCallback(async (chord: Chord) => { + // Unplayable cards have no shape, so there is nothing honest to play + if (!chord.voicing) return; await handleUserInteraction(); audioEngine.playChord(chord.notes); setActiveChordId(chord.id); @@ -118,16 +211,10 @@ const App: React.FC = () => { const loadFavoriteChords = async () => { try { const baseChords = await getChords(tuningMode, vibeMode); - const transposed = baseChords + const resolved = baseChords .filter((chord: Chord) => favorites.includes(chord.id)) - .map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - if (!cancelled) setFavoriteChords(transposed); + .map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile)); + if (!cancelled) setFavoriteChords(resolved); } catch { if (!cancelled) setFavoriteChords([]); } @@ -137,7 +224,7 @@ const App: React.FC = () => { return () => { cancelled = true; }; - }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot]); + }, [showFavoritesOnly, favorites, tuningMode, vibeMode, selectedRoot, handProfile]); const handleLockToggle = useCallback(async (chord: Chord) => { if (lockedChordId === chord.id) { @@ -149,16 +236,14 @@ const App: React.FC = () => { const baseChords = await getChords(tuningMode, vibeMode); - const originalParent = baseChords.find((p: Chord) => { - const transposed = transposeChord(p, selectedRoot, tuningMode); - return transposed.id === chord.id; - }); + // Ids are stable across roots, so the displayed id is the base id + const originalParent = baseChords.find((p: Chord) => p.id === chord.id); if (originalParent && originalParent.relatedChords && originalParent.relatedChords.length > 0) { - const transposedRelated = originalParent.relatedChords + const resolvedRelated = originalParent.relatedChords .slice(0, RELATED_CHORDS_COUNT) - .map((relatedChord: Chord) => transposeChord(relatedChord, selectedRoot, tuningMode)); - setRelatedChords(transposedRelated); + .map((relatedChord: Chord) => resolveLibraryChord(relatedChord, selectedRoot, tuningMode, handProfile)); + setRelatedChords(resolvedRelated); setTimeout(() => { relatedSectionRef.current?.scrollIntoView({ @@ -170,7 +255,7 @@ const App: React.FC = () => { setRelatedChords([]); } } - }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, setLockedChordId, setRelatedChords]); + }, [lockedChordId, playChord, selectedRoot, tuningMode, vibeMode, handProfile, setLockedChordId, setRelatedChords]); const isChordLocked = useCallback((chord: Chord) => { if (!lockedChordId) return false; @@ -181,10 +266,13 @@ const App: React.FC = () => { setIsLoadingChords(true); setDisplayedChords([]); setChordsToLoad(6); + // A quick root/tuning/vibe change must not let an older load overwrite newer cards + let cancelled = false; const loadChords = async () => { try { const baseChords = await getChords(tuningMode, vibeMode); + if (cancelled) return; if (!baseChords || baseChords.length === 0) { setDisplayedChords([]); @@ -194,33 +282,22 @@ const App: React.FC = () => { setTotalChordsAvailable(baseChords.length); + // Every tab on screen comes from an engine shape, so cards render already resolved const firstBatch = baseChords.slice(0, CHORDS_PER_BATCH); - const strippedChords = firstBatch.map((chord: Chord) => ({ - ...chord, - relatedChords: undefined - })); - - setDisplayedChords(strippedChords as Chord[]); + setDisplayedChords(firstBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile))); setIsLoadingChords(false); - - setTimeout(() => { - const transposed = firstBatch.map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - setDisplayedChords(transposed); - }, 150); } catch { + if (cancelled) return; setDisplayedChords([]); setIsLoadingChords(false); } }; loadChords(); - }, [selectedRoot, tuningMode, vibeMode, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]); + return () => { + cancelled = true; + }; + }, [selectedRoot, tuningMode, vibeMode, handProfile, setDisplayedChords, setIsLoadingChords, setChordsToLoad, setTotalChordsAvailable]); const loadMoreChords = useCallback(async () => { if (isLoadingChords) return; @@ -241,31 +318,14 @@ const App: React.FC = () => { return; } - setDisplayedChords([...displayedChords, ...(nextBatch as Chord[])]); + const resolved = nextBatch.map((chord: Chord) => resolveLibraryChord(chord, selectedRoot, tuningMode, handProfile)); + setDisplayedChords([...displayedChords, ...resolved]); incrementChordsToLoad(CHORDS_PER_BATCH); - - setTimeout(() => { - const transposed = nextBatch.map((chord: Chord) => { - try { - return transposeChord(chord, selectedRoot, tuningMode); - } catch { - return chord; - } - }); - - const newChords = [...displayedChords]; - const startIndex = displayedChords.length; - transposed.forEach((chord: Chord, i: number) => { - newChords[startIndex + i] = chord; - }); - setDisplayedChords(newChords); - }, 100); - setIsLoadingChords(false); } catch { setIsLoadingChords(false); } - }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]); + }, [chordsToLoad, tuningMode, vibeMode, selectedRoot, handProfile, isLoadingChords, displayedChords, setDisplayedChords, setIsLoadingChords, incrementChordsToLoad]); const gridChords = showFavoritesOnly ? favoriteChords : displayedChords; @@ -368,13 +428,39 @@ const App: React.FC = () => { isDistorted={isDistorted} /> - + {view === 'library' && ( + + )} -
+
+ + {view === 'library' && ( + )}
{ onSelectRoot={setSelectedRoot} isDistorted={isDistorted} /> + +
+ {view === 'finder' && ( +
+ }> + + +
+ )} + + {view === 'rhythm' && RHYTHM_LAB_ENABLED && ( +
+ }> + + +
+ )} + + {view === 'library' && ( + <>
{showFavoritesOnly && gridChords.length === 0 ? ( { > {
- + {lockedChord.name} @@ -573,6 +686,7 @@ const App: React.FC = () => { > { )} + + )} {
+ + {handProfileMounted && ( + + Loading... +

+ } + > + +
+ )}
); }; diff --git a/UPGRADE.md b/UPGRADE.md index c75e7ea..f166c87 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -60,6 +60,10 @@ Production'da debug loglari kalmamali. - [ ] 4-fret spread optimizasyonunu implement et VEYA - [ ] Yorumlanmis kodu tamamen sil +**Durum (2026-10-03): Gecersiz, `engine/` ile asildi.** `transposeTabs` kaldirildi. Kart tablari artik +`engine/` (parmak atamasi, mm cinsinden uzanma, barresiz uretim) tarafindan `utils/libraryVoicing.ts` +uzerinden uretiliyor; perde-acikligi kurali yerine kisisel el profili kullaniliyor. + --- ## Orta Oncelikli Upgrades diff --git a/components/ChordCard.tsx b/components/ChordCard.tsx index 10eeb44..580dc47 100644 --- a/components/ChordCard.tsx +++ b/components/ChordCard.tsx @@ -1,10 +1,13 @@ import React, { useRef, useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Chord } from '../types'; -import { buildChordExplorerUrl } from '../utils/chordExplorer'; +import type { Tuning } from '../engine/types'; +import { buildExplorerUrl } from '../utils/chordExplorer'; +import { VoicingTab } from './VoicingTab'; interface ChordCardProps { chord: Chord; + tuning: Tuning; isDistorted: boolean; onPlay: (chord: Chord) => void; onLockToggle?: (chord: Chord) => void; @@ -19,6 +22,7 @@ interface ChordCardProps { export const ChordCard: React.FC = ({ chord, + tuning, isDistorted, onPlay, onLockToggle, @@ -35,6 +39,12 @@ export const ChordCard: React.FC = ({ const [glowPosition, setGlowPosition] = useState({ x: 0, y: 0 }); const [isHovering, setIsHovering] = useState(false); const [hasAnimated, setHasAnimated] = useState(skipInitialAnimation); + // Link by what sounds; Chord Explorer is E Standard only, so a Drop D shape stays out of the link. + // No link when Chord Explorer has no type for the sound. + const explorerTarget = chord.voicing?.explorer ?? chord.explorer; + const explorerUrl = explorerTarget + ? buildExplorerUrl(explorerTarget, chord.voicing && tuning.id === 'e-standard' ? chord.voicing.shape : undefined) + : null; // Mark as animated after first render useEffect(() => { @@ -73,6 +83,7 @@ export const ChordCard: React.FC = ({ : 'rgba(8, 145, 178, 0.4)'; const accentColor = isDistorted ? 'rose' : 'cyan'; + const addable = canAddToRiff && !!chord.voicing; return ( = ({ }} /> - {/* Main Card */} - = ({ } ) }} - whileTap={{ scale: 0.98 }} - transition={{ type: 'spring', stiffness: 400, damping: 25 }} - onClick={(e) => { - const target = e.target as HTMLElement; - if (target.closest('button[aria-label="Lock as main chord"]')) { - e.stopPropagation(); - e.preventDefault(); - return; - } - }} > {/* Border Glow on Hover */} = ({ {/* Background Text Faded - First 2 letters of chord name */} ); }; diff --git a/components/DistortionSwitch.tsx b/components/DistortionSwitch.tsx index a06c3b8..eceedb8 100644 --- a/components/DistortionSwitch.tsx +++ b/components/DistortionSwitch.tsx @@ -90,9 +90,13 @@ const ChannelButton: React.FC = ({ return ( Array.from({ length: hi - lo + 1 }, (_, i) => lo + i); + +const presetIdFor = (mm: number): string | null => SCALE_LENGTH_PRESETS.find((p) => Math.abs(p.mm - mm) < 0.05)?.id ?? null; + +const useIsDesktop = (): boolean => { + const query = '(min-width: 768px)'; + const [matches, setMatches] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches); + useEffect(() => { + const mql = window.matchMedia(query); + const onChange = () => setMatches(mql.matches); + onChange(); + mql.addEventListener('change', onChange); + return () => mql.removeEventListener('change', onChange); + }, []); + return matches; +}; + +/** Clipboard API first, then a hidden textarea + execCommand for older or locked-down browsers. */ +const copyText = async (text: string, host: HTMLElement | null): Promise => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // Permission denied or insecure context: try the fallback + } + try { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'fixed'; + area.style.top = '0'; + area.style.left = '0'; + area.style.opacity = '0'; + (host ?? document.body).appendChild(area); + area.select(); + const ok = document.execCommand('copy'); + area.remove(); + return ok; + } catch { + return false; + } +}; + +const ringClass = (isDistorted: boolean): string => + `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-black ${isDistorted ? 'focus-visible:ring-rose-500' : 'focus-visible:ring-cyan-500'}`; + +const SectionLabel: React.FC<{ id?: string; children: React.ReactNode }> = ({ id, children }) => ( +

+ {children} +

+); + +interface RadioOption { + value: string; + label: string; + ariaLabel?: string; +} + +interface SegmentedRadioProps { + labelledBy: string; + describedBy?: string; + options: readonly RadioOption[]; + value: string; + onChange: (value: string) => void; + isDistorted: boolean; +} + +/** Single-select segmented control with radio semantics: one tab stop, arrow keys move the selection. */ +const SegmentedRadio: React.FC = ({ labelledBy, describedBy, options, value, onChange, isDistorted }) => { + const refs = useRef<(HTMLButtonElement | null)[]>([]); + const selected = options.findIndex((o) => o.value === value); + const tabStop = selected >= 0 ? selected : 0; + + const select = (index: number) => { + const next = (index + options.length) % options.length; + onChange(options[next].value); + refs.current[next]?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent, index: number) => { + const moves: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; + if (e.key in moves) { + e.preventDefault(); + select(index + moves[e.key]); + } else if (e.key === 'Home') { + e.preventDefault(); + select(0); + } else if (e.key === 'End') { + e.preventDefault(); + select(options.length - 1); + } + }; + + return ( +
+ {options.map((option, i) => { + const checked = i === selected; + return ( + + ); + })} +
+ ); +}; + +interface SwitchRowProps { + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + isDistorted: boolean; +} + +const SwitchRow: React.FC = ({ label, description, checked, onChange, isDistorted }) => { + const labelId = useId(); + const descriptionId = useId(); + return ( + + ); +}; + +interface StepperRowProps { + label: string; + unit: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + isDistorted: boolean; +} + +const StepperRow: React.FC = ({ label, unit, value, min, max, step, onChange, isDistorted }) => { + const labelId = useId(); + const clamp = (v: number) => Math.min(max, Math.max(min, v)); + const buttonClass = `w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border border-white/15 font-mono text-base text-neutral-200 hover:border-white/40 transition-colors duration-200 disabled:opacity-30 disabled:cursor-default ${ringClass(isDistorted)}`; + return ( +
+ {label} +
+ + + {value} + {unit} + + +
+
+ ); +}; + +interface HandProfilePanelProps { + open: boolean; + onClose: () => void; +} + +/** Hand profile drawer: right panel on md+, bottom sheet on mobile. Modal, focus-trapped, Escape closes. */ +export const HandProfilePanel: React.FC = ({ open, onClose }) => { + const isDistorted = useChordStore((s) => s.isDistorted); + const profile = useHandProfileStore((s) => s.profile); + const calibration = useHandProfileStore((s) => s.calibration); + const setCalibration = useHandProfileStore((s) => s.setCalibration); + const resetProfile = useHandProfileStore((s) => s.resetProfile); + const exportProfileJson = useHandProfileStore((s) => s.exportProfileJson); + + const reduceMotion = useReducedMotion(); + const isDesktop = useIsDesktop(); + const titleId = useId(); + const scaleLabelId = useId(); + const lowQuestionId = useId(); + const highQuestionId = useId(); + const customInputId = useId(); + const customErrorId = useId(); + + const panelRef = useRef(null); + const titleRef = useRef(null); + const copyButtonRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const [customMode, setCustomMode] = useState(() => presetIdFor(calibration.scaleLengthMm) === null); + const [customText, setCustomText] = useState(() => String(Math.round(calibration.scaleLengthMm * 10) / 10)); + const [status, setStatus] = useState(''); + + // Focus in on open, trap Tab, close on Escape, and hand focus back to the opener on close + useEffect(() => { + if (!open) return; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const frame = requestAnimationFrame(() => titleRef.current?.focus()); + + // Tab order only: roving radios (tabindex -1) and the programmatic title focus are not stops + const focusables = (): HTMLElement[] => + panelRef.current ? Array.from(panelRef.current.querySelectorAll(FOCUSABLE)).filter((el) => el.tabIndex >= 0) : []; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCloseRef.current(); + return; + } + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) { + e.preventDefault(); + return; + } + const index = items.indexOf(document.activeElement as HTMLElement); + if (e.shiftKey && index <= 0) { + e.preventDefault(); + items[items.length - 1].focus(); + } else if (!e.shiftKey && (index === -1 || index === items.length - 1)) { + e.preventDefault(); + items[0].focus(); + } + }; + // Anything that still lands focus outside (a click elsewhere, assistive tech) is pulled back in + const onFocusIn = (e: FocusEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) titleRef.current?.focus(); + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('focusin', onFocusIn); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('focusin', onFocusIn); + document.body.style.overflow = previousOverflow; + if (opener && opener.isConnected) opener.focus(); + }; + }, [open]); + + useEffect(() => { + if (!status) return; + const timer = setTimeout(() => setStatus(''), 4000); + return () => clearTimeout(timer); + }, [status]); + + const scaleValue = customMode ? CUSTOM_SCALE_ID : presetIdFor(calibration.scaleLengthMm) ?? CUSTOM_SCALE_ID; + const customNumber = Number(customText); + const customValid = customText.trim() !== '' && Number.isFinite(customNumber) && customNumber >= SCALE_MIN_MM && customNumber <= SCALE_MAX_MM; + + const onScaleChange = (id: string) => { + if (id === CUSTOM_SCALE_ID) { + setCustomMode(true); + setCustomText(String(Math.round(calibration.scaleLengthMm * 10) / 10)); + return; + } + const preset = SCALE_LENGTH_PRESETS.find((p) => p.id === id); + if (!preset) return; + setCustomMode(false); + setCalibration({ scaleLengthMm: preset.mm }); + }; + + const onCustomChange = (text: string) => { + setCustomText(text); + const mm = Number(text); + if (text.trim() !== '' && Number.isFinite(mm) && mm >= SCALE_MIN_MM && mm <= SCALE_MAX_MM) setCalibration({ scaleLengthMm: mm }); + }; + + const onCopy = useCallback(async () => { + const ok = await copyText(exportProfileJson(), panelRef.current); + copyButtonRef.current?.focus(); + setStatus(ok ? 'Profile JSON copied. Paste it into Chord Explorer to use the same hand there.' : 'Copy failed: the browser blocked clipboard access.'); + }, [exportProfileJson]); + + const onReset = () => { + resetProfile(); + setCustomMode(false); + setStatus('Hand profile reset to the defaults.'); + }; + + const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400'; + const pillClass = `flex items-center justify-center gap-1.5 px-4 h-11 md:h-9 rounded-full border font-mono text-[10px] uppercase tracking-widest transition-all duration-200 ${ringClass(isDistorted)}`; + + const transition = reduceMotion ? { duration: 0.12 } : { duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] as const }; + const hidden = reduceMotion ? { opacity: 0 } : isDesktop ? { x: '100%' } : { y: '100%' }; + const shown = reduceMotion ? { opacity: 1 } : isDesktop ? { x: 0 } : { y: 0 }; + + const lowReach = Math.round(profile.reachAtLowMm); + const highReach = Math.round(profile.reachAtHighMm); + const stretchOn = calibration.allowStretches; + + return ( + + {open && ( +
+
+ )} +
+ ); +}; diff --git a/components/RhythmLab.tsx b/components/RhythmLab.tsx new file mode 100644 index 0000000..b835978 --- /dev/null +++ b/components/RhythmLab.tsx @@ -0,0 +1,245 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { audioEngine } from '../services/audioEngine'; +import { useChordStore } from '../stores/chordStore'; +import { useRiffStore } from '../stores/riffStore'; +import { scheduleRhythmRestart, useRhythmHandProfile, useRhythmStore } from '../stores/rhythmStore'; +import { gridUnitTicks } from '../engine/rhythm/grid'; +import { rhythmToPlaybackEvents } from '../engine/rhythm/playback'; +import { validateRhythm } from '../engine/rhythm/validate'; +import type { HarmonySlot } from '../engine/types'; +import { tuningForMode } from '../utils/libraryVoicing'; +import { rhythmToMidi } from '../utils/midi'; +import { clickTimes, implicitPowerSlot, loopSeconds, secondsPerTick, toHarmonySlots } from '../utils/rhythmSlots'; +import { RhythmStylePanel } from './rhythm/RhythmStylePanel'; +import { RhythmTransport } from './rhythm/RhythmTransport'; +import { RhythmSlotsStrip } from './rhythm/RhythmSlotsStrip'; +import { RhythmGrid } from './rhythm/RhythmGrid'; +import { rhythmTheme } from './rhythm/theme'; + +/** Restart debounce while a slider is dragged during playback. */ +const RESTART_DELAY_MS = 60; + +/** + * Metal Rhythm Lab: seeded rhythm patterns over up to four chord slots, with playback, metronome, MIDI + * export, an editable grid and the fingering optimizer. Self-contained: reads the chord, riff and rhythm stores. + */ +export const RhythmLab: React.FC = () => { + const isDistorted = useChordStore((s) => s.isDistorted); + const tuningMode = useChordStore((s) => s.tuningMode); + const selectedRoot = useChordStore((s) => s.selectedRoot); + const riffStepCount = useRiffStore((s) => s.steps.length); + const profile = useRhythmHandProfile(); + + const pattern = useRhythmStore((s) => s.pattern); + const slots = useRhythmStore((s) => s.slots); + const bpm = useRhythmStore((s) => s.bpm); + const isPlaying = useRhythmStore((s) => s.isPlaying); + const metronomeOn = useRhythmStore((s) => s.metronomeOn); + const setBpm = useRhythmStore((s) => s.setBpm); + const setMetronome = useRhythmStore((s) => s.setMetronome); + const setIsPlaying = useRhythmStore((s) => s.setIsPlaying); + const newIdea = useRhythmStore((s) => s.newIdea); + const mutate = useRhythmStore((s) => s.mutate); + + const theme = rhythmTheme(isDistorted); + const tuning = tuningForMode(tuningMode); + const implicitSlot = useMemo(() => implicitPowerSlot(selectedRoot, tuning, profile), [selectedRoot, tuning, profile]); + const harmony: HarmonySlot[] = useMemo( + () => (slots.length > 0 ? toHarmonySlots(slots) : implicitSlot ? toHarmonySlots([implicitSlot]) : []), + [slots, implicitSlot] + ); + const slotLabels = useMemo(() => slots.map((s) => s.label), [slots]); + + const [announcement, setAnnouncement] = useState(''); + const announce = useCallback((message: string) => { + // Clearing first makes screen readers repeat an identical message + setAnnouncement(''); + window.setTimeout(() => setAnnouncement(message), 30); + }, []); + + // Slots follow the app tuning (Standard / Drop): re-voice the ones made for the other tuning + useEffect(() => { + const dropped = useRhythmStore.getState().retuneSlots(tuning, profile); + if (dropped > 0) announce(`${dropped} slot${dropped === 1 ? '' : 's'} had no playable voicing in ${tuning.name} and were removed.`); + }, [tuning, profile, announce]); + + const latest = useRef({ harmony, tuning }); + latest.current = { harmony, tuning }; + + const play = useCallback(() => { + const state = useRhythmStore.getState(); + const { pattern: current, bpm: tempo } = state; + const events = rhythmToPlaybackEvents(current, latest.current.harmony, latest.current.tuning, tempo); + audioEngine.playRhythm(events, { + loopSeconds: loopSeconds(current, tempo), + bpm: tempo, + clickTimes: clickTimes(current, tempo), + metronome: state.metronomeOn, + stepSeconds: gridUnitTicks(current.params.grid) * secondsPerTick(tempo), + onStep: (unit) => useRhythmStore.getState().setPlayheadUnit(unit) + }); + }, []); + + const startPlayback = useCallback(async () => { + const chord = useChordStore.getState(); + if (!chord.isAudioReady || !audioEngine.isReady()) { + await audioEngine.init(); + audioEngine.setDistortion(chord.isDistorted); + chord.setIsAudioReady(true); + } + // The RiffBar shares the Transport: stop it and reset its state before the rhythm takes over + const riff = useRiffStore.getState(); + if (riff.isPlaying || audioEngine.isSequencePlaying()) { + audioEngine.stopSequence(); + riff.setIsPlaying(false); + riff.setCurrentStep(-1); + } + play(); + setIsPlaying(true); + }, [play, setIsPlaying]); + + const stopPlayback = useCallback(() => { + audioEngine.stopRhythm(); + setIsPlaying(false); + }, [setIsPlaying]); + + // The RiffBar (playSequence) stops the rhythm through the engine: mirror it in the store + useEffect( + () => + audioEngine.onRhythmStopped(() => { + useRhythmStore.getState().setIsPlaying(false); + }), + [] + ); + + // Restart cleanly (same loop position) when the pattern, slots, tuning or tempo change mid-playback + useEffect(() => { + if (!useRhythmStore.getState().isPlaying) return; + return scheduleRhythmRestart(play, RESTART_DELAY_MS); + }, [pattern, harmony, tuning, bpm, play]); + + useEffect( + () => () => { + if (audioEngine.isRhythmPlaying()) audioEngine.stopRhythm(); + useRhythmStore.getState().setIsPlaying(false); + }, + [] + ); + + const handleMetronome = () => { + const next = !metronomeOn; + setMetronome(next); + audioEngine.setRhythmMetronome(next); + }; + + const handleNewIdea = () => { + newIdea(); + const { pattern: next, lockedBars } = useRhythmStore.getState(); + announce(`New idea: ${next.name}${lockedBars.length > 0 ? `, locked bars kept: ${lockedBars.map((b) => b + 1).join(', ')}` : ''}.`); + }; + + const handleMutate = () => { + const changed = mutate(); + announce(changed ? 'Mutated: one small change outside the locked bars.' : 'No mutation found outside the locked bars.'); + }; + + const handleExportMidi = () => { + const { pattern: current, bpm: tempo } = useRhythmStore.getState(); + const bytes = rhythmToMidi(current, harmony, tuning, tempo); + const blob = new Blob([bytes], { type: 'audio/midi' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `rhythm-${current.params.style}-${tempo}bpm.mid`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + announce(`Exported ${anchor.download}.`); + }; + + const handleUseRiffChords = () => { + const made = useRhythmStore.getState().useRiffBarChords(tuning, profile); + announce(made > 0 ? `${made} RiffBar chord${made === 1 ? '' : 's'} loaded as slots.` : 'No playable RiffBar chords to load.'); + }; + + const warnings = useMemo( + () => validateRhythm(pattern, { bpm, comfortableSixteenthBpm: profile.comfortableSixteenthBpm }).warnings, + [pattern, bpm, profile.comfortableSixteenthBpm] + ); + + const meter = `${pattern.meter.numerator}/${pattern.meter.denominator}`; + + return ( +
+
+
+

+ Rhythm Lab +

+

+ {pattern.name} // {meter} // {pattern.bars} bar{pattern.bars === 1 ? '' : 's'} // {tuning.name} +

+
+ {pattern.tags.length > 0 && ( +
    + {pattern.tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ )} +
+ +
+ + + + + + + + +
+ {warnings.map((w) => ( +

+ Tempo note + {w.message} Comfortable up to about {w.bpmLimit} BPM for this pattern. This is comfort guidance, not a + medical or safety assessment. Stop if you feel pain or tension. +

+ ))} +
+

+ {announcement} +

+
+
+ ); +}; diff --git a/components/RiffBar.tsx b/components/RiffBar.tsx index c900aec..5e50229 100644 --- a/components/RiffBar.tsx +++ b/components/RiffBar.tsx @@ -285,7 +285,7 @@ export const RiffBar: React.FC = () => { `} > {index + 1} - {step.name} + {step.name} + ); + })} + +); + +const ControlLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +const NoveltyMeter: React.FC<{ novelty: number; isDistorted: boolean }> = ({ novelty, isDistorted }) => { + const percent = noveltyPercent(novelty); + const lit = Math.round((percent / 100) * NOVELTY_SEGMENTS); + return ( +
+ Unusual +
+ ); +}; + +interface FinderCardProps { + voicing: GeneratedVoicing; + index: number; + root: string; + family: VoicingFamily; + tuning: Tuning; + profile: HandProfile; + showNovelty: boolean; + canAdd: boolean; + canSlot: boolean; + isDistorted: boolean; + reduceMotion: boolean; + onPlay: (voicing: GeneratedVoicing) => void; + onAdd: (voicing: GeneratedVoicing) => void; + onSlot: (voicing: GeneratedVoicing) => void; +} + +const FinderCard: React.FC = ({ + voicing, + index, + root, + family, + tuning, + profile, + showNovelty, + canAdd, + canSlot, + isDistorted, + reduceMotion, + onPlay, + onAdd, + onSlot +}) => { + const tab = toRiffForgeTab(voicing.shape); + const explorerUrl = finderExplorerUrl(root, family.id, voicing, tuning); + const tags = voicingTagLabels(voicing.tags); + const ring = ringClass(isDistorted); + const roundButton = `shrink-0 w-11 h-11 md:w-9 md:h-9 flex items-center justify-center rounded-full border transition-colors duration-200 ${ring}`; + + return ( + +
+
+

{voicing.name}

+

+ Degrees low to high: + {voicing.degrees} +

+
+ {showNovelty && } +
+ +
+ +
+ +

{explainVoicing(voicing, profile)}

+ + {tags.length > 0 && ( +
    + {tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ )} + +
+ + + + {explorerUrl && ( + + + + )} +
+
+ ); +}; + +interface VoicingFinderProps { + onOpenHandProfile: () => void; + handProfileOpen?: boolean; +} + +/** Playable voicings of one family on the selected root and tuning, ranked for the stored hand profile. */ +export const VoicingFinder: React.FC = ({ onOpenHandProfile, handProfileOpen = false }) => { + const isDistorted = useChordStore((s) => s.isDistorted); + const selectedRoot = useChordStore((s) => s.selectedRoot); + const tuningMode = useChordStore((s) => s.tuningMode); + const profile = useHandProfileStore((s) => s.profile); + const setCalibration = useHandProfileStore((s) => s.setCalibration); + const riffLength = useRiffStore((s) => s.steps.length); + const addStep = useRiffStore((s) => s.addStep); + const slotCount = useRhythmStore((s) => s.slots.length); + const addSlot = useRhythmStore((s) => s.addSlot); + const reduceMotion = useReducedMotion() ?? false; + + const titleId = useId(); + const statusRef = useRef(null); + const [familyId, setFamilyId] = useState(FINDER_DEFAULT_FAMILY); + const [sort, setSort] = useState('easiest'); + const [stringGroup, setStringGroup] = useState('all'); + const [pedalOnly, setPedalOnly] = useState(false); + // "One more muted inner string" is not a calibration answer: it stays a finder-only override for one query + const [extraMuteFor, setExtraMuteFor] = useState(null); + const [actionMessage, setActionMessage] = useState(''); + + const tuning = tuningForMode(tuningMode); + const family = getFamily(familyId) ?? (getFamily(FINDER_DEFAULT_FAMILY) as VoicingFamily); + const overrideKey = `${selectedRoot}|${family.id}|${tuning.id}`; + const extraMute = extraMuteFor === overrideKey; + + const effectiveProfile = useMemo( + () => (extraMute ? { ...profile, maxInteriorMutes: profile.maxInteriorMutes + 1 } : profile), + [profile, extraMute] + ); + + const query: FinderQuery = useMemo( + () => ({ root: selectedRoot, familyId: family.id, tuning, profile: effectiveProfile, distortion: isDistorted, sort }), + [selectedRoot, family.id, tuning, effectiveProfile, isDistorted, sort] + ); + const result = useMemo(() => findVoicings(query), [query]); + const filtered = useMemo(() => filterVoicings(result.voicings, { stringGroup, pedalOnly }), [result, stringGroup, pedalOnly]); + const shown = filtered.slice(0, FINDER_SHOW_LIMIT); + const hints = useMemo(() => (result.voicings.length === 0 ? relaxationHints(query, extraMute) : []), [result, query, extraMute]); + + const filtersActive = stringGroup !== 'all' || pedalOnly; + const capped = !filtersActive && result.voicings.length >= FINDER_GENERATE_LIMIT; + const announcement = resultAnnouncement(filtered.length, selectedRoot, family); + const canAdd = riffLength < MAX_RIFF_STEPS; + + useEffect(() => { + if (!actionMessage) return; + const timer = setTimeout(() => setActionMessage(''), 3000); + return () => clearTimeout(timer); + }, [actionMessage]); + + const playVoicing = useCallback( + async (voicing: GeneratedVoicing) => { + const chord = useChordStore.getState(); + if (!chord.isAudioReady) { + await audioEngine.init(); + audioEngine.setDistortion(chord.isDistorted); + chord.setIsAudioReady(true); + } + // Same shape as the tab on the card + audioEngine.playChord(shapeToMidi(voicing.shape, tuning).map(midiToName)); + }, + [tuning] + ); + + const addVoicing = useCallback( + (voicing: GeneratedVoicing) => { + const tab = toRiffForgeTab(voicing.shape); + const added = addStep({ + id: `finder:${family.id}:${tab}`, + name: voicing.name, + subtext: `${family.label} · ${tab}`, + notes: shapeToMidi(voicing.shape, tuning).map(midiToName) + }); + setActionMessage(added ? `Added ${voicing.name} to the riff.` : `The riff is full (${MAX_RIFF_STEPS} steps).`); + }, + [addStep, family, tuning] + ); + + const slotVoicing = useCallback( + (voicing: GeneratedVoicing) => { + const added = addSlot({ + label: voicing.name, + root: selectedRoot, + source: { kind: 'family', familyId: voicing.familyId }, + shape: [...voicing.shape], + tuningId: tuning.id + }); + setActionMessage( + added + ? `${voicing.name} is now Rhythm Lab slot ${useRhythmStore.getState().slots.length}.` + : `The Rhythm Lab already has ${MAX_RHYTHM_SLOTS} chord slots.` + ); + }, + [addSlot, selectedRoot, tuning] + ); + + const applyHint = (hint: RelaxationHint) => { + if (hint.kind === 'calibration') setCalibration(hint.patch); + else setExtraMuteFor(overrideKey); + // The hint button disappears with the empty state; keep keyboard focus on the result status + requestAnimationFrame(() => statusRef.current?.focus()); + }; + + const clearFilters = () => { + setStringGroup('all'); + setPedalOnly(false); + requestAnimationFrame(() => statusRef.current?.focus()); + }; + + const accentText = isDistorted ? 'text-rose-400' : 'text-cyan-400'; + const ring = ringClass(isDistorted); + + return ( +
+
+
+
+

+ Voicing finder +

+

+ {selectedRoot} + + in + {tuning.name} +

+

{profileSummary(effectiveProfile, isDistorted)}

+
+ +
+ +
+ {FAMILY_GROUPS.map((group) => ( +
+ {group.label} +
+ {group.familyIds.map((id) => { + const f = getFamily(id); + if (!f) return null; + const active = id === family.id; + return ( + + ); + })} +
+
+ ))} +
+ +

{family.description}

+ +
+
+ Sort + setSort(v as VoicingSort)} isDistorted={isDistorted} /> +
+
+ Strings + setStringGroup(v as StringGroupFilter)} + isDistorted={isDistorted} + /> +
+
+ Pedal + +
+
+
+ +
+

+ + {filtered.length} + {capped ? '+' : ''} + {' '} + {filtered.length === 1 ? 'voicing' : 'voicings'} for {familyTitle(selectedRoot, family)} + {filtered.length > shown.length ? ` · showing ${shown.length}` : ''} + {filtersActive && result.voicings.length > filtered.length ? ` · ${result.voicings.length - filtered.length} filtered out` : ''} +

+ {extraMute && ( + + )} +
+ +

+ {announcement} +

+

+ {actionMessage} +

+ + {shown.length > 0 ? ( +
+ {shown.map((voicing, index) => ( + + ))} +
+ ) : ( +
+ {result.voicings.length === 0 ? ( + <> +

No playable shape here

+

+ {result.empty?.message ?? 'No voicing fits this recipe and your hand profile.'} +

+ {hints.length > 0 && ( +
+ {hints.map((hint) => ( + + ))} +
+ )} + + ) : ( + <> +

No voicings match these filters

+

+ {result.voicings.length} {result.voicings.length === 1 ? 'voicing fits' : 'voicings fit'} your hand without them. +

+ + + )} +
+ )} +
+ ); +}; + +const HandIcon: React.FC = () => ( + +); diff --git a/components/VoicingTab.tsx b/components/VoicingTab.tsx new file mode 100644 index 0000000..6c40479 --- /dev/null +++ b/components/VoicingTab.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import type { FingerNumber, Tuning } from '../engine/types'; +import { pitchClassName } from '../engine/pitch'; + +interface VoicingTabProps { + shape: readonly (number | null)[]; + fingers: readonly (FingerNumber | null)[]; + degreesByString: readonly (string | null)[]; + tuning: Tuning; + isDistorted: boolean; + compact?: boolean; +} + +const FINGER_LABEL: Record = { 0: 'T', 1: '1', 2: '2', 3: '3', 4: '4' }; +const FINGER_NAME: Record = { 0: 'thumb', 1: 'index', 2: 'middle', 3: 'ring', 4: 'pinky' }; + +/** Screen-reader summary, low string first: "E string open, A string fret 2 middle finger, ...". */ +export const describeVoicing = ( + shape: readonly (number | null)[], + fingers: readonly (FingerNumber | null)[], + tuning: Tuning +): string => + shape + .map((fret, s) => { + const string = `${pitchClassName(tuning.openMidi[s])} string`; + if (fret === null) return `${string} muted`; + if (fret === 0) return `${string} open`; + const finger = fingers[s]; + return `${string} fret ${fret}${finger !== null && finger !== undefined ? ` ${FINGER_NAME[finger]} finger` : ''}`; + }) + .join(', '); + +/** Tab grid, low string on the left: string names, frets, finger numbers and degrees per string. */ +export const VoicingTab: React.FC = ({ shape, fingers, degreesByString, tuning, isDistorted, compact = false }) => { + const accent = isDistorted ? 'text-rose-500' : 'text-cyan-500'; + const glow = isDistorted ? '0 0 8px rgba(225, 29, 72, 0.6)' : '0 0 8px rgba(8, 145, 178, 0.6)'; + const cell = compact ? 'w-6' : 'w-7'; + const rowLabel = 'w-11 shrink-0 text-left font-mono text-[11px] uppercase tracking-wider text-neutral-400'; + + return ( +
+

{describeVoicing(shape, fingers, tuning)}

+ +
+ ); +}; diff --git a/components/rhythm/RhythmGrid.tsx b/components/rhythm/RhythmGrid.tsx new file mode 100644 index 0000000..159a1db --- /dev/null +++ b/components/rhythm/RhythmGrid.tsx @@ -0,0 +1,452 @@ +import React, { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useReducedMotion } from 'framer-motion'; +import type { RhythmPattern } from '../../engine/types'; +import { barUnits, buildGroups, cycleGrouping, isPolymeter } from '../../engine/rhythm/grid'; +import { useRhythmStore } from '../../stores/rhythmStore'; +import { + describeCell, + gridColumns, + laneIdOf, + rhythmLanes, + toggleTargetFor, + unitPosition, + type GridColumn, + type RhythmLane, + type UnitPosition +} from '../../utils/rhythmSlots'; +import { SECTION_LABEL, SEGMENT_GROUP, segmentClass, type RhythmTheme } from './theme'; + +type EditKind = 'hit' | 'accent' | 'palmMute' | 'clear'; + +const TOOLS: { id: EditKind; label: string; key: string }[] = [ + { id: 'hit', label: 'Hit', key: 'Space' }, + { id: 'accent', label: 'Accent', key: 'A' }, + { id: 'palmMute', label: 'PM', key: 'P' }, + { id: 'clear', label: 'Clear', key: 'Del' } +]; + +const TOOL_NAMES: Record = { hit: 'Toggle hit', accent: 'Cycle accent', palmMute: 'Toggle palm mute', clear: 'Clear cell' }; + +interface RhythmGridProps { + theme: RhythmTheme; + slotLabels: readonly string[]; + onAnnounce: (message: string) => void; +} + +const GLYPH: Record = { pedal: '●', slot: '■', dyad: 'dy', dead: 'x' }; + +interface CellProps { + laneIndex: number; + lane: RhythmLane; + column: GridColumn; + position: UnitPosition; + groupStart: boolean; + locked: boolean; + focused: boolean; + label: string; + theme: RhythmTheme; + register: (key: string, el: HTMLDivElement | null) => void; + onActivate: (laneIndex: number, unit: number) => void; + onFocusCell: (laneIndex: number, unit: number) => void; +} + +const Cell: React.FC = memo( + ({ laneIndex, lane, column, position, groupStart, locked, focused, label, theme, register, onActivate, onFocusCell }) => { + const own = column.lane === lane.id ? column.events.filter((e) => laneIdOf(e.target) === lane.id) : []; + const first = own[0]; + const held = !first && column.heldLane === lane.id; + const attack = first && !first.tie; + const kind = first?.target.kind; + const separator = position.isBarStart + ? 'border-l-2 border-l-white/40' + : position.isBeatStart + ? 'border-l border-l-white/20' + : 'border-l border-l-white/[0.05]'; + const hitStyle = !first + ? '' + : first.tie + ? `border border-dashed ${theme.border}` + : kind === 'pedal' + ? 'bg-neutral-700/60 border border-neutral-400/50' + : kind === 'dead' + ? 'border border-dashed border-neutral-400/70' + : `border ${theme.hitChord}`; + + return ( +
register(`${laneIndex}:${column.unit}`, el)} + role="gridcell" + tabIndex={focused ? 0 : -1} + aria-label={label} + onClick={() => onActivate(laneIndex, column.unit)} + onFocus={() => onFocusCell(laneIndex, column.unit)} + className={`relative shrink-0 w-[var(--cell)] h-11 md:h-10 cursor-pointer select-none ${separator} ${ + locked ? theme.lockedCell : '' + } hover:bg-white/[0.04] ${theme.ringInset}`} + > + {groupStart && !position.isBarStart && ( +
+ ); + } +); +Cell.displayName = 'RhythmGridCell'; + +/** Playhead column highlight; subscribes on its own so the grid does not re-render on every step. */ +const Playhead: React.FC<{ theme: RhythmTheme; reduceMotion: boolean }> = ({ theme, reduceMotion }) => { + const unit = useRhythmStore((s) => s.playheadUnit); + const isPlaying = useRhythmStore((s) => s.isPlaying); + const previous = useRef(-1); + const wrapped = unit < previous.current; + useEffect(() => { + previous.current = unit; + }, [unit]); + if (!isPlaying || unit < 0) return null; + return ( +