mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
UI consolidation round: pace checkbox, unified Limit notes section, fixes
- Pace dropdown REMOVED from Phrase setup — replaced by a 'Sync to exercise' checkbox in the Metronome widget (analog to 'Auto' in single-note modes). pace is derived from the checkbox (checked = tempo, unchecked = step). - Preview clicks now registered in PREVIEW_GROUP: stopping/abandoning a preview cancels its scheduled clicks — no more parallel ghost metronomes after preview->stop->next exercise. - Chord pool replaces the chord dropdown entirely (single chord AND sequences): togglable degree list, empty = all 7. - Giuliani figures corrected to NOTE sequences (p always takes the bass/ root): p-i-m, p-i-m-a, p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m, p-e-a-m-a-m (replaced the fingering-driven patterns that made no musical sense). - 'Limit notes' is now one bordered section like Tuner/Metronome, with a header, Clear-all link, and Strings behind a toggle (row hidden until restricted).
This commit is contained in:
@@ -140,8 +140,10 @@ class AudioEngine {
|
|||||||
* Short NON-TONAL metronome click (~10 ms noise burst). Not registered in
|
* Short NON-TONAL metronome click (~10 ms noise burst). Not registered in
|
||||||
* the pitched-audio gate; instead the mic is blanked for 30 ms so the
|
* the pitched-audio gate; instead the mic is blanked for 30 ms so the
|
||||||
* click itself can't be scored as a pitch (contract E2).
|
* click itself can't be scored as a pitch (contract E2).
|
||||||
|
* Optional groupId: the click becomes cancellable via cancelGroup (used
|
||||||
|
* by preview clicks so stopping a preview also stops its clicks).
|
||||||
*/
|
*/
|
||||||
playClickAt(time: number, volume = 0.5, accent = false): void {
|
playClickAt(time: number, volume = 0.5, accent = false, groupId?: string): void {
|
||||||
const ctx = this.ensureContext();
|
const ctx = this.ensureContext();
|
||||||
if (!this.noiseBuffer) {
|
if (!this.noiseBuffer) {
|
||||||
const len = Math.floor(ctx.sampleRate * 0.01);
|
const len = Math.floor(ctx.sampleRate * 0.01);
|
||||||
@@ -160,6 +162,11 @@ class AudioEngine {
|
|||||||
src.start(time);
|
src.start(time);
|
||||||
src.stop(time + 0.02);
|
src.stop(time + 0.02);
|
||||||
this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03);
|
this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03);
|
||||||
|
if (groupId) {
|
||||||
|
// Register as a cancellable node (cast: clicks are buffer sources,
|
||||||
|
// cancelGroup only calls stop() on them, which BufferSource has).
|
||||||
|
this.active.push({ osc: src as unknown as OscillatorNode, gain: gainNode, endsAt: time + 0.02, groupId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True for ~30 ms after a scheduled click (mic frames should be skipped). */
|
/** True for ~30 ms after a scheduled click (mic frames should be skipped). */
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ interface MetronomeWidgetProps {
|
|||||||
showAutoAdvance?: boolean;
|
showAutoAdvance?: boolean;
|
||||||
/** Compact layout (phrase transport row). */
|
/** Compact layout (phrase transport row). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** 'option': show the sync/free toggle (Phrase Mode). null: plain on/off. */
|
/** 'option': show the sync checkbox (Phrase Mode) that replaces the pace
|
||||||
|
* dropdown: checked = clicks/beats only with the exercise (preview &
|
||||||
|
* run, count-in included); unchecked = free-running metronome.
|
||||||
|
* null: plain on/off (single-note modes). */
|
||||||
syncMode?: 'option' | null;
|
syncMode?: 'option' | null;
|
||||||
/** External gate (sync mode): true while the exercise runs. */
|
/** External gate (sync mode): true while the exercise runs. */
|
||||||
gate?: boolean | null;
|
gate?: boolean | null;
|
||||||
@@ -85,18 +88,14 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
<span>Metronome</span>
|
<span>Metronome</span>
|
||||||
</label>
|
</label>
|
||||||
{syncMode === 'option' ? (
|
{syncMode === 'option' ? (
|
||||||
<button
|
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
className={`switch-button ${rhythm.syncToExercise ? 'active' : ''}`}
|
<input
|
||||||
onClick={() => onUpdate({
|
type="checkbox"
|
||||||
syncToExercise: !rhythm.syncToExercise,
|
checked={!!rhythm.syncToExercise}
|
||||||
active: true, // armed in either mode; the mode decides behavior
|
onChange={(e) => onUpdate({ syncToExercise: e.target.checked })}
|
||||||
})}
|
/>
|
||||||
title={rhythm.syncToExercise
|
Sync to exercise
|
||||||
? 'Sync: starts with the exercise (count-in). Click while idle: off.'
|
</label>
|
||||||
: 'Free: runs independently — start/stop it yourself.'}
|
|
||||||
>
|
|
||||||
<div className="switch-thumb" />
|
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className={`switch-button ${rhythm.active ? 'active' : ''}`}
|
className={`switch-button ${rhythm.active ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -82,16 +82,18 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
// (single source of truth across modes; the phrase-local BPM control
|
// (single source of truth across modes; the phrase-local BPM control
|
||||||
// was replaced by the common metronome widget in Controls).
|
// was replaced by the common metronome widget in Controls).
|
||||||
phrase.bpm = settings.rhythm.bpm;
|
phrase.bpm = settings.rhythm.bpm;
|
||||||
|
// Pace is now DERIVED from the metronome widget's sync checkbox:
|
||||||
|
// checked = tempo (sync), unchecked = step (at your pace).
|
||||||
|
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
|
||||||
|
phrase.pace = pace;
|
||||||
// Click decision — ONE source: the metronome widget's sound checkbox.
|
// Click decision — ONE source: the metronome widget's sound checkbox.
|
||||||
// - Tempo + syncToExercise: the run clicks when armed && sound
|
// - Sync (tempo): run clicks when armed && sound (count-in incl.);
|
||||||
// (silent while idle; starts with the exercise incl. count-in).
|
// widget silent while idle.
|
||||||
// - Tempo + free metronome: the widget clicks freely; the run stays
|
// - Free metronome: widget clicks freely; the run stays silent to
|
||||||
// silent (its beats are visual only) to avoid doubling.
|
// avoid doubling (sync off in tempo = free widget during runs).
|
||||||
// - Step: widget clicks freely; phrase clickSound follows the switch.
|
phrase.clickSound = pace === 'tempo'
|
||||||
const sync = settings.rhythm.syncToExercise;
|
? settings.rhythm.active && settings.rhythm.sound
|
||||||
phrase.clickSound = phrase.pace === 'tempo'
|
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
|
||||||
? (sync ? settings.rhythm.active && settings.rhythm.sound : false)
|
|
||||||
: settings.rhythm.active && settings.rhythm.sound;
|
|
||||||
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
||||||
updateSettings(s => {
|
updateSettings(s => {
|
||||||
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
|
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
|
||||||
@@ -616,13 +618,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
|
|
||||||
{phrase.material === 'arpeggio' && (
|
{phrase.material === 'arpeggio' && (
|
||||||
<>
|
<>
|
||||||
<label>Chord
|
|
||||||
<select value={phrase.arpeggioDegree} onChange={e => setPhrase({ arpeggioDegree: e.target.value, arpeggioBars: 1 })}>
|
|
||||||
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => (
|
|
||||||
<option key={d} value={d}>{ARPEGGIO_DEGREE_LABELS[d]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>Pattern
|
<label>Pattern
|
||||||
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
|
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
|
||||||
<option value="up">Up</option>
|
<option value="up">Up</option>
|
||||||
@@ -633,12 +628,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
<option value="giuliani-pim">p-i-m</option>
|
<option value="giuliani-pim">p-i-m</option>
|
||||||
<option value="giuliani-pima">p-i-m-a</option>
|
<option value="giuliani-pima">p-i-m-a</option>
|
||||||
<option value="giuliani-pami">p-a-m-i</option>
|
<option value="giuliani-pami">p-a-m-i</option>
|
||||||
<option value="giuliani-mami">m-a-m-i</option>
|
<option value="giuliani-aim">a-i-m</option>
|
||||||
<option value="giuliani-ami">a-m-i</option>
|
|
||||||
<option value="giuliani-pimamim">p-i-m-a-m-i</option>
|
<option value="giuliani-pimamim">p-i-m-a-m-i</option>
|
||||||
<option value="giuliani-papima">p-a-p-i-m-a</option>
|
<option value="giuliani-pmamim">p-m-a-m-i-m</option>
|
||||||
<option value="giuliani-amipim">a-m-i-p-i-m</option>
|
<option value="giuliani-peamama">p-e-a-m-a-m</option>
|
||||||
<option value="giuliani-miamim">m-i-a-m-i-m</option>
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -662,35 +655,32 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{phrase.arpeggioBars > 1 && (
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
|
Chords ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
|
||||||
Chord pool ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
|
</span>
|
||||||
</span>
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
|
||||||
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
|
const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d);
|
||||||
const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d);
|
return (
|
||||||
return (
|
<button
|
||||||
<button
|
key={d}
|
||||||
key={d}
|
className={`control-button small ${active ? 'active' : ''}`}
|
||||||
className={`control-button small ${active ? 'active' : ''}`}
|
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
|
||||||
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
|
onClick={() => {
|
||||||
onClick={() => {
|
const cur = phrase.arpeggioChordSelection.length === 0
|
||||||
// Toggle; empty selection = all back on.
|
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
|
||||||
const cur = phrase.arpeggioChordSelection.length === 0
|
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
|
||||||
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
|
const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d];
|
||||||
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
|
setPhrase({ arpeggioChordSelection: next });
|
||||||
const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d];
|
}}
|
||||||
setPhrase({ arpeggioChordSelection: next });
|
>
|
||||||
}}
|
{ARPEGGIO_DEGREE_LABELS[d]}
|
||||||
>
|
</button>
|
||||||
{ARPEGGIO_DEGREE_LABELS[d]}
|
);
|
||||||
</button>
|
})}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
<label>Rhythm
|
<label>Rhythm
|
||||||
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}>
|
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}>
|
||||||
<option value="quarters">Quarter notes</option>
|
<option value="quarters">Quarter notes</option>
|
||||||
@@ -763,28 +753,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label>Pace
|
{/* Pace lives in the metronome widget now:
|
||||||
<select
|
'Sync to exercise' checked = tempo pace,
|
||||||
value={phrase.pace}
|
unchecked = at-your-pace (step). */}
|
||||||
onChange={e => {
|
|
||||||
const pace = e.target.value as PhraseSettings['pace'];
|
|
||||||
setPhrase({ pace });
|
|
||||||
// "Sync to metronome" only makes sense with the
|
|
||||||
// metronome running — switch it on automatically.
|
|
||||||
if (pace === 'tempo' && !settings.rhythm.active) {
|
|
||||||
setSettings(s => ({ ...s, rhythm: { ...s.rhythm, active: true } }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="step">At your pace</option>
|
|
||||||
<option value="tempo">Sync to metronome</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{phrase.pace === 'tempo' && (
|
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
|
||||||
Tempo (BPM): set it on the metronome in the tools below — shared across modes.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<label className="phrase-check">
|
<label className="phrase-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -795,7 +766,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
</label>
|
</label>
|
||||||
{phrase.pace === 'tempo' && (
|
{phrase.pace === 'tempo' && (
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
||||||
Clicks: use the metronome's "Click sound" checkbox (with count-in, synced).
|
BPM + click: on the metronome widget below.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<label className="phrase-check">
|
<label className="phrase-check">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* (mirrored into phrase settings), so state stays consistent across modes.
|
* (mirrored into phrase settings), so state stays consistent across modes.
|
||||||
*/
|
*/
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Key, Guitar, Music } from 'lucide-react';
|
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
|
||||||
import { useSettings } from '../context/useSettings';
|
import { useSettings } from '../context/useSettings';
|
||||||
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
|
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
|
||||||
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
|
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
|
||||||
@@ -81,14 +81,32 @@ export const TargetNoteControls: React.FC = () => {
|
|||||||
|
|
||||||
const allSelected = pf.strings.length === 0;
|
const allSelected = pf.strings.length === 0;
|
||||||
|
|
||||||
|
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="practice-filter-grid">
|
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
|
||||||
|
{/* Section header: unified with Tuner/Metronome style */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
<span>Limit notes</span>
|
||||||
|
</label>
|
||||||
|
{anyActive && (
|
||||||
|
<button
|
||||||
|
className="link-button"
|
||||||
|
style={{ fontSize: '11px', padding: 0 }}
|
||||||
|
onClick={() => setFilter({ keyEnabled: false, fretWindowEnabled: false, strings: [] })}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Key filter */}
|
{/* Key filter */}
|
||||||
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
|
<div className="control-group">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<Key size={18} />
|
<Key size={18} />
|
||||||
<span>Target key (limits notes)</span>
|
<span>Key</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
||||||
@@ -149,13 +167,17 @@ export const TargetNoteControls: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<Guitar size={18} />
|
<Guitar size={18} />
|
||||||
<span>Strings (limits notes)</span>
|
<span>Strings</span>
|
||||||
</label>
|
</label>
|
||||||
{allSelected && (
|
<button
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7 }}>all</span>
|
className={`switch-button ${!allSelected ? 'active' : ''}`}
|
||||||
)}
|
onClick={() => setFilter({ strings: allSelected ? [0] : [] })}
|
||||||
|
title={allSelected ? 'All strings — toggle on to restrict' : 'Restrict to selected strings'}
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
|
{!allSelected && <div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
|
||||||
{Array.from({ length: stringCount }, (_, i) => {
|
{Array.from({ length: stringCount }, (_, i) => {
|
||||||
const active = allSelected || pf.strings.includes(i);
|
const active = allSelected || pf.strings.includes(i);
|
||||||
return (
|
return (
|
||||||
@@ -170,7 +192,7 @@ export const TargetNoteControls: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>}
|
||||||
{pf.strings.length > 0 && (
|
{pf.strings.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="link-button"
|
className="link-button"
|
||||||
@@ -189,7 +211,7 @@ export const TargetNoteControls: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<Music size={18} />
|
<Music size={18} />
|
||||||
<span>Fret window (limits notes)</span>
|
<span>Fret window</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}
|
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}
|
||||||
|
|||||||
@@ -507,11 +507,13 @@ export function usePhraseTrainer(
|
|||||||
}
|
}
|
||||||
// Tempo pace: click along with the preview so the rhythm is
|
// Tempo pace: click along with the preview so the rhythm is
|
||||||
// audible while listening (quarter beats over the full span).
|
// audible while listening (quarter beats over the full span).
|
||||||
|
// Registered in PREVIEW_GROUP so stopping the preview also stops
|
||||||
|
// its clicks (no ghost metronomes after an aborted preview).
|
||||||
if (paceRef.current === 'tempo' && configRef.current.clickSound) {
|
if (paceRef.current === 'tempo' && configRef.current.clickSound) {
|
||||||
const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0);
|
const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0);
|
||||||
const beats = Math.floor(totalTicks / PPQ);
|
const beats = Math.floor(totalTicks / PPQ);
|
||||||
for (let i = 0; i < beats; i++) {
|
for (let i = 0; i < beats; i++) {
|
||||||
audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0);
|
audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0, PREVIEW_GROUP);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPhaseBoth('preview');
|
setPhaseBoth('preview');
|
||||||
|
|||||||
@@ -21,12 +21,10 @@ export type ArpeggioPattern =
|
|||||||
| 'giuliani-pim' // root-third-fifth (p-i-m)
|
| 'giuliani-pim' // root-third-fifth (p-i-m)
|
||||||
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a)
|
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a)
|
||||||
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i)
|
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i)
|
||||||
| 'giuliani-mami' // third-fifth-octave-third (inner-voice feel)
|
| 'giuliani-aim' // root-octave-third (upper voices first)
|
||||||
| 'giuliani-ami' // octave-fifth-third (descending from the top)
|
|
||||||
| 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure)
|
| 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure)
|
||||||
| 'giuliani-papima' // p-a-p-i-m-a (bass-skip pattern)
|
| 'giuliani-pmamim' // p-m-a-m-i-m (asymmetric upper-voice figure)
|
||||||
| 'giuliani-amipim' // a-m-i-p-i-m (tremolo-adjacent)
|
| 'giuliani-peamama'; // p-e-a-m-a-m (wide bass + upper pairs)
|
||||||
| 'giuliani-miamim'; // m-i-a-m-i-m (triple-string figure)
|
|
||||||
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
|
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
|
||||||
/** How the chord per bar is chosen in sequence mode. */
|
/** How the chord per bar is chosen in sequence mode. */
|
||||||
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
|
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
|
||||||
@@ -65,29 +63,34 @@ export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
|
|||||||
'giuliani-pim': 'Giuliani p-i-m',
|
'giuliani-pim': 'Giuliani p-i-m',
|
||||||
'giuliani-pima': 'Giuliani p-i-m-a',
|
'giuliani-pima': 'Giuliani p-i-m-a',
|
||||||
'giuliani-pami': 'Giuliani p-a-m-i',
|
'giuliani-pami': 'Giuliani p-a-m-i',
|
||||||
'giuliani-mami': 'Giuliani m-a-m-i',
|
'giuliani-aim': 'Giuliani a-i-m',
|
||||||
'giuliani-ami': 'Giuliani a-m-i',
|
|
||||||
'giuliani-pimamim': 'Giuliani p-i-m-a-m-i',
|
'giuliani-pimamim': 'Giuliani p-i-m-a-m-i',
|
||||||
'giuliani-papima': 'Giuliani p-a-p-i-m-a',
|
'giuliani-pmamim': 'Giuliani p-m-a-m-i-m',
|
||||||
'giuliani-amipim': 'Giuliani a-m-i-p-i-m',
|
'giuliani-peamama': 'Giuliani p-e-a-m-a-m',
|
||||||
'giuliani-miamim': 'Giuliani m-i-a-m-i-m',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Giuliani figures: chord-cycle index per note (0=root 1=third 2=fifth
|
* Giuliani figures — NOTE sequences (not fingerings): the thumb (p) always
|
||||||
* 3=octave). Each figure repeats with an octave register shift per
|
* takes the bass/root, the remaining notes ride the upper chord tones.
|
||||||
* repetition, mirroring the p-i-m-a feel of the 120 right-hand studies.
|
* Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave]; the
|
||||||
|
* figure repeats with an ascending octave shift per repetition (the bass
|
||||||
|
* stays in place — like the Maestoso studies).
|
||||||
*/
|
*/
|
||||||
const GIULIANI_FIGURES: Record<string, number[]> = {
|
const GIULIANI_FIGURES: Record<string, number[]> = {
|
||||||
|
// Studies 1–20 feel: bass + two upper voices, ascending
|
||||||
'giuliani-pim': [0, 1, 2],
|
'giuliani-pim': [0, 1, 2],
|
||||||
|
// Studies 21–40 feel: bass + three upper voices
|
||||||
'giuliani-pima': [0, 1, 2, 3],
|
'giuliani-pima': [0, 1, 2, 3],
|
||||||
|
// Descending upper voices after the bass
|
||||||
'giuliani-pami': [0, 3, 2, 1],
|
'giuliani-pami': [0, 3, 2, 1],
|
||||||
'giuliani-mami': [1, 2, 3, 1],
|
// Upper voices first (a-i-m), bass anchored at start of each cycle
|
||||||
'giuliani-ami': [3, 2, 1],
|
'giuliani-aim': [0, 3, 1],
|
||||||
|
// Six-note rolling figure (studies 61–80 territory)
|
||||||
'giuliani-pimamim': [0, 1, 2, 3, 2, 1],
|
'giuliani-pimamim': [0, 1, 2, 3, 2, 1],
|
||||||
'giuliani-papima': [0, 3, 0, 1, 2, 3],
|
// Asymmetric: bass + upper triad roll
|
||||||
'giuliani-amipim': [3, 2, 1, 0, 1, 2],
|
'giuliani-pmamim': [0, 2, 3, 2, 1, 2],
|
||||||
'giuliani-miamim': [2, 1, 3, 2, 1, 2],
|
// Wide: bass, fifth, octave, then upper-voice pairs
|
||||||
|
'giuliani-peamama': [0, 2, 3, 1, 3, 1],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** pc of a spelled degree (step letter + alter). */
|
/** pc of a spelled degree (step letter + alter). */
|
||||||
|
|||||||
Reference in New Issue
Block a user