Skip to content

Commit 63e2b9a

Browse files
committed
harmonic analysis engine
1 parent 6574f56 commit 63e2b9a

16 files changed

Lines changed: 1271 additions & 42 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@ All notable changes to `@jmove/generator` will be documented in this file.
44

55
This project follows [Semantic Versioning](https://semver.org/).
66

7+
## [1.3.0] - 2026-07-17
8+
9+
### Added
10+
11+
- **Harmonic analysis engine** (`harmonicAnalysis.ts`) - analyzes chord progressions for key centers, Roman numeral degrees, harmonic function (tonic/predominant/dominant), ii-V-I patterns, cadences, secondary dominants, modulations, and per-chord tension values
12+
- New types: `ChordAnalysis`, `HarmonicFunction`, `CadenceType`, `HarmonicAnalysisResult`
13+
- `ChordEvent.analysis` - optional per-chord harmonic annotation populated by `analyzeHarmony()`
14+
- `BandContext.harmonicAnalysis` - full analysis result available to all generators
15+
- `analyzeHarmony()` exported from package for standalone use
16+
- 61 new tests covering key detection, degree assignment, ii-V-I detection, secondary dominants, cadences, tension values, modulation detection, real jazz progressions, key transposition accuracy, and voicing deduplication across styles
17+
18+
### Fixed
19+
20+
- **Key transposition bug** - `detectTemplateKey()` used last-chord heuristic, causing wrong key for templates ending on non-tonic chords (e.g. "Blues in Bb" produced chords in Ab). Replaced with explicit `TEMPLATE_KEY` map covering all ~60 template variants
21+
- **Duplicate MIDI notes in voicings** - cluster, inversion, open, and root-position voicing builders lacked pitch deduplication after octave clamping. Added `[...new Set()]` to 4 fallback paths
22+
- **Harsh minor-2nd clusters in Alfa Mist** - minor cluster intervals `[2,3,5,10]` always placed 9th and b3rd one semitone apart. Changed to `[3,5,7,10]` (b3-4-5-b7)
23+
24+
### Changed
25+
26+
- **Ensemble** runs harmonic analysis once before generation; all three generators receive annotated chords with per-chord function, tension, and cadence data
27+
- **Piano comping** uses `chord.analysis` for ii-V-I awareness instead of ad-hoc `isDominantQuality`/`isResolvingDominant`; altered voicings now trigger on secondary dominants and authentic cadence dominants, not just V-I root motion; anticipation probability modulated by per-chord tension (high-tension dominants anticipate more)
28+
- **Walking bass** beat 1 selection is harmonic-aware: 85% root on tonic resolution (strong arrival), 55% root on dominant chords (more variety for tension); leading-tone approach on beat 4 of V-I cadences (45% probability) creates strongest resolution motion
29+
- **Drum fills** placed before authentic cadence resolutions using harmonic analysis, not just form/phrase markers
30+
- `planMusicalIntents` uses average phrase tension to modulate anticipation and passing chord probabilities per phrase
31+
732
## [1.2.9] - 2026-07-16
833

934
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Deep technical articles about how JMove and the generator work:
3838
- **Drum Patterns** - 19 styles with humanization, ghost notes, groove templates based on GrooVAE research. Holdsworth preset features Chad Wackerman-inspired drumming: cross-stick interjections, ghost cascades, linear fills, 11/8-specific patterns
3939

4040
**Intelligence**
41+
- **Harmonic Analysis Engine** - `analyzeHarmony()` detects key centers, assigns Roman numeral degrees, identifies ii-V-I patterns, cadences, secondary dominants, and modulations. Per-chord tension values drive musical decisions across all generators
4142
- **Musicality Engine** - phrase-level intelligence: dynamic drops, air gaps, harmonic anticipation, passing chords, conversation dynamics, motif memory. Four parameters (creativity, conversation, airGaps, harmonicFreedom) control musical character
4243
- **Per-Instrument Complexity** - 3 general sliders drive 13 granular sub-controls via piecewise linear mapping. Manual overrides take precedence over derived values
4344
- **Full Song Form** - multi-section arrangements with dynamic shaping and section-driven dynamics (intro sparse, shout dense)

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jmove/generator",
3-
"version": "1.2.9",
3+
"version": "1.3.0",
44
"description": "Jazz backing track generator — walking bass, piano comping, drum patterns, and style presets for practice sessions",
55
"license": "MIT",
66
"author": "JMove",

src/drumPatterns.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3024,11 +3024,23 @@ export function generateDrumPattern(options: DrumPatternOptions = {}): DrumHit[]
30243024
// BandContext: fill probability scales with energy — sparse sections rarely fill
30253025
// In streaming (measures=1), fillHint from ensemble.ts provides lookahead info
30263026
// since the single-measure loop can't look at m+1/m+2.
3027+
//
3028+
// Harmonic-aware: boost fill probability before cadence resolutions.
3029+
// Real drummers set up cadences with fills to mark the arrival.
30273030
let fillPattern: Pattern | null = null;
30283031
const fillHint = options.fillHint;
3032+
const isBeforeCadence = (() => {
3033+
const ha = bandCtx?.harmonicAnalysis;
3034+
if (!ha) return false;
3035+
// Check if next measure's chord is a cadence resolution (V-I arrival)
3036+
const nextMeasureIdx = absoluteM + 1;
3037+
return nextMeasureIdx < ha.chordAnalyses.length
3038+
&& ha.chordAnalyses[nextMeasureIdx].cadenceRole === "resolution"
3039+
&& ha.chordAnalyses[nextMeasureIdx].cadenceType === "authentic";
3040+
})();
30293041
if (FILL_STYLES.has(style) && (m > 0 || fillHint)) {
30303042
const isBeforeSectionMarker = fillHint === "section" || sectionMarkers.includes(m + 1);
3031-
const isBeforeFormMarker = fillHint === "phrase" || formMarkers.includes(m + 1);
3043+
const isBeforeFormMarker = fillHint === "phrase" || formMarkers.includes(m + 1) || isBeforeCadence;
30323044
const isSetupBar = fillHint === "setup" || sectionMarkers.includes(m + 2);
30333045

30343046
// Energy multiplier: at energy 0.3 fills are 40% as likely, at 1.0 fully likely

src/ensemble.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
import { generateDrumPattern } from "./drumPatterns";
1818
import { generateWalkingBass } from "./walkingBass";
1919
import { generatePianoComping } from "./pianoComping";
20+
import { analyzeHarmony } from "./harmonicAnalysis";
2021
import { createPRNG, randomSeed, deriveStream, STREAM_DRUMS, STREAM_BASS, STREAM_PIANO } from "./prng";
2122
import type {
2223
BandContext,
24+
ChordEvent,
25+
HarmonicAnalysisResult,
2326
PhraseMap,
2427
PhraseIntent,
2528
PhraseArc,
@@ -33,6 +36,25 @@ import type {
3336
CompNote,
3437
} from "./types";
3538

39+
// ── Harmonic Analysis Annotation ──
40+
41+
/**
42+
* Run harmonic analysis on chord events and return annotated copies.
43+
* Each returned ChordEvent has its `analysis` field populated.
44+
*/
45+
function annotateChords(
46+
chords: ChordEvent[],
47+
keyHint: string | undefined,
48+
measureDuration: number,
49+
): { annotated: ChordEvent[]; analysis: HarmonicAnalysisResult } {
50+
const analysis = analyzeHarmony(chords, keyHint, measureDuration);
51+
const annotated = chords.map((c, i) => ({
52+
...c,
53+
analysis: analysis.chordAnalyses[i],
54+
}));
55+
return { annotated, analysis };
56+
}
57+
3658
// ── Phrase Map Computation ──
3759

3860
function computePhraseMap(options: EnsembleOptions): PhraseMap {
@@ -86,6 +108,7 @@ function planMusicalIntents(
86108
phraseMap: PhraseMap,
87109
options: EnsembleOptions,
88110
rng: () => number,
111+
harmonic?: HarmonicAnalysisResult,
89112
): PhraseIntent[] {
90113
const creativity = (options.creativity ?? 35) / 100; // 0-1
91114
const conversation = (options.conversation ?? 30) / 100;
@@ -180,9 +203,25 @@ function planMusicalIntents(
180203
}
181204

182205
// ── Harmonic Anticipation ──
183-
// Higher harmonicFreedom → more likely piano plays next chord early
184-
const anticipationChance = harmonicFreedom * 0.35; // max 35%
185-
const passingChordChance = harmonicFreedom * 0.25; // max 25%
206+
// Higher harmonicFreedom → more likely piano plays next chord early.
207+
// Boost on predominant phrases (lots of ii chords setting up V),
208+
// reduce on tonic-heavy phrases (already resolved, no need to rush).
209+
let anticipationChance = harmonicFreedom * 0.35; // max 35%
210+
let passingChordChance = harmonicFreedom * 0.25; // max 25%
211+
if (harmonic && harmonic.chordAnalyses.length > 0) {
212+
// Average tension across chords in this phrase
213+
const phraseChordsStart = phraseStart;
214+
const phraseChordsEnd = phraseEnd;
215+
const phraseAnalyses = harmonic.chordAnalyses.filter((_, idx) =>
216+
idx >= phraseChordsStart && idx < phraseChordsEnd);
217+
if (phraseAnalyses.length > 0) {
218+
const avgTension = phraseAnalyses.reduce((s, a) => s + a.tension, 0) / phraseAnalyses.length;
219+
// High-tension phrases: boost anticipation (forward motion)
220+
// Low-tension phrases: reduce passing chords (let resolution breathe)
221+
anticipationChance *= 0.7 + avgTension * 0.6; // range: 0.7x to 1.3x
222+
passingChordChance *= 0.6 + avgTension * 0.8; // range: 0.6x to 1.4x
223+
}
224+
}
186225

187226
// ── Motif Lock Duration ──
188227
// How many bars piano/bass hold their pattern before changing.
@@ -412,10 +451,15 @@ export function generateEnsemble(options: EnsembleOptions): EnsembleResult {
412451
const density = options.density ?? 50;
413452
const swingAmount = options.swingAmount ?? 100;
414453

454+
// ── Harmonic Analysis (run once, before any generation) ──
455+
const { annotated: annotatedChords, analysis: harmonicAnalysis } =
456+
annotateChords(options.chordEvents, undefined, measureDuration);
457+
415458
// Compute phrase structure and musical intent
416459
const phraseMap = computePhraseMap(options);
417-
phraseMap.intents = planMusicalIntents(phraseMap, options, masterRng);
460+
phraseMap.intents = planMusicalIntents(phraseMap, options, masterRng, harmonicAnalysis);
418461
const context = initContext(phraseMap, options);
462+
context.harmonicAnalysis = harmonicAnalysis;
419463

420464
// Determine section energy for density scaling.
421465
// Batch path: compute weighted average energy across all measures so that
@@ -482,7 +526,7 @@ export function generateEnsemble(options: EnsembleOptions): EnsembleResult {
482526

483527
// ── Step 2: Generate Bass (informed by kick pattern) ──
484528
const scaledBassDensity = scaleDensity(density, energy);
485-
const bassNotes = generateWalkingBass(options.chordEvents, {
529+
const bassNotes = generateWalkingBass(annotatedChords, {
486530
style: bassStyle,
487531
tempo,
488532
swingAmount,
@@ -508,7 +552,7 @@ export function generateEnsemble(options: EnsembleOptions): EnsembleResult {
508552
? Math.round(scaledPianoDensity * 0.8)
509553
: scaledPianoDensity;
510554

511-
const pianoNotes = generatePianoComping(options.chordEvents, {
555+
const pianoNotes = generatePianoComping(annotatedChords, {
512556
style: pianoStyle,
513557
tempo,
514558
swingAmount,
@@ -557,9 +601,14 @@ export function* generateEnsembleMeasures(options: EnsembleOptions): Generator<M
557601
const density = options.density ?? 50;
558602
const swingAmount = options.swingAmount ?? 100;
559603

604+
// ── Harmonic Analysis (run once, before any generation) ──
605+
const { annotated: annotatedChords, analysis: harmonicAnalysis } =
606+
annotateChords(options.chordEvents, undefined, measureDuration);
607+
560608
const phraseMap = computePhraseMap(options);
561-
phraseMap.intents = planMusicalIntents(phraseMap, options, masterRng);
609+
phraseMap.intents = planMusicalIntents(phraseMap, options, masterRng, harmonicAnalysis);
562610
const context = initContext(phraseMap, options);
611+
context.harmonicAnalysis = harmonicAnalysis;
563612

564613
const drumStyle = options.instrumentStyles?.drums ?? options.style;
565614
const bassStyle = options.instrumentStyles?.bass ?? options.style;
@@ -642,8 +691,8 @@ export function* generateEnsembleMeasures(options: EnsembleOptions): Generator<M
642691
const hitsPerMeasure = totalDrumHits / measuresGenerated;
643692
context.drumDensity = Math.min(1, Math.max(0, (hitsPerMeasure - 5) / 40));
644693

645-
// Generate bass for this measure's chords
646-
const measureChords = options.chordEvents.filter(
694+
// Generate bass for this measure's chords (use annotated chords with analysis)
695+
const measureChords = annotatedChords.filter(
647696
c => c.time >= measureStart - 0.001 && c.time < measureEnd,
648697
);
649698
const bassSlice = measureChords.length > 0

0 commit comments

Comments
 (0)