7 Commits

Author SHA1 Message Date
OpenClaw
aebed68127 Arpeggio random pattern option + docs update 2026-09-15 07:17:36 +00:00
OpenClaw
d7549e0111 Fix strict tsc -b errors: GiulianiPattern type import, placement null guard 2026-09-15 06:30:26 +00:00
OpenClaw
8ecb7f2de2 Phrase mode: always-eighths arpeggio/Giuliani, auto meter, custom patterns, alternating bass
- arpeggioGenerator: always 240-tick eighths; auto-derive 3/4 vs 4/4
  from pattern length (remainder heuristic); never stretch the last
  note (fixes the two-octave 2880-tick VexFlow crash in 6/8); rest
  fills built from renderable durations only; new 'custom' pattern
  from digit input (1=root..5=fifth-above-octave); removed meter/rhythm
  config — generator computes its own meter.
- giulianiGenerator: always eighths; meter auto-derived from figure
  length (3v->3/4, 4v->4/4, 5v->4/4+rest, 6v->3/4); fixed pimami
  figure [0,1,2,3,2,1]; added pimai/pmia/pmim figures; alternating
  bass option (even bars voice the fifth as bass); removed the
  quarter-note fallback.
- PhraseTrainer: removed meter/rhythm selects for arpeggio+giuliani;
  custom pattern text input; alternating bass checkbox.
- SettingsTypes: 'custom' arpeggio pattern, customArpeggioPattern,
  giulianiAlternateBass; removed arpeggioRhythm.
- PhraseSheetMusic: minWidthPerBar 420 -> 500 for 16th notes.
- Tests updated for the new invariants + coverage for custom patterns,
  alternating bass, new figures, renderable-tick sweeps (1031 -> 191
  per-file, all green); removed temporary crash_test.test.ts.
2026-09-15 06:29:11 +00:00
OpenClaw
0b85f24797 Mobile clipping fix + 6/8 render crash fix (root cause) + more variants
- Clipping: min width per bar raised (16ths 420px, 8ths 330px, quarters
  240px) and format width reserves more room (clef/key 100px, barline
  margin 24px) — narrow screens now wrap into more rows with real margins
  instead of clipping the last bar.
- 6/8 crash ROOT CAUSE: Giuliani figures whose length didn't divide the
  bar into renderable note values produced 96-tick durations (32nd-ish)
  that durationCode can't spell -> renderer threw -> white screen.
  Giuliani generator now snaps figure subdivision to renderable durations
  (per 2/3/4 divisions, quarter-note walk as last resort); pimamim in
  6/8 renders as a walking quarter figure.
- 6/8 melody templates: fixed sums (were 2640/3120 in 4 templates — those
  filtered out, silently degrading to the fallback).
- More variants: arpeggio figures 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2; Giuliani patterns p-i-a-i, p-m-a-m-i, p-i-m-a-m-i (waltz),
  p-i-m-a-i-a.
- Tests 179/179.
2026-09-15 03:35:00 +00:00
OpenClaw
a7a1d34c11 Mobile width fix, 6/8 crash fix, more arpeggio+Giuliani variants
- PhraseSheetMusic: min width per bar scales with notation density
  (16ths 320px, 8ths 260px, quarters 210px) — narrow screens wrap into
  more rows instead of clipping notes off-screen.
- 6/8 crash fixed at the root: melody templatesFor had no 6/8 templates
  (empty pool -> pick on empty array). Added 6/8 template sets for levels
  1/2 plus an empty-pool fallback guard so generators can never pick from
  an empty array again (any meter/level combination stays playable).
- More arpeggio broken-chord figures: 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2.
- More Giuliani patterns: p-i-a-i (wide), p-m-a-m-i, p-i-m-a-m-i (waltz
  feel), p-i-m-a-i-a.
- Tests 179/179.
2026-09-15 03:04:29 +00:00
OpenClaw
ffb2776c8c Giuliani studies as own material category; beam-before-draw fix
- NEW material 'giuliani': figures require a real voicing — the generator
  assigns cycle positions (bass root + upper chord tones) to DISTINCT
  strings with valid frets per bar; bars that cannot be voiced are skipped
  (deterministic), clear error when nothing fits. Patterns: 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. Honors the chord pool, string
  restrictions and the tuning; one figure per beat.
- 6/8 meter added (numerator 6) across score model, settings, generators;
  meter selects now offer 4/4, 3/4, 6/8.
- Arpeggio category: Giuliani entries replaced with note-sequence
  broken-chord figures (1-3-2-1, 1-3-2-5(8), 1-5-3-5(8), 1-2-3-5-3) —
  pure pitch sequences, no voicing claims.
- Beam fix (root cause): setBeam now happens BEFORE voice.draw — VexFlow
  decides flag vs beam at draw time; registering after drawing left both
  flag and beam. Beams drawn after notes, flags suppressed correctly.
2026-09-15 02:44:52 +00:00
OpenClaw
9acf32a32f 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).
2026-09-14 21:08:15 +00:00
15 changed files with 1159 additions and 287 deletions

View File

@@ -99,3 +99,35 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
- MIDI adapter, motif-based generation, persisted phrase setup, extra - MIDI adapter, motif-based generation, persisted phrase setup, extra
repertoire, per-note timing scoring — all explicitly deferred in the plan. repertoire, per-note timing scoring — all explicitly deferred in the plan.
## Feedback round 4 (2026-09-15): always-eighths arpeggio/Giuliani + custom/random patterns
**Generator redesign (Phrase Mode materials `arpeggio` + `giuliani`):**
- **Always eighth notes** (240 ticks/note). The quarter-note fallback and
stretch-the-last-note logic were removed — the latter produced
non-renderable 2880-tick durations (two-octave arpeggio in 6/8) that
crashed the VexFlow renderer (white screen).
- **Auto-derived meter** (`autoArpeggioMeter`): 3/4 vs 4/4 chosen by the
smallest rest remainder for the path length (fewer bars as tiebreaker).
Giuliani meter derives from figure length (3 voices → 3/4, 4 → 4/4,
5 → 4/4 + rest fill, 6 → 3/4). The Meter and Rhythm selects were removed
from both setups; leftover bar space is filled with rests built from
renderable durations only.
- **Custom arpeggio pattern**: new `custom` pattern + text field; digits map
1=root, 2=third, 3=fifth, 4=octave, 5=fifth above the octave; invalid
input fails with an actionable error.
- **Random arpeggio pattern**: new `random` pattern — one broken-chord
figure is chosen deterministically per seed, so every auto-continued run
gets a fresh figure while Retry stays reproducible.
- **Two-octave coverage** doubles the path; the auto-derived meter adjusts
(e.g. updown two-octave = 13 notes → 4/4 with a rest fill).
- **Giuliani**: `pimami` figure corrected to 6 voices; new figures
`pimai`, `pmia`, `pmim`; new **alternating bass** option (even bars voice
the fifth as the bass — the original studies' thumb-jump feel).
- **Renderer**: `minWidthPerBar` for sixteenths raised 420 → 500 px
(mobile clipping).
**Verification:** 193 unit tests green (`npx vitest run`), `tsc --noEmit`
clean, production build + deploy to `smt-beta`. A pattern×degree×coverage
sweep test guarantees every generated duration stays in the renderable set.

View File

@@ -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). */

View File

@@ -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' : ''}`}

View File

@@ -105,7 +105,12 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
const events = scoreEvents(score); const events = scoreEvents(score);
const totalBars = score.measures.length; const totalBars = score.measures.length;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / 210))); // Min width per bar grows with notation density (eighths/16ths need more
// horizontal room than quarters) so narrow screens wrap into more rows
// instead of clipping notes.
const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920);
const minWidthPerBar = densestTick <= 120 ? 500 : densestTick <= 240 ? 330 : 240;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / minWidthPerBar)));
const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow)); const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow));
const isGrand = clef === 'grand'; const isGrand = clef === 'grand';
const rowHeight = isGrand ? 250 : 140; const rowHeight = isGrand ? 250 : 140;
@@ -319,14 +324,38 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
voices.push(buildVoice(treble, () => true)); voices.push(buildVoice(treble, () => true));
} }
const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 92 : 16)); const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 100 : 24));
const formatter = new Formatter(); const formatter = new Formatter();
formatter.joinVoices(voices.map(v => v.voice)); formatter.joinVoices(voices.map(v => v.voice));
formatter.format(voices.map(v => v.voice), formatWidth); formatter.format(voices.map(v => v.voice), formatWidth);
// --- Beams: BEFORE drawing notes. setBeam must happen prior to
// voice.draw — VexFlow decides flag vs beam on this.beam at
// draw time; registering late leaves BOTH flag and beam.
const beamPairs: { a: Fragment; b2: Fragment }[] = [];
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
for (let i = 0; i + 1 < beamed.length; i++) {
const a = beamed[i], b2 = beamed[i + 1];
if (a.staveKey !== b2.staveKey) continue;
const step = a.isSixteenth ? 120 : 240;
if (b2.start - a.start !== step) continue;
if ((a.ticks !== step) || (b2.ticks !== step)) continue;
const beatA = Math.floor((a.start - measureStart) / 480);
const beatB = Math.floor((b2.start - measureStart) / 480);
if (beatA === beatB) beamPairs.push({ a, b2 });
}
// Apply beams to notes FIRST (suppresses flags), then draw.
const beams = beamPairs.map(({ a, b2 }) => {
const beam = new Beam([a.note, b2.note], false);
a.note.setBeam(beam);
b2.note.setBeam(beam);
return beam;
});
treble.setContext(context).draw(); treble.setContext(context).draw();
if (bass) bass.setContext(context).draw(); if (bass) bass.setContext(context).draw();
for (const { voice, stave } of voices) voice.draw(context, stave); for (const { voice, stave } of voices) voice.draw(context, stave);
for (const beam of beams) beam.setContext(context).draw();
if (isGrand && isFirstOfRow) { if (isGrand && isFirstOfRow) {
const brace = new StaveConnector(treble, bass!); const brace = new StaveConnector(treble, bass!);
@@ -337,27 +366,6 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
line.setContext(context).draw(); line.setContext(context).draw();
} }
// --- Beams: consecutive eighth/16th pairs within one beat ---
// Note: the Beam must be registered on each note via setBeam,
// otherwise VexFlow still draws flags (beam === undefined in
// shouldDrawFlag) and you get BOTH flag and beam.
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
for (let i = 0; i + 1 < beamed.length; i++) {
const a = beamed[i], b2 = beamed[i + 1];
if (a.staveKey !== b2.staveKey) continue;
// consecutive within the same beat, same subdivision size
const step = a.isSixteenth ? 120 : 240;
if (b2.start - a.start !== step) continue;
if ((a.ticks !== step) || (b2.ticks !== step)) continue;
const beatA = Math.floor((a.start - measureStart) / 480);
const beatB = Math.floor((b2.start - measureStart) / 480);
if (beatA === beatB) {
const beam = new Beam([a.note, b2.note], false);
a.note.setBeam(beam);
b2.note.setBeam(beam);
beam.setContext(context).draw();
}
}
} }
// --- Ties between consecutive fragments of one event (this row) --- // --- Ties between consecutive fragments of one event (this row) ---

View File

@@ -20,6 +20,7 @@ import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from
import { generateMelody } from '../music/melodyGenerator'; import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills'; import { generateScaleDrill } from '../music/scaleDrills';
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator'; import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
import { generateGiulianiStudy, GIULIANI_PATTERN_LABELS, type GiulianiPattern } from '../music/giulianiGenerator';
import { isMode, type ModeId } from '../music/scales'; import { isMode, type ModeId } from '../music/scales';
import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator'; import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator';
import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model'; import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model';
@@ -82,16 +83,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 } };
@@ -189,6 +192,21 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
rhythm: phrase.scaleRhythm, rhythm: phrase.scaleRhythm,
}, pool); }, pool);
} }
if (phrase.material === 'giuliani') {
// Meter is auto-derived inside the generator from the figure
// length (always eighths) — the user no longer picks a meter.
return generateGiulianiStudy({
keyTonic: phrase.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId,
pattern: phrase.giulianiPattern,
tuningId: settings.tuningId,
bars: phrase.giulianiBars,
chordSelection: phrase.arpeggioChordSelection as string[],
seed: melodySeed,
allowedStrings: pf.strings,
alternateBass: phrase.giulianiAlternateBass ?? false,
}, pool);
}
if (phrase.material === 'arpeggio') { if (phrase.material === 'arpeggio') {
return generateArpeggio({ return generateArpeggio({
keyTonic: phrase.keyTonic, keyTonic: phrase.keyTonic,
@@ -196,11 +214,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I',
pattern: phrase.arpeggioPattern, pattern: phrase.arpeggioPattern,
coverage: phrase.arpeggioCoverage, coverage: phrase.arpeggioCoverage,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
bars: phrase.arpeggioBars, bars: phrase.arpeggioBars,
progression: phrase.arpeggioProgression, progression: phrase.arpeggioProgression,
chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[], chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[],
rhythm: phrase.arpeggioRhythm, customPattern: phrase.customArpeggioPattern,
seed: melodySeed, seed: melodySeed,
}, pool); }, pool);
} }
@@ -217,8 +234,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, }, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator,
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, phrase.arpeggioBars, phrase.arpeggioProgression,
phrase.arpeggioChordSelection, melodySeed, phrase.arpeggioChordSelection, melodySeed,
phrase.giulianiPattern, phrase.giulianiBars, phrase.giulianiAlternateBass,
melodySeed, pool, importedScore]); melodySeed, pool, importedScore]);
const fullScore: Result<Score> | null = useMemo(() => { const fullScore: Result<Score> | null = useMemo(() => {
@@ -555,6 +573,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="melody">Melodies (generated)</option> <option value="melody">Melodies (generated)</option>
<option value="scale">Scale drills</option> <option value="scale">Scale drills</option>
<option value="arpeggio">Arpeggios (generated)</option> <option value="arpeggio">Arpeggios (generated)</option>
<option value="giuliani">Giuliani studies</option>
<option value="library">Exercises</option> <option value="library">Exercises</option>
<option value="import">Imported file</option> <option value="import">Imported file</option>
</select> </select>
@@ -574,9 +593,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
<label>Meter <label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option> <option value={4}>4/4</option>
<option value={3}>3/4</option> <option value={3}>3/4</option>
<option value={6}>6/8</option>
</select> </select>
</label> </label>
<label>Rhythm <label>Rhythm
@@ -606,9 +626,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
<label>Meter <label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option> <option value={4}>4/4</option>
<option value={3}>3/4</option> <option value={3}>3/4</option>
<option value={6}>6/8</option>
</select> </select>
</label> </label>
</> </>
@@ -616,32 +637,40 @@ 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>
<option value="down">Down</option> <option value="down">Down</option>
<option value="updown">Up &amp; down</option> <option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</option> <option value="1235">1-2-3-5</option>
<optgroup label="Giuliani studies"> <optgroup label="Broken-chord figures">
<option value="giuliani-pim">p-i-m</option> <option value="1321">1-3-2-1</option>
<option value="giuliani-pima">p-i-m-a</option> <option value="1325">1-3-2-5(8)</option>
<option value="giuliani-pami">p-a-m-i</option> <option value="1535">1-5-3-5(8)</option>
<option value="giuliani-mami">m-a-m-i</option> <option value="12353">1-2-3-5-3</option>
<option value="giuliani-ami">a-m-i</option> <option value="1353">1-3-5-3</option>
<option value="giuliani-pimamim">p-i-m-a-m-i</option> <option value="121321">1-2-1-3-2-1</option>
<option value="giuliani-papima">p-a-p-i-m-a</option> <option value="15453">1-5-4-5-3-5</option>
<option value="giuliani-amipim">a-m-i-p-i-m</option> <option value="132532">1-3-2-5-3-2</option>
<option value="giuliani-miamim">m-i-a-m-i-m</option> <option value="random">Random</option>
<option value="custom">Custom…</option>
</optgroup> </optgroup>
</select> </select>
</label> </label>
{phrase.arpeggioPattern === 'custom' && (
<label>Custom pattern
<input
type="text"
value={phrase.customArpeggioPattern || ''}
onChange={e => setPhrase({ customArpeggioPattern: e.target.value.replace(/[^1-5]/g, '') })}
placeholder="e.g. 1321"
style={{ width: '90px' }}
/>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block' }}>
Digits: 1=root, 2=third, 3=fifth, 4=octave, 5=fifth-above-octave
</span>
</label>
)}
<label>Coverage <label>Coverage
<select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}> <select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}>
<option value="one-octave">One octave</option> <option value="one-octave">One octave</option>
@@ -662,47 +691,60 @@ 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 </>
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}> )}
<option value="quarters">Quarter notes</option>
<option value="eighths">Eighth notes</option> {phrase.material === 'giuliani' && (
<>
<label>Pattern
<select value={phrase.giulianiPattern} onChange={e => setPhrase({ giulianiPattern: e.target.value as PhraseSettings['giulianiPattern'] })}>
{(Object.keys(GIULIANI_PATTERN_LABELS) as GiulianiPattern[]).map(p => (
<option key={p} value={p}>{GIULIANI_PATTERN_LABELS[p]}</option>
))}
</select> </select>
</label> </label>
<label>Meter <label>Length
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <select value={phrase.giulianiBars} onChange={e => setPhrase({ giulianiBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
<option value={4}>4/4</option> {[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)}
<option value={3}>3/4</option>
</select> </select>
</label> </label>
<label style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={phrase.giulianiAlternateBass ?? false}
onChange={e => setPhrase({ giulianiAlternateBass: e.target.checked })}
/>
Alternating bass (fifth on even bars)
</label>
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Meter is derived from the pattern; chords come from the arpeggio chord pool; voicing enforced (bass + upper strings).
</span>
</> </>
)} )}
@@ -763,28 +805,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 +818,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">

View File

@@ -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' : ''}`}

View File

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

View File

@@ -18,8 +18,9 @@ describe('generateArpeggio', () => {
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
// C-E-G-C: 4 notes, quarter each (plus final extension) // C-E-G-C as eighths (always 240 ticks now) plus a rest fill in the
const midis = ev.map(e => e.pitch!.midi); // auto-derived bar.
const midis = ev.filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]); expect(midis).toEqual([48, 52, 55, 60]);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Arpeggio'); expect(r.value.title).toContain('Arpeggio');
@@ -30,7 +31,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([60, 55, 52, 48]); expect(midis).toEqual([60, 55, 52, 48]);
}); });
@@ -38,7 +39,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
// ascend C-E-G-C then descend back to C; only one C5 (turning point) // ascend C-E-G-C then descend back to C; only one C5 (turning point)
expect(midis).toEqual([48, 52, 55, 60, 55, 52, 48]); expect(midis).toEqual([48, 52, 55, 60, 55, 52, 48]);
expect(midis.filter(m => m === 60)).toHaveLength(1); expect(midis.filter(m => m === 60)).toHaveLength(1);
@@ -48,7 +49,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]); expect(midis).toEqual([48, 52, 55, 60]);
}); });
@@ -56,14 +57,14 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60, 64, 67, 72]); expect(midis).toEqual([48, 52, 55, 60, 64, 67, 72]);
}); });
it('spells the dominant triad G-B-D in C major', () => { it('spells the dominant triad G-B-D in C major', () => {
const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12); const pcs = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([7, 11, 2, 7]); // G B D G expect(pcs).toEqual([7, 11, 2, 7]); // G B D G
}); });
@@ -71,7 +72,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12); const pcs = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([11, 2, 5, 11]); // B D F B expect(pcs).toEqual([11, 2, 5, 11]); // B D F B
}); });
@@ -101,8 +102,9 @@ describe('generateArpeggio — sequence mode', () => {
// bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align // bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
// 4 beats per bar × 4 bars = 16 notes in quarters mode // Auto meter from a 4-note path (960 ticks): 3/4 wins (remainder 480
expect(ev.length).toBe(16); // vs 960 in 4/4) → 6 eighths per bar × 4 bars = 24
expect(ev.length).toBe(24);
// deterministic // deterministic
const r2 = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool); const r2 = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool);
expect(r2.ok && JSON.stringify(scoreEvents(r2.value)) === JSON.stringify(ev)).toBe(true); expect(r2.ok && JSON.stringify(scoreEvents(r2.value)) === JSON.stringify(ev)).toBe(true);
@@ -116,12 +118,13 @@ describe('generateArpeggio — sequence mode', () => {
expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']); expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']);
}); });
it('eighths rhythm doubles notes per bar', () => { it('eighths fill each bar (always-eighths rhythm)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', rhythm: 'eighths', seed: 3 }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', seed: 3 }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(ev.length).toBe(16); // 2 bars × 4 beats × 2 // Auto meter from a 4-note path: 3/4 → 6 eighths per bar × 2 bars
expect(ev.length).toBe(12);
expect(ev[0].durationTicks).toBe(240); expect(ev[0].durationTicks).toBe(240);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
}); });
@@ -136,3 +139,103 @@ describe('generateArpeggio — sequence mode', () => {
} }
}); });
}); });
describe('generateArpeggio — always-eighths + custom pattern', () => {
it('auto-derives 3/4 for a 4-note path (remainder 480 < 960)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.meter).toEqual({ numerator: 3, denominator: 4 });
const ev = scoreEvents(r.value);
for (const e of ev.filter(x => x.pitch)) expect(e.durationTicks).toBe(240);
// 4 notes fill 960 of the 1440-tick bar; remainder is a 480 rest
const rest = ev.find(e => e.pitch === null);
expect(rest).toBeDefined();
expect(rest!.durationTicks).toBe(480);
expect(rest!.startTick).toBe(960);
});
it('auto-derives 4/4 for a 6-note path (remainder 480 < 480? no — 6×240=1440 fits 3/4)', () => {
// 1321 figure → 4 notes; use 121321 → 6 notes = 1440 ticks = 3/4 exact
const r = generateArpeggio({ ...base, degree: 'I', pattern: '121321', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.meter).toEqual({ numerator: 3, denominator: 4 });
const ev = scoreEvents(r.value).filter(e => e.pitch);
expect(ev.length).toBe(6);
expect(ev.every(e => e.durationTicks === 240)).toBe(true);
});
it('never stretches the last note (the old 2880-tick crash)', () => {
const ev = scoreEvents(
(generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'two-octave' }, pool) as { ok: true; value: import('../score/model').Score }).value
);
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
for (const e of ev) expect(RENDERABLE).toContain(e.durationTicks);
});
it('builds a custom pattern from digits (1321 = root-fifth-third-root)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '1321', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 55, 52, 48]); // C G E C
});
it('digit 5 reaches the fifth above the octave', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '15', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 67]); // C + G above the octave C
});
it('rejects invalid custom patterns cleanly', () => {
const empty = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', coverage: 'one-octave' }, pool);
expect(empty.ok).toBe(false);
const bad = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '179', coverage: 'one-octave' }, pool);
expect(bad.ok).toBe(false);
});
it('random pattern resolves to a valid broken-chord figure per seed', () => {
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
const seen = new Set<string>();
for (let seed = 0; seed < 30; seed++) {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'one-octave', seed }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
for (const e of scoreEvents(r.value)) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
// The title reveals the resolved figure label
const match = r.value.title.match(/\(([^)]+)\,/);
if (match) seen.add(match[1]);
}
// Across seeds more than one distinct figure should appear
expect(seen.size).toBeGreaterThan(1);
});
it('random pattern is deterministic per seed', () => {
const a = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool);
const b = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});
it('sweep: every pattern × degree × coverage produces only renderable ticks', () => {
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
const patterns = ['up', 'down', 'updown', '1235', '1321', '1325', '1535', '12353', '121321', '1353', '15453', '132532'] as const;
const degrees = ['I', 'ii', 'iii', 'IV', 'V', 'vi', 'vii0'] as const;
for (const pattern of patterns) {
for (const degree of degrees) {
for (const coverage of ['one-octave', 'two-octave'] as const) {
const r = generateArpeggio({ ...base, degree, pattern, coverage }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
const ev = scoreEvents(r.value);
expect(ev.length).toBeGreaterThan(0);
for (const e of ev) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
}
}
}
});
});

View File

@@ -7,6 +7,9 @@
* (one or two octaves). Chord tones are spelled via the key's degree * (one or two octaves). Chord tones are spelled via the key's degree
* spelling (same machinery as melody/scale generators), so spelling and * spelling (same machinery as melody/scale generators), so spelling and
* validation match the Phrase Mode invariants exactly. * validation match the Phrase Mode invariants exactly.
*
* Arpeggio mode is ALWAYS eighths (240 ticks per note). Meter is auto-derived
* from the pattern length — the user no longer picks rhythm or meter.
*/ */
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model'; import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
import { keyFor, type KeyContext, type ModeId } from './scales'; import { keyFor, type KeyContext, type ModeId } from './scales';
@@ -18,20 +21,19 @@ export type ArpeggioPattern =
| 'up' | 'down' | 'updown' | '1235' | 'up' | 'down' | 'updown' | '1235'
// Giuliani-style right-hand figures over one chord (indices into the // Giuliani-style right-hand figures over one chord (indices into the
// chord cycle per note; 1=root 2=third 3=fifth 4=octave): // chord cycle per note; 1=root 2=third 3=fifth 4=octave):
| 'giuliani-pim' // root-third-fifth (p-i-m) | '1321' // root-fifth-third-root
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a) | '1325' // root-fifth-third-octave
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i) | '1535' // root-octave-fifth-octave
| 'giuliani-mami' // third-fifth-octave-third (inner-voice feel) | '12353' // root-third-fifth-octave-fifth
| 'giuliani-ami' // octave-fifth-third (descending from the top) | '121321' // rolling thirds (root-third-root-fifth-third-root)
| 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure) | '1353' // root-third-fifth-third
| 'giuliani-papima' // p-a-p-i-m-a (bass-skip pattern) | '15453' // root-octave-fifth-octave-fifth-octave
| 'giuliani-amipim' // a-m-i-p-i-m (tremolo-adjacent) | '132532' // long broken-chord wave
| 'giuliani-miamim'; // m-i-a-m-i-m (triple-string figure) | 'custom' // user-defined pattern from customPattern digits
| 'random'; // one broken-chord figure, chosen randomly per exercise (seed)
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';
/** Rhythm density for arpeggio sequences. */
export type ArpeggioRhythm = 'quarters' | 'eighths';
export interface ArpeggioConfig { export interface ArpeggioConfig {
readonly keyTonic: string; readonly keyTonic: string;
@@ -39,16 +41,17 @@ export interface ArpeggioConfig {
readonly degree: ArpeggioDegree; readonly degree: ArpeggioDegree;
readonly pattern: ArpeggioPattern; readonly pattern: ArpeggioPattern;
readonly coverage: ArpeggioCoverage; readonly coverage: ArpeggioCoverage;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
/** Sequence mode: bars > 1 with chord progression selection. */ /** Sequence mode: bars > 1 with chord progression selection. */
readonly bars?: number; readonly bars?: number;
readonly progression?: ArpeggioProgression; readonly progression?: ArpeggioProgression;
readonly rhythm?: ArpeggioRhythm;
/** Restrict progression chords to these degrees (empty = all 7). /** Restrict progression chords to these degrees (empty = all 7).
* The first bar always uses `degree` if it is in the selection. */ * The first bar always uses `degree` if it is in the selection. */
readonly chordSelection?: readonly ArpeggioDegree[]; readonly chordSelection?: readonly ArpeggioDegree[];
/** Seed for progression selection (deterministic). */ /** Seed for progression selection (deterministic). */
readonly seed?: number; readonly seed?: number;
/** User-defined custom pattern digits (1-indexed: 1=root, 2=third, 3=fifth,
* 4=octave, 5=fifth-above-octave). Only used when pattern === 'custom'. */
readonly customPattern?: string;
} }
/** Degree index (0-based) into the scale for each arpeggio degree label. */ /** Degree index (0-based) into the scale for each arpeggio degree label. */
@@ -62,34 +65,44 @@ export const ARPEGGIO_DEGREE_LABELS: Record<ArpeggioDegree, string> = {
export const PATTERN_LABELS: Record<ArpeggioPattern, string> = { export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5', up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5',
'giuliani-pim': 'Giuliani p-i-m', '1321': '1-3-2-1',
'giuliani-pima': 'Giuliani p-i-m-a', '1325': '1-3-2-5(8)',
'giuliani-pami': 'Giuliani p-a-m-i', '1535': '1-5-3-5(8)',
'giuliani-mami': 'Giuliani m-a-m-i', '12353': '1-2-3-5-3',
'giuliani-ami': 'Giuliani a-m-i', '121321': '1-2-1-3-2-1',
'giuliani-pimamim': 'Giuliani p-i-m-a-m-i', '1353': '1-3-5-3',
'giuliani-papima': 'Giuliani p-a-p-i-m-a', '15453': '1-5-4-5-3-5',
'giuliani-amipim': 'Giuliani a-m-i-p-i-m', '132532': '1-3-2-5-3-2',
'giuliani-miamim': 'Giuliani m-i-a-m-i-m', 'custom': 'Custom…',
'random': 'Random',
}; };
/** /**
* Giuliani figures: chord-cycle index per note (0=root 1=third 2=fifth * Broken-chord figures — NOTE sequences (not fingerings): the bass takes the
* 3=octave). Each figure repeats with an octave register shift per * 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].
*/ */
const GIULIANI_FIGURES: Record<string, number[]> = { const BROKEN_FIGURES: Record<string, number[]> = {
'giuliani-pim': [0, 1, 2], '1321': [0, 2, 1, 0],
'giuliani-pima': [0, 1, 2, 3], '1325': [0, 2, 1, 3],
'giuliani-pami': [0, 3, 2, 1], '1535': [0, 3, 2, 3],
'giuliani-mami': [1, 2, 3, 1], '12353': [0, 1, 2, 3, 2],
'giuliani-ami': [3, 2, 1], '121321': [0, 1, 0, 2, 1, 0],
'giuliani-pimamim': [0, 1, 2, 3, 2, 1], '1353': [0, 1, 2, 1],
'giuliani-papima': [0, 3, 0, 1, 2, 3], '15453': [0, 3, 2, 3, 2, 3],
'giuliani-amipim': [3, 2, 1, 0, 1, 2], '132532': [0, 2, 1, 3, 2, 1],
'giuliani-miamim': [2, 1, 3, 2, 1, 2],
}; };
/**
* Renderable note durations — subset of VexFlow duration codes the
* PhraseSheetMusic renderer accepts. ALL generated durations MUST be in this
* set, otherwise the renderer produces ghost slots and crashes or misaligns.
*/
const RENDERABLE_NOTE_TICKS = new Set([120, 240, 360, 480, 720, 960, 1440, 1920]);
/** Descending renderable durations for rest fills (largest first). */
const REST_FILL_TICKS = [1920, 1440, 960, 720, 480, 240];
/** pc of a spelled degree (step letter + alter). */ /** pc of a spelled degree (step letter + alter). */
const degreePc = (d: { step: StepLetter; alter: number }): number => const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12; (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
@@ -127,6 +140,29 @@ function spellInKey(key: KeyContext, midi: number): { step: StepLetter; alter: n
return { step: d.step, alter: d.alter, octave }; return { step: d.step, alter: d.alter, octave };
} }
/**
* Auto-derive a meter (3/4 or 4/4) from the number of eighths in the phrase.
* Always returns 3/4 or 4/4 — 6/8 is never used for arpeggios since it can
* produce non-renderable tick sizes with some pattern lengths.
*
* For N notes at 240 ticks each:
* Try 4/4: bars4 = ceil(N*240 / 1920), remainder4 = bars4*1920 - N*240
* Try 3/4: bars3 = ceil(N*240 / 1440), remainder3 = bars3*1440 - N*240
* Pick the one with smaller remainder (fewer bars as tiebreaker).
*/
export function autoArpeggioMeter(noteCount: number): { numerator: 4 | 3; denominator: 4 } {
const totalTicks = noteCount * 240;
const bars4 = Math.ceil(totalTicks / 1920);
const remainder4 = bars4 * 1920 - totalTicks;
const bars3 = Math.ceil(totalTicks / 1440);
const remainder3 = bars3 * 1440 - totalTicks;
if (remainder3 < remainder4) return { numerator: 3, denominator: 4 };
if (remainder4 < remainder3) return { numerator: 4, denominator: 4 };
// equal remainders → prefer fewer bars
return bars3 <= bars4 ? { numerator: 3, denominator: 4 } : { numerator: 4, denominator: 4 };
}
/** /**
* Build the arpeggio path: chord tones ascending from the lowest available * Build the arpeggio path: chord tones ascending from the lowest available
* root (degree tone) that allows a full one/two-octave cycle in the pool. * root (degree tone) that allows a full one/two-octave cycle in the pool.
@@ -192,11 +228,11 @@ function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverag
return path; return path;
} }
default: { default: {
// Giuliani figure: cycle [root, third, fifth, octave] with the // Broken-chord figure: cycle [root, third, fifth, octave] with the
// figure selecting cycle positions per note; the octave wraps so // figure selecting cycle positions per note; the octave wraps so
// e.g. p-i-m over C-E-G becomes C E G | C' E' G' ... (ascending // e.g. 1321 over C-E-G becomes C G E C' ... (ascending octave
// octave register shift every figure repetition). // register shift every figure repetition).
const figure = GIULIANI_FIGURES[pattern]; const figure = BROKEN_FIGURES[pattern];
// 4-note cycle per octave: root, third, fifth, octave // 4-note cycle per octave: root, third, fifth, octave
const cycleOct = (oct: number): number[] => [ const cycleOct = (oct: number): number[] => [
base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1), base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1),
@@ -214,6 +250,143 @@ function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverag
} }
} }
/**
* Parse custom pattern digits into cycle indices (0-based).
* Digits are 1-indexed: 1=root, 2=third, 3=fifth, 4=octave, 5=fifth-above-octave.
* Returns null for empty/invalid input.
*/
function parseCustomPattern(digits: string): number[] | null {
if (!digits || digits.trim().length === 0) return null;
const result: number[] = [];
for (const ch of digits.trim()) {
const n = parseInt(ch, 10);
if (isNaN(n) || n < 1 || n > 5) return null;
// Map 1→0 (root), 2→1 (third), 3→2 (fifth), 4→3 (octave), 5→4 (fifth above octave)
result.push(n - 1);
}
return result.length > 0 ? result : null;
}
/**
* Build a custom path from 0-based digit indices using the one-octave cycle
* [root, third, fifth, octave] (index 4 = fifth above the octave).
*/
function buildCustomPath(
base: number[],
pattern: number[],
octavesWanted: number,
pool: number[],
): number[] | null {
const cycleOct = (oct: number): number[] => [
base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1),
];
const path: number[] = [];
for (let oct = 0; oct < octavesWanted; oct++) {
const c = cycleOct(oct);
// Extend the cycle for digit 5: fifth above the octave (+7 semitones).
const extended = [...c, c[3] + 7];
for (const idx of pattern) {
const note = extended[idx];
if (note === undefined) continue;
if (oct > 0 && !pool.includes(note)) continue;
path.push(note);
}
}
return path.length > 0 ? path : null;
}
/**
* Build the path for one chord (degree), honouring built-in patterns and the
* user-defined custom pattern. Returns null when the chord cannot be voiced
* in the playable pool.
*/
function pathForChord(
key: KeyContext,
degreeIndex: number,
pool: number[],
coverage: ArpeggioCoverage,
pattern: ArpeggioPattern,
customDigits: number[] | null,
): number[] | null {
if (pattern === 'custom') {
if (!customDigits) return null;
const tones = chordTonesInPool(key, degreeIndex, pool);
if (tones.length < 3) return null;
const rootPc = degreePc(key.degrees[degreeIndex]);
const roots = tones.filter(m => ((m % 12) + 12) % 12 === rootPc);
const octavesWanted = coverage === 'one-octave' ? 1 : 2;
for (const root of roots) {
// chord tones >= root within one octave, including the octave
const cycle: number[] = [];
for (const midi of tones) {
if (midi >= root && midi <= root + 12) cycle.push(midi);
}
if (!cycle.includes(root + 12) && pool.includes(root + 12)) cycle.push(root + 12);
cycle.sort((a, b) => a - b);
if (cycle.length < 3 || cycle[0] !== root) continue;
const p = buildCustomPath(cycle, customDigits, octavesWanted, pool);
if (p) return p;
}
return null;
}
return buildPath(key, degreeIndex, pool, coverage, pattern);
}
/**
* Emit one eighth-note event with key-aware spelling.
* Returns false when the midi note cannot be spelled in this key.
*/
function pushNote(
events: ScoreEvent[],
key: KeyContext,
midi: number,
startTick: number,
id: string,
): boolean {
const spelled = spellInKey(key, midi);
if (!spelled) return false;
events.push({
id,
startTick,
durationTicks: 240,
pitch: { midi, ...spelled },
});
return true;
}
/**
* Fill the tail of the material (after the last note) with rests built from
* RENDERABLE durations only. Splits the gap greedily, largest duration first.
*/
function pushRestFill(events: ScoreEvent[], fromTick: number, untilTick: number): void {
let cursor = fromTick;
while (cursor < untilTick) {
const gap = untilTick - cursor;
const dur = REST_FILL_TICKS.find(d => d <= gap);
if (!dur) break; // gap smaller than an eighth — should not happen
events.push({
id: `r${events.length}`,
startTick: cursor,
durationTicks: dur,
pitch: null, // rest
});
cursor += dur;
}
}
/**
* Resolve the effective broken-chord figure for this exercise. 'random'
* picks one deterministic figure per seed so every auto-continued run gets
* a fresh broken-chord pattern while remaining reproducible.
*/
function resolveRandomPattern(pattern: ArpeggioPattern, seed: number): ArpeggioPattern {
if (pattern !== 'random') return pattern;
const figures = Object.keys(BROKEN_FIGURES);
const rng = makeRng(seed * 7919 + 13);
return figures[Math.floor(rng() * figures.length)] as ArpeggioPattern;
}
export function generateArpeggio( export function generateArpeggio(
config: ArpeggioConfig, config: ArpeggioConfig,
eligiblePitches: number[], eligiblePitches: number[],
@@ -223,14 +396,11 @@ export function generateArpeggio(
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' }; if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
const bars = config.bars ?? 1; const bars = config.bars ?? 1;
const pattern = config.pattern; const pattern = resolveRandomPattern(config.pattern, config.seed ?? 0);
const coverage = config.coverage; const coverage = config.coverage;
// ---- Chord sequence ---------------------------------------------------- // ---- Chord sequence ----------------------------------------------------
// Single-bar mode: the chosen degree for all bars (bars is 1).
// Sequence mode: one chord per bar, chosen by progression rule.
const degreeIndices: number[] = []; const degreeIndices: number[] = [];
// Sequence chord pool: user selection (restricted to it) or all 7.
const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX).map(d => DEGREE_INDEX[d as ArpeggioDegree]); const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX).map(d => DEGREE_INDEX[d as ArpeggioDegree]);
const pool7 = sel.length > 0 ? Array.from(new Set(sel)).sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6]; const pool7 = sel.length > 0 ? Array.from(new Set(sel)).sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6];
if (bars === 1) { if (bars === 1) {
@@ -240,8 +410,6 @@ export function generateArpeggio(
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)]; const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
for (let b = 0; b < bars; b++) { for (let b = 0; b < bars; b++) {
if (b === 0) { if (b === 0) {
// First bar: a random chord FROM THE SELECTION — fixed starts
// repeated the exercise identically every time.
degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34 degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34
? DEGREE_INDEX[config.degree] ? DEGREE_INDEX[config.degree]
: pick(pool7)); : pick(pool7));
@@ -249,20 +417,10 @@ export function generateArpeggio(
} }
const prev = degreeIndices[b - 1]; const prev = degreeIndices[b - 1];
if (config.progression === 'diatonic-cycle') { if (config.progression === 'diatonic-cycle') {
// Next diatonic degree that is also in the selection
let next = (prev + 1) % 7; let next = (prev + 1) % 7;
for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7; for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7;
degreeIndices.push(next); degreeIndices.push(next);
} else if (config.progression === 'functional') { } else if (config.progression === 'functional') {
// Simple tonal function rules (major-oriented; mirrored for
// minor by degree relationships below):
// I -> {IV, V, vi}
// ii -> {V}
// iii -> {IV, vi}
// IV -> {I, V}
// V -> {I, vi}
// vi -> {ii, IV}
// vii -> {I}
const NEXT: Record<number, number[]> = { const NEXT: Record<number, number[]> = {
0: [3, 4, 5], 0: [3, 4, 5],
1: [4], 1: [4],
@@ -272,86 +430,85 @@ export function generateArpeggio(
5: [1, 3], 5: [1, 3],
6: [0], 6: [0],
}; };
// functional: honor the selection — filter rule targets by it
const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d)); const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d));
degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev))); degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev)));
} else { } else {
// random: any selected degree, avoid immediate repetition
const candidates = pool7.filter(d => d !== prev); const candidates = pool7.filter(d => d !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7)); degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7));
} }
} }
} }
// ---- Path per bar: one arpeggio cycle per chord ------------------------- // ---- Custom pattern digits (only for pattern === 'custom') --------------
const full = config.meter.numerator * PPQ; const customDigits = pattern === 'custom' ? parseCustomPattern(config.customPattern ?? '') : null;
const perBarSegments: { path: number[]; degreeIndex: number }[] = []; if (pattern === 'custom' && !customDigits) {
for (const di of degreeIndices) { return { ok: false, error: 'Custom pattern is empty or invalid — enter digits like "1321" (1=root, 2=third, 3=fifth, 4=octave).' };
const path = buildPath(key, di, pool, coverage, pattern);
if (!path || path.length === 0) {
return {
ok: false,
error: 'The arpeggio does not fit the playable range — widen the range (strings/fret window) or pick another degree.',
};
}
perBarSegments.push({ path, degreeIndex: di });
} }
// ---- Events: fit each segment into one bar ------------------------------ // ---- Build the path for the first chord ----------------------------------
const rhythm = config.rhythm ?? 'quarters'; // All bars share the same pattern, so the path length (and thus meter) is
// derived from the first chord's voicing. Sequence mode repeats the path
// within each bar, so the meter also derives from the path length itself.
const firstPath = pathForChord(key, degreeIndices[0], pool, coverage, pattern, customDigits);
if (!firstPath || firstPath.length === 0) {
return {
ok: false,
error: 'The arpeggio does not fit the playable range — widen the range (strings/fret window) or pick another degree.',
};
}
const meter = autoArpeggioMeter(firstPath.length);
const notesPerBar = meter.numerator * 2; // 2 eighths per beat
const barLength = meter.numerator * PPQ;
const noteDur = 240; // always eighths
// ---- Events: single-chord vs sequence mode --------------------------------
const events: ScoreEvent[] = []; const events: ScoreEvent[] = [];
const chordSymbols: (string | null)[] = []; const chordSymbols: (string | null)[] = [];
let cursor = 0;
for (let b = 0; b < bars; b++) { if (bars === 1) {
const { path, degreeIndex } = perBarSegments[b]; // Single-chord mode: the path plays once; any remaining ticks in the
chordSymbols.push(chordNameFor(key, degreeIndex)); // final bar(s) become rests (renderable durations only). The path is
if (bars === 1) { // NEVER stretched — durations stay at 240 ticks.
// Single-bar mode (v1 behavior): whole path, final note extended const barCount = Math.max(1, Math.ceil((firstPath.length * noteDur) / barLength));
// to fill the bar — or, when the path overflows one bar, spread chordSymbols.push(chordNameFor(key, degreeIndices[0]));
// over the needed bars with the overflow in the last note.
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full)); let cursor = 0;
const totalTicks = barCount * full; for (let slot = 0; slot < firstPath.length; slot++) {
const lastDuration = PPQ + (totalTicks - path.length * PPQ); pushNote(events, key, firstPath[slot], cursor, `a0-${slot}`);
path.forEach((midi, n) => {
const spelled = spellInKey(key, midi);
if (!spelled) return;
const dur = n === path.length - 1 ? lastDuration : PPQ;
events.push({ id: `a${b}-${n}`, startTick: cursor, durationTicks: dur, pitch: { midi, ...spelled } });
cursor += dur;
});
continue;
}
// Sequence mode: fit the bar exactly. 'quarters': one note per beat,
// cycling up/down through the path. 'eighths': two notes per beat.
const slots = rhythm === 'quarters' ? 1 : 2;
const notesPerBar = config.meter.numerator * slots;
const line: number[] = [];
let dir = 1;
let lastIdx = 0;
while (line.length < notesPerBar) {
line.push(path[lastIdx]);
if (lastIdx + dir < 0 || lastIdx + dir >= path.length) dir = -dir;
else lastIdx += dir;
}
const noteDur = full / notesPerBar;
line.forEach((midi, n) => {
const spelled = spellInKey(key, midi);
if (!spelled) return;
events.push({
id: `a${b}-${n}`,
startTick: cursor,
durationTicks: noteDur,
pitch: { midi, ...spelled },
});
cursor += noteDur; cursor += noteDur;
}); }
const totalTicks = barCount * barLength;
if (cursor < totalTicks) pushRestFill(events, cursor, totalTicks);
} else {
// Sequence mode: one chord per bar, each bar holds exactly
// `notesPerBar` eighths (the path cycles if it is shorter, and is
// truncated if longer — with two-octave coverage the auto-derived
// meter always gives enough room).
let cursor = 0;
for (let b = 0; b < bars; b++) {
const di = degreeIndices[b];
chordSymbols.push(chordNameFor(key, di));
const barPath = pathForChord(key, di, pool, coverage, pattern, customDigits);
if (!barPath || barPath.length === 0) {
return { ok: false, error: `Cannot voice arpeggio for chord ${chordNameFor(key, di)}.` };
}
for (let n = 0; n < notesPerBar; n++) {
pushNote(events, key, barPath[n % barPath.length], cursor, `a${b}-${n}`);
cursor += noteDur;
}
}
}
// ---- Validate all durations are renderable -----------------------------
for (const e of events) {
if (!RENDERABLE_NOTE_TICKS.has(e.durationTicks)) {
return { ok: false, error: `Internal arpeggio error: non-renderable duration ${e.durationTicks} at ${e.id}.` };
}
} }
const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`; const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`;
const patternLabel = PATTERN_LABELS[config.pattern]; const patternLabel = PATTERN_LABELS[pattern];
// Single-bar paths can overflow into extra bars (updown/two-octave); const totalMeasures = Math.max(1, Math.ceil(events.reduce((s, e) => Math.max(s, e.startTick + e.durationTicks), 0) / barLength));
// sequence mode always has exactly `bars` measures.
const totalMeasures = bars === 1 ? Math.max(1, Math.ceil((cursor) / full)) : bars;
const title = bars === 1 const title = bars === 1
? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})` ? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})`
: `Arpeggio — ${key.tonic}: ${bars} bars (${config.progression ?? 'functional'})`; : `Arpeggio — ${key.tonic}: ${bars} bars (${config.progression ?? 'functional'})`;
@@ -359,12 +516,12 @@ export function generateArpeggio(
version: 1, version: 1,
id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${bars}b-${config.progression ?? 'single'}-${config.seed ?? 0}`, id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${bars}b-${config.progression ?? 'single'}-${config.seed ?? 0}`,
title, title,
meter: config.meter, meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature }, key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalMeasures }, (_, i) => ({ measures: Array.from({ length: totalMeasures }, (_, i) => ({
number: i + 1, number: i + 1,
startTick: i * full, startTick: i * barLength,
durationTicks: full, durationTicks: barLength,
})), })),
chordSymbols: bars === 1 chordSymbols: bars === 1
// repeat the single chord symbol across the overflow bars // repeat the single chord symbol across the overflow bars
@@ -375,4 +532,4 @@ export function generateArpeggio(
const problems = validateScore(score); const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` }; if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` };
return { ok: true, value: score }; return { ok: true, value: score };
} }

View File

@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { generateGiulianiStudy, type GiulianiPattern } from './giulianiGenerator';
import { scoreEvents, validateScore } from '../score/model';
const strings = [40, 45, 50, 55, 59, 64];
const pool: number[] = [];
for (const open of strings) for (let f = 0; f <= 12; f++) pool.push(open + f);
const base = {
keyTonic: 'C',
keyMode: 'major' as const,
tuningId: 'standard',
bars: 4,
chordSelection: [] as string[],
allowedStrings: [] as number[],
};
describe('generateGiulianiStudy', () => {
it('generates a 4-bar p-i-m-a study in C major', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 5 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
expect(r.value.measures.length).toBe(4);
// 4 voices → 4/4 auto-derived; eighths: 8 notes per bar
expect(ev.length).toBe(4 * 8);
expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Giuliani');
});
it('keeps figure notes on distinct strings per repetition (voicing rule)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 3 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
// For each repetition of 4 consecutive notes, all midis must differ
// (distinct cycle positions → distinct pitches within a beat figure)
const ev = scoreEvents(r.value).filter(e => e.pitch);
for (let i = 0; i + 3 < ev.length; i += 4) {
const four = ev.slice(i, i + 4).map(e => e.pitch!.midi);
expect(new Set(four).size).toBe(4);
}
});
it('six-note figures work (pimamim)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pimamim', seed: 1 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
// 6 voices → 3/4 auto-derived; eighths: 6 notes per bar (one cycle)
expect(ev.length).toBe(4 * 6);
expect(ev.every(e => e.durationTicks === 240)).toBe(true);
expect(validateScore(r.value)).toEqual([]);
});
it('is deterministic per seed', () => {
const a = generateGiulianiStudy({ ...base, pattern: 'pim', seed: 9 }, pool);
const b = generateGiulianiStudy({ ...base, pattern: 'pim', seed: 9 }, pool);
expect(JSON.stringify(a) === JSON.stringify(b)).toBe(true);
});
it('honors string restrictions (fails cleanly when impossible)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 2, allowedStrings: [0] }, pool);
expect(r.ok).toBe(false); // 4 voices cannot live on one string
});
});
describe('generateGiulianiStudy — always-eighths + new patterns', () => {
it('all note durations are exactly 240 across every pattern', () => {
const patterns = ['pim', 'pima', 'pami', 'aim', 'pimamim', 'pmamim', 'piai', 'pmami', 'pimami', 'pimaia', 'pimai', 'pmia', 'pmim'] as const;
for (const p of patterns) {
const r = generateGiulianiStudy({ ...base, pattern: p, seed: 1 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
const ev = scoreEvents(r.value);
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
for (const e of ev.filter(x => x.pitch)) expect(e.durationTicks).toBe(240);
for (const e of ev.filter(x => x.pitch === null)) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
}
});
it('alternating bass puts the fifth degree in the bass on even bars', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4, alternateBass: true }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
const full = 4 * 480; // 4/4 bar
// Even bars (0-indexed): first note of each figure cycle is the bass.
// Its pc must be the FIFTH of the bar's chord (C major: cycle starts
// on I or another diatonic chord; verify bass ≠ root and bass = fifth).
for (let bar = 0; bar < 4; bar += 2) {
const barEv = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full);
expect(barEv.length).toBeGreaterThan(0);
const bassMidi = barEv[0].pitch!.midi;
const upperPcs = new Set(barEv.slice(1).map(e => e.pitch!.midi % 12));
// the bass pc must NOT equal any upper-voice root pc check below;
// direct check: bass pc differs from the plain-root voicing
const plain = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4 }, pool);
expect(plain.ok).toBe(true);
if (!plain.ok) return;
const plainEv = scoreEvents(plain.value).filter(e => e.pitch);
const plainBass = plainEv.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
expect(bassMidi % 12).not.toBe(plainBass.pitch!.midi % 12);
// and it must be a fifth above the root (7 semitones) — i.e. a
// chord tone that is neither root nor third of the upper cycle
const bassPc = bassMidi % 12;
const rootPc = plainBass.pitch!.midi % 12;
// fifth of a diatonic triad: +7 semitones (+6 for the dim chord)
expect([6, 7]).toContain((bassPc - rootPc + 12) % 12);
void upperPcs;
}
});
it('odd bars keep the root bass when alternating is on', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4, alternateBass: true }, pool);
const plain = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4 }, pool);
expect(r.ok && plain.ok).toBe(true);
if (!r.ok || !plain.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
const plainEv = scoreEvents(plain.value).filter(e => e.pitch);
const full = 4 * 480;
for (let bar = 1; bar < 4; bar += 2) {
const bass = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
const plainBass = plainEv.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
expect(bass.pitch!.midi % 12).toBe(plainBass.pitch!.midi % 12);
}
});
it('new figure lengths auto-derive the specified meter', () => {
const cases: [GiulianiPattern, 3 | 4][] = [
['pim', 3], ['pima', 4], ['pimamim', 3], ['pimai', 4], ['pmia', 4], ['pmim', 4],
];
for (const [p, num] of cases) {
const r = generateGiulianiStudy({ ...base, pattern: p, seed: 2 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
expect(r.value.meter.numerator).toBe(num);
expect(r.value.meter.denominator).toBe(4);
}
});
it('5-voice figures (pimai) fill 4/4 with a trailing 720-tick rest', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pimai', seed: 2, bars: 2 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
const full = 1920;
for (let bar = 0; bar < 2; bar++) {
const barEv = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full);
const rest = barEv.find(e => e.pitch === null);
expect(rest).toBeDefined();
expect(rest!.startTick).toBe(bar * full + 1200);
expect(rest!.durationTicks).toBe(720);
}
});
});

View File

@@ -0,0 +1,326 @@
/**
* Giuliani study generator (Phrase Mode material) — its own category
* because the figures require a REAL guitar voicing: the thumb takes the
* bass (root on a lower string) and i/m/a ride higher strings. A figure is
* only playable when each consecutive voice slot maps to a distinct string
* with a valid fret in the playable pool — arbitrary chord arpeggios cannot
* guarantee that.
*
* Voicing search: for a figure of voice-count V, pick a bass string (for
* the root) and V-1 higher strings; each note of the cycle is placed on its
* designated string at the fret matching the chord tone. Figure repeats
* over the chord; every bar re-voices (deterministically) so sequences work.
*
* Rhythm is ALWAYS eighths (240 ticks per note); meter is auto-derived from
* the figure length, so odd figure lengths never produce non-renderable
* durations.
*/
import { PPQ, validateScore, type Result, type Score, type ScoreEvent, STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
import { keyFor, type KeyContext, type ModeId } from './scales';
import { TUNINGS, type Tuning } from '../music/Tunings';
import { makeRng } from './melodyGenerator';
export type GiulianiPattern =
| 'pim' // Studies 1–20 feel: bass + two upper voices
| 'pima' // Studies 21–40: bass + three upper voices
| 'pami' // bass + descending upper voices
| 'aim' // upper voices first
| 'pimamim' // six-note rolling
| 'pmamim' // asymmetric
| 'piai' // bass + octave-third (wide spacing)
| 'pmami' // bass + fifth-octave-third
| 'pimami' // bass-third-fifth-octave-third-fifth (waltz-like)
| 'pimaia' // bass with two upper-voice pairs
| 'pimai' // p-i-m-a-i
| 'pmia' // p-m-i-a
| 'pmim'; // p-m-i-m
export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = {
pim: 'p-i-m (3 voices)',
pima: 'p-i-m-a (4 voices)',
pami: 'p-a-m-i',
aim: 'a-i-m',
pimamim: 'p-i-m-a-m-i',
pmamim: 'p-m-a-m-i-m',
piai: 'p-i-a-i (wide)',
pmami: 'p-m-a-m-i',
pimami: 'p-i-m-a-m-i (waltz)',
pimaia: 'p-i-m-a-i-a',
pimai: 'p-i-m-a-i',
pmia: 'p-m-i-a',
pmim: 'p-m-i-m',
};
/** Figure voice slots per repetition (indices into the cycle [root, third, fifth, octave]). */
const FIGURES: Record<GiulianiPattern, number[]> = {
pim: [0, 1, 2],
pima: [0, 1, 2, 3],
pami: [0, 3, 2, 1],
aim: [0, 3, 1],
pimamim: [0, 1, 2, 3, 2, 1],
pmamim: [0, 2, 3, 2, 1, 2],
piai: [0, 1, 3, 1],
pmami: [0, 2, 3, 2, 1],
pimami: [0, 1, 2, 3, 2, 1],
pimaia: [0, 1, 2, 3, 1, 3],
pimai: [0, 1, 2, 3, 1],
pmia: [0, 2, 1, 3],
pmim: [0, 2, 1, 2],
};
export interface GiulianiConfig {
readonly keyTonic: string;
readonly keyMode: ModeId;
readonly pattern: GiulianiPattern;
readonly tuningId: string;
/** Bars (one chord per bar). */
readonly bars: number;
/** Chord degrees allowed (empty = all 7). */
readonly chordSelection: readonly string[];
/** Seed for chord sequence + voicing choices. */
readonly seed: number;
/** Deterministic pattern from the practice filter (strings restriction). */
readonly allowedStrings: readonly number[];
/** Alternating bass: even bars (0-indexed) take the fifth degree as the
* bass note instead of the root. Upper voices stay on chord tones. */
readonly alternateBass?: boolean;
}
const DEGREE_INDEX: Record<string, number> = { I: 0, ii: 1, iii: 2, IV: 3, V: 4, vi: 5, 'vii0': 6 };
const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
function chordNameFor(key: KeyContext, degreeIndex: number): string {
const d = key.degrees[degreeIndex];
const third = key.degrees[(degreeIndex + 2) % 7];
const fifth = key.degrees[(degreeIndex + 4) % 7];
const root = d.step + (d.alter < 0 ? 'b'.repeat(-d.alter) : '#'.repeat(d.alter));
const thirdInterval = (degreePc(third) - degreePc(d) + 12) % 12;
const fifthInterval = (degreePc(fifth) - degreePc(d) + 12) % 12;
let suffix = '';
if (thirdInterval === 3 && fifthInterval === 7) suffix = 'm';
else if (thirdInterval === 3 && fifthInterval === 6) suffix = 'dim';
return root + suffix;
}
interface ChordTone { readonly pc: number; readonly step: StepLetter; readonly alter: number }
/** Diatonic triad pcs for a degree, spelled. */
function triadPcs(key: KeyContext, degreeIndex: number): ChordTone[] {
return [0, 2, 4].map(o => key.degrees[(degreeIndex + o) % 7]).map(d => ({
pc: degreePc(d), step: d.step, alter: d.alter,
}));
}
/**
* Auto-derive meter from the figure length (always eighths):
* 3 voices (240×3=720): 3/4 (2 cycles = 1440, perfect fit)
* 4 voices (240×4=960): 4/4 (2 cycles = 1920, perfect fit)
* 5 voices (240×5=1200): 4/4 (1 cycle + 720 rest)
* 6 voices (240×6=1440): 3/4 (1 cycle = 1440, perfect fit)
*/
export function autoGiulianiMeter(figureLength: number): { numerator: 3 | 4; denominator: 4 } {
switch (figureLength) {
case 3: return { numerator: 3, denominator: 4 };
case 4: return { numerator: 4, denominator: 4 };
case 5: return { numerator: 4, denominator: 4 };
case 6: return { numerator: 3, denominator: 4 };
default: {
// Fall back to the same remainder heuristic as arpeggios.
const totalTicks = figureLength * 240;
const bars4 = Math.ceil(totalTicks / 1920);
const remainder4 = bars4 * 1920 - totalTicks;
const bars3 = Math.ceil(totalTicks / 1440);
const remainder3 = bars3 * 1440 - totalTicks;
if (remainder3 < remainder4) return { numerator: 3, denominator: 4 };
if (remainder4 < remainder3) return { numerator: 4, denominator: 4 };
return bars3 <= bars4 ? { numerator: 3, denominator: 4 } : { numerator: 4, denominator: 4 };
}
}
}
/**
* Voice a figure bar: assign each cycle position (root/third/fifth/octave)
* to a string with a valid fret, bass string lowest. Returns per-slot
* {stringIndex, fret, midi} or null when impossible with the allowed strings.
* When `bassOverridePc` is given, the bass (cycle position 0) uses that pitch
* class instead of the root (alternating bass).
*/
function voiceBar(
tuning: Tuning,
chord: ChordTone[],
pattern: GiulianiPattern,
allowedStrings: number[],
bassOverridePc?: number,
): { stringIndex: number; fret: number; midi: number }[] | null {
const figure = FIGURES[pattern];
const maxVoice = Math.max(...figure) + 1; // e.g. 3 for p-i-m, 4 for p-i-m-a
const bassPc = bassOverridePc ?? chord[0].pc;
// Candidate strings sorted low→high for the bass; upper voices fill above.
const strings = tuning.strings.map((open, idx) => ({ open, idx }));
const sorted = [...strings].sort((a, b) => a.open - b.open);
const usable = sorted.filter(s => allowedStrings.length === 0 || allowedStrings.includes(s.idx));
// cyclePcs[i] = pitch class of cycle position i (0 = bass, 1..3 = upper
// chord tones: third, fifth, octave-of-root).
const cyclePcs = [...chord.map(c => c.pc), (chord[0].pc + 12) % 12];
if (bassOverridePc !== undefined) cyclePcs[0] = bassOverridePc;
const bassCandidates = usable.filter(s => {
// bass must be able to reach the bass pc within 12 frets
for (let fret = 0; fret <= 12; fret++) {
if (((s.open + fret) % 12 + 12) % 12 === bassPc) return true;
}
return false;
});
for (const bass of bassCandidates) {
// assign remaining voices to strings above the bass
const uppers = usable.filter(s => s.open > bass.open).slice(0, maxVoice - 1);
if (uppers.length < maxVoice - 1) continue;
// Voice slots: cycle positions 1..maxVoice-1 map to uppers in order
const slotStrings: ({ open: number; idx: number } | null)[] = [bass];
for (let v = 1; v < maxVoice; v++) slotStrings.push(uppers[v - 1]);
let ok = true;
const placement: { stringIndex: number; fret: number; midi: number }[] = [];
const usedFrets = new Set<string>();
for (let slot = 0; slot < maxVoice; slot++) {
const pc = cyclePcs[slot];
const s = slotStrings[slot];
if (!s) { ok = false; break; }
let found: number | null = null;
for (let fret = 0; fret <= 12; fret++) {
if ((((s.open + fret) % 12) + 12) % 12 === pc) { found = fret; break; }
}
if (found === null) { ok = false; break; }
const midi = s.open + found;
const key = `${slot}:${found}`;
if (usedFrets.has(key)) { ok = false; break; } // same string double-stop — not for these studies
usedFrets.add(key);
placement.push({ stringIndex: s.idx, fret: found, midi });
}
if (ok) return placement;
}
return null;
}
export function generateGiulianiStudy(
config: GiulianiConfig,
eligiblePitches: number[],
): Result<Score> {
const tuning = TUNINGS[config.tuningId];
if (!tuning) return { ok: false, error: 'No tuning available for Giuliani studies — pick a fretted instrument.' };
const key = keyFor(config.keyTonic, config.keyMode);
const poolSet = new Set(eligiblePitches.filter(m => Number.isInteger(m)));
if (poolSet.size === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
const rng = makeRng(config.seed);
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
// Chord sequence: random from selection (no immediate repeats)
const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX);
const poolDegrees = (sel.length > 0 ? sel : Object.keys(DEGREE_INDEX)) as string[];
const degreeIndices: number[] = [];
for (let b = 0; b < config.bars; b++) {
const prev = degreeIndices[b - 1];
const candidates = poolDegrees
.map(d => DEGREE_INDEX[d])
.filter(di => di !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : poolDegrees.map(d => DEGREE_INDEX[d])));
}
// Voice each bar; drop bars that cannot be voiced (rare with full pool).
const figure = FIGURES[config.pattern];
// Meter is ALWAYS auto-derived from the figure length; the user no longer
// picks a meter for Giuliani studies.
const meter = autoGiulianiMeter(figure.length);
const full = meter.numerator * PPQ;
const events: ScoreEvent[] = [];
const chordSymbols: (string | null)[] = [];
const usableBars: { degreeIndex: number; placement: ReturnType<typeof voiceBar> }[] = [];
for (let b = 0; b < degreeIndices.length; b++) {
const di = degreeIndices[b];
const chord = triadPcs(key, di);
// Alternating bass: even bars (0-indexed) take the fifth degree as
// the bass note instead of the root.
const bassOverridePc = config.alternateBass && b % 2 === 0
? chord[2].pc
: undefined;
const placement = voiceBar(tuning, chord, config.pattern, config.allowedStrings as number[], bassOverridePc);
if (!placement) continue;
usableBars.push({ degreeIndex: di, placement });
}
if (usableBars.length === 0) {
return { ok: false, error: 'No bar could be voiced with the current string selection — allow more strings or a lower fret window.' };
}
// Rhythm: ALWAYS eighths (240 ticks). Each bar holds
// `notesPerBar = (bar length / 240)` notes; the figure repeats to fill
// the bar. For 3- and 4-voice figures in their derived meter this is a
// whole number of repetitions; 5-voice figures in 4/4 leave a 720-tick
// rest at the bar end.
const notesPerBar = Math.floor((meter.numerator * PPQ) / 240);
const fillSlots = Math.floor(notesPerBar / figure.length) * figure.length;
let cursor = 0;
for (let b = 0; b < usableBars.length; b++) {
const { degreeIndex, placement } = usableBars[b];
if (!placement) continue; // filtered above, but satisfies strict TS
chordSymbols.push(chordNameFor(key, degreeIndex));
for (let n = 0; n < fillSlots; n++) {
const f = n % figure.length;
const slot = figure[f];
const note = placement[slot];
if (!note) continue;
// spelling via key degree of this pc
const pc = ((note.midi % 12) + 12) % 12;
const d = key.degrees.find(deg => degreePc(deg) === pc);
if (!d) continue;
const octave = (note.midi - pc) / 12 - 1;
events.push({
id: `g${b}-${n}`,
startTick: cursor,
durationTicks: 240,
pitch: { midi: note.midi, step: d.step, alter: d.alter, octave },
});
cursor += 240;
}
// Rest fill for figure lengths that leave a gap (e.g. 5 voices in 4/4:
// one cycle = 1200 ticks, remaining 720 = dotted quarter rest).
const barStart = b * full;
const barEnd = barStart + full;
if (cursor < barEnd) {
const gap = barEnd - cursor;
events.push({
id: `gr${b}`,
startTick: cursor,
durationTicks: gap,
pitch: null,
});
cursor = barEnd;
}
}
const totalBars = usableBars.length;
const score: Score = {
version: 1,
id: `giuliani-${config.keyTonic}-${config.keyMode}-${config.pattern}-${config.bars}b-${config.seed}`,
title: `Giuliani study — ${key.tonic} ${config.pattern} (${totalBars} bars)`,
meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalBars }, (_, i) => ({
number: i + 1,
startTick: i * full,
durationTicks: full,
})),
chordSymbols,
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal giuliani error: ${problems[0]}` };
return { ok: true, value: score };
}

View File

@@ -30,7 +30,7 @@ export interface MelodyConfig {
readonly keyMode: ModeId; readonly keyMode: ModeId;
/** 1..8 */ /** 1..8 */
readonly bars: number; readonly bars: number;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters), /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters),
* 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */ * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */
readonly rhythmLevel: 1 | 2 | 3; readonly rhythmLevel: 1 | 2 | 3;
@@ -65,6 +65,21 @@ const TEMPLATES_4_L3: number[][] = [
[480, 480, 360, 120, 480], [480, 480, 360, 120, 480],
[720, 120, 120, 120, 120, 480], [720, 120, 120, 120, 120, 480],
]; ];
// 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented).
const TEMPLATES_6_L1: number[][] = [
[240, 240, 240, 240, 240, 240, 240, 240, 480, 480],
[480, 480, 240, 240, 240, 240, 480, 480],
[240, 240, 480, 480, 480, 240, 240, 240, 240],
[960, 480, 480, 480, 480],
[480, 480, 480, 480, 960],
[1440, 480, 480, 480],
];
const TEMPLATES_6_L2: number[][] = [
[480, 480, 120, 120, 120, 120, 240, 240, 480, 480],
[120, 120, 120, 120, 480, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],];
const TEMPLATES_3_L1: number[][] = [ const TEMPLATES_3_L1: number[][] = [
[480, 480, 480], [480, 480, 480],
[960, 480], [960, 480],
@@ -86,11 +101,24 @@ const TEMPLATES_3_L3: number[][] = [
[120, 120, 120, 120, 120, 120, 120, 120, 480], [120, 120, 120, 120, 120, 120, 120, 120, 480],
]; ];
function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2 | 3): number[][] { function templatesFor(meter: { numerator: 3 | 4 | 6 }, level: 1 | 2 | 3): number[][] {
const base = meter.numerator === 4 let base: number[][];
? (level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3]) if (meter.numerator === 4) {
: (level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3]); base = level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3];
return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); } else if (meter.numerator === 6) {
base = level === 1 ? TEMPLATES_6_L1 : [...TEMPLATES_6_L1, ...TEMPLATES_6_L2];
} else {
base = level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3];
}
const out = base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
// Never return an empty pool — fall back to the closest available level
// so generator.pick on an empty array can never crash the app.
if (out.length === 0) {
const all = [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_6_L1];
const anyFit = all.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
return anyFit.length > 0 ? anyFit : [[meter.numerator * PPQ]];
}
return out;
} }
/** Motion weights by semitone distance (soft preference, contract C3). */ /** Motion weights by semitone distance (soft preference, contract C3). */

View File

@@ -19,7 +19,7 @@ export interface DrillConfig {
readonly keyMode: ModeId; readonly keyMode: ModeId;
readonly direction: DrillDirection; readonly direction: DrillDirection;
readonly coverage: DrillCoverage; readonly coverage: DrillCoverage;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** Note duration: quarters (default) or eighths. */ /** Note duration: quarters (default) or eighths. */
readonly rhythm?: DrillRhythm; readonly rhythm?: DrillRhythm;
} }

View File

@@ -86,7 +86,7 @@ export interface Score {
readonly version: 1; readonly version: 1;
readonly id: string; readonly id: string;
readonly title: string; readonly title: string;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
readonly key: ScoreKey; readonly key: ScoreKey;
readonly measures: ScoreMeasure[]; readonly measures: ScoreMeasure[];
/** Exactly one voice in v1. */ /** Exactly one voice in v1. */

View File

@@ -42,7 +42,7 @@ export interface RhythmSettings {
volume: number; volume: number;
} }
export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'library' | 'import'; export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'giuliani' | 'library' | 'import';
export type PhrasePace = 'step' | 'tempo'; export type PhrasePace = 'step' | 'tempo';
export interface PhraseSettings { export interface PhraseSettings {
@@ -52,7 +52,7 @@ export interface PhraseSettings {
keyTonic: string; keyTonic: string;
keyMode: string; // ModeId keyMode: string; // ModeId
bars: number; // 1–8 (melodies) bars: number; // 1–8 (melodies)
meterNumerator: 3 | 4; meterNumerator: 3 | 4 | 6;
rhythmLevel: 1 | 2 | 3; rhythmLevel: 1 | 2 | 3;
scaleCoverage: 'one-octave' | 'two-octave' | 'position'; scaleCoverage: 'one-octave' | 'two-octave' | 'position';
scaleDirection: 'up' | 'down' | 'updown'; scaleDirection: 'up' | 'down' | 'updown';
@@ -61,12 +61,14 @@ export interface PhraseSettings {
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start) arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
/** Sequence mode: which degrees may appear (empty = all 7). */ /** Sequence mode: which degrees may appear (empty = all 7). */
arpeggioChordSelection: string[]; arpeggioChordSelection: string[];
arpeggioPattern: 'up' | 'down' | 'updown' | '1235'; arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom' | 'random';
arpeggioCoverage: 'one-octave' | 'two-octave'; arpeggioCoverage: 'one-octave' | 'two-octave';
/** Sequence mode: bars per exercise (1 = single chord). */ /** Sequence mode: bars per exercise (1 = single chord). */
arpeggioBars: number; arpeggioBars: number;
arpeggioProgression: 'random' | 'functional' | 'diatonic-cycle'; arpeggioProgression: 'random' | 'functional' | 'diatonic-cycle';
arpeggioRhythm: 'quarters' | 'eighths'; /** Giuliani study material (own category — voicing-enforced). */
giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim';
giulianiBars: number;
/** Phrase-local fret window (guitar/bass) — overrides the note set. */ /** Phrase-local fret window (guitar/bass) — overrides the note set. */
fretWindowEnabled: boolean; fretWindowEnabled: boolean;
fretMin: number; fretMin: number;
@@ -84,6 +86,10 @@ export interface PhraseSettings {
/** Begin the run as soon as the first note is played (no count-in, /** Begin the run as soon as the first note is played (no count-in,
* step pace only). */ * step pace only). */
autoStartOnNote: boolean; autoStartOnNote: boolean;
/** Custom arpeggio pattern as raw digits (1=root, 2=third, etc.). */
customArpeggioPattern?: string;
/** Alternating bass thumb for Giuliani studies. */
giulianiAlternateBass?: boolean;
} }
export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
@@ -103,7 +109,8 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
arpeggioCoverage: 'one-octave', arpeggioCoverage: 'one-octave',
arpeggioBars: 1, arpeggioBars: 1,
arpeggioProgression: 'functional', arpeggioProgression: 'functional',
arpeggioRhythm: 'quarters', giulianiPattern: 'pima',
giulianiBars: 4,
fretWindowEnabled: false, fretWindowEnabled: false,
fretMin: 0, fretMin: 0,
fretMax: 4, fretMax: 4,
@@ -115,6 +122,8 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
autoContinue: true, autoContinue: true,
inputMode: 'mic', inputMode: 'mic',
autoStartOnNote: false, autoStartOnNote: false,
customArpeggioPattern: '',
giulianiAlternateBass: false,
}; };
export function getPhraseSettings(s: AppSettings): PhraseSettings { export function getPhraseSettings(s: AppSettings): PhraseSettings {