@hum2music/harmony 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +18 -4
- package/src/analyze.ts +38 -0
- package/src/bars.ts +28 -0
- package/src/chord-candidates.ts +69 -0
- package/src/harmonic-naturalness.ts +19 -0
- package/src/index.ts +11 -0
- package/src/key-estimation.ts +61 -0
- package/src/progression-search.ts +97 -0
- package/src/quantize.ts +35 -0
- package/src/song-structure.ts +37 -0
- package/src/tempo.ts +61 -0
- package/src/timed-note.ts +7 -0
- package/src/voice-leading.ts +24 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hum2music/harmony",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tempo, key, chord-progression, and song-structure estimation for Hum2Music.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://github.com/hum2music/hum2music",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/hum2music/hum2music.git",
|
|
10
|
+
"directory": "packages/harmony"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": ["src"],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@hum2music/core": "0.1.0",
|
|
18
|
+
"@hum2music/music-theory": "0.1.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/analyze.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChordEvent, KeyCandidate, NoteEvent, SongSection, TempoCandidate } from "@hum2music/core";
|
|
2
|
+
import { groupNotesIntoBars } from "./bars";
|
|
3
|
+
import { estimateKey } from "./key-estimation";
|
|
4
|
+
import { quantizeToBeats } from "./quantize";
|
|
5
|
+
import { searchChordProgression, type ProgressionSearchWeights } from "./progression-search";
|
|
6
|
+
import { estimateSongStructure } from "./song-structure";
|
|
7
|
+
import { estimateTempo } from "./tempo";
|
|
8
|
+
import type { TimedNote } from "./timed-note";
|
|
9
|
+
|
|
10
|
+
export interface HarmonyAnalysis {
|
|
11
|
+
tempoCandidates: TempoCandidate[];
|
|
12
|
+
selectedBpm: number;
|
|
13
|
+
keyCandidates: KeyCandidate[];
|
|
14
|
+
melody: NoteEvent[];
|
|
15
|
+
chords: ChordEvent[];
|
|
16
|
+
songSections: SongSection[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AnalyzeHarmonyOptions {
|
|
20
|
+
overrideBpm?: number;
|
|
21
|
+
progressionWeights?: ProgressionSearchWeights;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Harmony analysis pipeline: timed notes -> tempo/key candidates -> quantized melody -> chord progression -> song structure. */
|
|
25
|
+
export function analyzeHarmony(notes: TimedNote[], options: AnalyzeHarmonyOptions | number = {}): HarmonyAnalysis {
|
|
26
|
+
const resolvedOptions: AnalyzeHarmonyOptions = typeof options === "number" ? { overrideBpm: options } : options;
|
|
27
|
+
|
|
28
|
+
const tempoCandidates = estimateTempo(notes);
|
|
29
|
+
const selectedBpm = resolvedOptions.overrideBpm ?? tempoCandidates[0]?.bpm ?? 120;
|
|
30
|
+
|
|
31
|
+
const melody = quantizeToBeats(notes, selectedBpm);
|
|
32
|
+
const keyCandidates = estimateKey(notes);
|
|
33
|
+
const bars = groupNotesIntoBars(melody);
|
|
34
|
+
const chords = searchChordProgression(bars, keyCandidates[0], resolvedOptions.progressionWeights);
|
|
35
|
+
const songSections = estimateSongStructure(chords);
|
|
36
|
+
|
|
37
|
+
return { tempoCandidates, selectedBpm, keyCandidates, melody, chords, songSections };
|
|
38
|
+
}
|
package/src/bars.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { BarSegment } from "./chord-candidates";
|
|
3
|
+
|
|
4
|
+
/** Splits quantized notes into fixed-size bars, per PRD §7.2's 4/4-only MVP scope. */
|
|
5
|
+
export function groupNotesIntoBars(notes: NoteEvent[], beatsPerBar = 4): BarSegment[] {
|
|
6
|
+
if (notes.length === 0) return [];
|
|
7
|
+
|
|
8
|
+
const lastEndBeat = Math.max(...notes.map((note) => note.startBeat + note.durationBeats));
|
|
9
|
+
const barCount = Math.ceil(lastEndBeat / beatsPerBar);
|
|
10
|
+
|
|
11
|
+
const bars: BarSegment[] = Array.from({ length: barCount }, (_, index) => ({
|
|
12
|
+
startBeat: index * beatsPerBar,
|
|
13
|
+
endBeat: (index + 1) * beatsPerBar,
|
|
14
|
+
notes: [],
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
for (const note of notes) {
|
|
18
|
+
const noteEndBeat = note.startBeat + note.durationBeats;
|
|
19
|
+
for (const bar of bars) {
|
|
20
|
+
const overlapStart = Math.max(note.startBeat, bar.startBeat);
|
|
21
|
+
const overlapEnd = Math.min(noteEndBeat, bar.endBeat);
|
|
22
|
+
const overlapBeats = overlapEnd - overlapStart;
|
|
23
|
+
if (overlapBeats > 0) bar.notes.push({ pitch: note.pitch, weight: overlapBeats });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return bars;
|
|
28
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CHORD_QUALITIES, type ChordQuality, chordPitchClasses, chordSymbol, pitchClassOf } from "@hum2music/music-theory";
|
|
2
|
+
|
|
3
|
+
export interface ChordCandidate {
|
|
4
|
+
root: number;
|
|
5
|
+
quality: ChordQuality;
|
|
6
|
+
symbol: string;
|
|
7
|
+
/** Fraction (0-1) of the bar's duration-weighted pitch-class mass that are chord tones. */
|
|
8
|
+
melodyFit: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface WeightedPitch {
|
|
12
|
+
pitch: number;
|
|
13
|
+
/** Duration (in beats) this pitch occupies within the bar. */
|
|
14
|
+
weight: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BarSegment {
|
|
18
|
+
startBeat: number;
|
|
19
|
+
endBeat: number;
|
|
20
|
+
notes: WeightedPitch[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Generates every (root, quality) chord as a scored candidate for one bar. */
|
|
24
|
+
export function generateChordCandidates(segment: BarSegment): ChordCandidate[] {
|
|
25
|
+
const histogram = buildWeightedPitchClassHistogram(segment.notes);
|
|
26
|
+
const candidates: ChordCandidate[] = [];
|
|
27
|
+
|
|
28
|
+
for (let root = 0; root < 12; root++) {
|
|
29
|
+
for (const quality of CHORD_QUALITIES) {
|
|
30
|
+
const chordTones = new Set(chordPitchClasses(root, quality));
|
|
31
|
+
candidates.push({
|
|
32
|
+
root,
|
|
33
|
+
quality,
|
|
34
|
+
symbol: chordSymbol(root, quality),
|
|
35
|
+
melodyFit: scoreMelodyFit(histogram, chordTones),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return candidates;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildWeightedPitchClassHistogram(notes: WeightedPitch[]): number[] {
|
|
44
|
+
const histogram = new Array(12).fill(0);
|
|
45
|
+
for (const note of notes) histogram[pitchClassOf(note.pitch)] += note.weight;
|
|
46
|
+
const total = histogram.reduce((sum, value) => sum + value, 0);
|
|
47
|
+
return total === 0 ? histogram : histogram.map((value) => value / total);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fraction of the bar's pitch-class mass covered by this chord's tones,
|
|
52
|
+
* scaled down by how many of the chord's own tones go unconfirmed by the
|
|
53
|
+
* melody. Without this scaling, a 7th/6th chord whose extra tone simply
|
|
54
|
+
* never sounds would always tie a plain triad that covers the same notes,
|
|
55
|
+
* since an absent tone costs nothing — this way the smallest chord that
|
|
56
|
+
* fully explains the melody wins, rather than an assumed, unheard tension.
|
|
57
|
+
*/
|
|
58
|
+
function scoreMelodyFit(histogram: number[], chordTones: Set<number>): number {
|
|
59
|
+
let matchedMass = 0;
|
|
60
|
+
let confirmedToneCount = 0;
|
|
61
|
+
for (const pitchClass of chordTones) {
|
|
62
|
+
if (histogram[pitchClass] > 0) {
|
|
63
|
+
matchedMass += histogram[pitchClass];
|
|
64
|
+
confirmedToneCount++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const confirmedCoverage = confirmedToneCount / chordTones.size;
|
|
68
|
+
return matchedMass * confirmedCoverage;
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { pitchClassOf } from "@hum2music/music-theory";
|
|
2
|
+
|
|
3
|
+
/** Pitch classes ordered by stacking perfect fifths from C, for circle-of-fifths distance. */
|
|
4
|
+
const CIRCLE_OF_FIFTHS_ORDER = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5];
|
|
5
|
+
const FIFTHS_POSITION = new Map(CIRCLE_OF_FIFTHS_ORDER.map((pitchClass, position) => [pitchClass, position]));
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scores how "natural" a chord-root transition is: root motion by a fifth
|
|
9
|
+
* (dominant/subdominant motion) scores highest, a tritone-apart move lowest.
|
|
10
|
+
* A simplified stand-in for a full functional-harmony transition table
|
|
11
|
+
* (PRD §8.3's H term).
|
|
12
|
+
*/
|
|
13
|
+
export function harmonicNaturalnessScore(prevRoot: number, curRoot: number): number {
|
|
14
|
+
const prevPosition = FIFTHS_POSITION.get(pitchClassOf(prevRoot))!;
|
|
15
|
+
const curPosition = FIFTHS_POSITION.get(pitchClassOf(curRoot))!;
|
|
16
|
+
const rawDistance = Math.abs(prevPosition - curPosition) % 12;
|
|
17
|
+
const fifthsDistance = Math.min(rawDistance, 12 - rawDistance);
|
|
18
|
+
return 1 - fifthsDistance / 6;
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./timed-note";
|
|
2
|
+
export * from "./tempo";
|
|
3
|
+
export * from "./quantize";
|
|
4
|
+
export * from "./key-estimation";
|
|
5
|
+
export * from "./chord-candidates";
|
|
6
|
+
export * from "./bars";
|
|
7
|
+
export * from "./harmonic-naturalness";
|
|
8
|
+
export * from "./voice-leading";
|
|
9
|
+
export * from "./progression-search";
|
|
10
|
+
export * from "./song-structure";
|
|
11
|
+
export * from "./analyze";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { KeyCandidate } from "@hum2music/core";
|
|
2
|
+
import { KEY_PROFILES, NOTE_NAMES, pitchClassOf, type ScaleMode } from "@hum2music/music-theory";
|
|
3
|
+
import type { TimedNote } from "./timed-note";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Correlates a duration-weighted pitch-class histogram against all 24
|
|
7
|
+
* Krumhansl-Kessler key profiles (12 tonics x major/minor) and returns the
|
|
8
|
+
* best-matching keys, ranked, rather than committing to a single guess
|
|
9
|
+
* (PRD §8.1).
|
|
10
|
+
*/
|
|
11
|
+
export function estimateKey(notes: TimedNote[], maxCandidates = 5): KeyCandidate[] {
|
|
12
|
+
const histogram = buildPitchClassHistogram(notes);
|
|
13
|
+
|
|
14
|
+
const candidates: KeyCandidate[] = [];
|
|
15
|
+
for (let tonic = 0; tonic < 12; tonic++) {
|
|
16
|
+
for (const mode of ["major", "minor"] as ScaleMode[]) {
|
|
17
|
+
const rotatedProfile = rotateProfileToTonic(KEY_PROFILES[mode], tonic);
|
|
18
|
+
const correlation = pearsonCorrelation(histogram, rotatedProfile);
|
|
19
|
+
candidates.push({ tonic: NOTE_NAMES[tonic], mode, confidence: clamp01((correlation + 1) / 2) });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
candidates.sort((a, b) => b.confidence - a.confidence);
|
|
24
|
+
return candidates.slice(0, maxCandidates);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildPitchClassHistogram(notes: TimedNote[]): number[] {
|
|
28
|
+
const histogram = new Array(12).fill(0);
|
|
29
|
+
for (const note of notes) histogram[pitchClassOf(note.pitch)] += note.durationMs;
|
|
30
|
+
const total = histogram.reduce((sum, value) => sum + value, 0);
|
|
31
|
+
return total === 0 ? histogram : histogram.map((value) => value / total);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `profile[i]` is defined relative to the tonic; this aligns it to absolute pitch classes. */
|
|
35
|
+
function rotateProfileToTonic(profile: number[], tonic: number): number[] {
|
|
36
|
+
return Array.from({ length: 12 }, (_, pitchClass) => profile[(((pitchClass - tonic) % 12) + 12) % 12]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pearsonCorrelation(a: number[], b: number[]): number {
|
|
40
|
+
const n = a.length;
|
|
41
|
+
const meanA = a.reduce((sum, value) => sum + value, 0) / n;
|
|
42
|
+
const meanB = b.reduce((sum, value) => sum + value, 0) / n;
|
|
43
|
+
|
|
44
|
+
let numerator = 0;
|
|
45
|
+
let varianceA = 0;
|
|
46
|
+
let varianceB = 0;
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
const deviationA = a[i] - meanA;
|
|
49
|
+
const deviationB = b[i] - meanB;
|
|
50
|
+
numerator += deviationA * deviationB;
|
|
51
|
+
varianceA += deviationA * deviationA;
|
|
52
|
+
varianceB += deviationB * deviationB;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const denominator = Math.sqrt(varianceA * varianceB);
|
|
56
|
+
return denominator === 0 ? 0 : numerator / denominator;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clamp01(value: number): number {
|
|
60
|
+
return Math.min(1, Math.max(0, value));
|
|
61
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ChordEvent, KeyCandidate } from "@hum2music/core";
|
|
2
|
+
import { NOTE_NAMES, isDiatonic, type ScaleMode } from "@hum2music/music-theory";
|
|
3
|
+
import { generateChordCandidates, type BarSegment, type ChordCandidate } from "./chord-candidates";
|
|
4
|
+
import { harmonicNaturalnessScore } from "./harmonic-naturalness";
|
|
5
|
+
import { voiceLeadingScore } from "./voice-leading";
|
|
6
|
+
|
|
7
|
+
export interface ProgressionSearchWeights {
|
|
8
|
+
melodyFit: number;
|
|
9
|
+
harmonicNaturalness: number;
|
|
10
|
+
voiceLeading: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WEIGHTS: ProgressionSearchWeights = {
|
|
14
|
+
melodyFit: 0.5,
|
|
15
|
+
harmonicNaturalness: 0.3,
|
|
16
|
+
voiceLeading: 0.2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Dynamic-programming search over bar-by-bar chord candidates, maximizing
|
|
21
|
+
* S(c) = wM*M + wH*H + wV*V across the whole progression rather than
|
|
22
|
+
* picking each bar's locally-best chord (PRD §8.3). The style term (S) is
|
|
23
|
+
* left out of the MVP weighting (see docs/specs/style-profiles.md).
|
|
24
|
+
*/
|
|
25
|
+
export function searchChordProgression(
|
|
26
|
+
bars: BarSegment[],
|
|
27
|
+
topKey?: KeyCandidate,
|
|
28
|
+
weights: ProgressionSearchWeights = DEFAULT_WEIGHTS,
|
|
29
|
+
): ChordEvent[] {
|
|
30
|
+
if (bars.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const candidatesPerBar = bars.map((bar) => generateChordCandidates(bar));
|
|
33
|
+
const candidateCount = candidatesPerBar[0].length;
|
|
34
|
+
|
|
35
|
+
const barScore = (candidate: ChordCandidate) =>
|
|
36
|
+
weights.melodyFit * candidate.melodyFit * diatonicBonus(candidate.root, topKey);
|
|
37
|
+
|
|
38
|
+
const dp: number[][] = candidatesPerBar.map(() => new Array(candidateCount).fill(-Infinity));
|
|
39
|
+
const parent: number[][] = candidatesPerBar.map(() => new Array(candidateCount).fill(-1));
|
|
40
|
+
|
|
41
|
+
for (let c = 0; c < candidateCount; c++) {
|
|
42
|
+
dp[0][c] = barScore(candidatesPerBar[0][c]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let i = 1; i < bars.length; i++) {
|
|
46
|
+
for (let c = 0; c < candidateCount; c++) {
|
|
47
|
+
const candidate = candidatesPerBar[i][c];
|
|
48
|
+
let best = -Infinity;
|
|
49
|
+
let bestPrev = -1;
|
|
50
|
+
|
|
51
|
+
for (let p = 0; p < candidateCount; p++) {
|
|
52
|
+
const prevCandidate = candidatesPerBar[i - 1][p];
|
|
53
|
+
const transitionScore =
|
|
54
|
+
weights.harmonicNaturalness * harmonicNaturalnessScore(prevCandidate.root, candidate.root) +
|
|
55
|
+
weights.voiceLeading *
|
|
56
|
+
voiceLeadingScore(prevCandidate.root, prevCandidate.quality, candidate.root, candidate.quality);
|
|
57
|
+
const score = dp[i - 1][p] + transitionScore;
|
|
58
|
+
if (score > best) {
|
|
59
|
+
best = score;
|
|
60
|
+
bestPrev = p;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
dp[i][c] = best + barScore(candidate);
|
|
65
|
+
parent[i][c] = bestPrev;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lastBarIndex = bars.length - 1;
|
|
70
|
+
let bestLastCandidate = 0;
|
|
71
|
+
for (let c = 1; c < candidateCount; c++) {
|
|
72
|
+
if (dp[lastBarIndex][c] > dp[lastBarIndex][bestLastCandidate]) bestLastCandidate = c;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const chosenIndexPerBar = new Array<number>(bars.length);
|
|
76
|
+
chosenIndexPerBar[lastBarIndex] = bestLastCandidate;
|
|
77
|
+
for (let i = lastBarIndex; i > 0; i--) {
|
|
78
|
+
chosenIndexPerBar[i - 1] = parent[i][chosenIndexPerBar[i]];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return bars.map((bar, i) => {
|
|
82
|
+
const candidate = candidatesPerBar[i][chosenIndexPerBar[i]];
|
|
83
|
+
return {
|
|
84
|
+
symbol: candidate.symbol,
|
|
85
|
+
startBeat: bar.startBeat,
|
|
86
|
+
durationBeats: bar.endBeat - bar.startBeat,
|
|
87
|
+
confidence: candidate.melodyFit,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function diatonicBonus(root: number, topKey?: KeyCandidate): number {
|
|
93
|
+
if (!topKey) return 1;
|
|
94
|
+
const tonicPitchClass = (NOTE_NAMES as readonly string[]).indexOf(topKey.tonic);
|
|
95
|
+
if (tonicPitchClass === -1) return 1;
|
|
96
|
+
return isDiatonic(root, tonicPitchClass, topKey.mode as ScaleMode) ? 1 : 0.85;
|
|
97
|
+
}
|
package/src/quantize.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { TimedNote } from "./timed-note";
|
|
3
|
+
|
|
4
|
+
/** Sixteenth-note grid: fine enough that quarter/eighth/dotted values fall exactly on it. */
|
|
5
|
+
const GRID_STEP_BEATS = 1 / 4;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts absolute-time notes to beat-relative notes for a given tempo
|
|
9
|
+
* hypothesis, snapping to the nearest sixteenth-note grid position. The
|
|
10
|
+
* pre-quantization timing is preserved in `expression.timingOffsetMs`
|
|
11
|
+
* (PRD §7.3) rather than discarded.
|
|
12
|
+
*/
|
|
13
|
+
export function quantizeToBeats(notes: TimedNote[], bpm: number): NoteEvent[] {
|
|
14
|
+
const msPerBeat = 60000 / bpm;
|
|
15
|
+
|
|
16
|
+
return notes.map((note) => {
|
|
17
|
+
const rawStartBeat = note.startMs / msPerBeat;
|
|
18
|
+
const startBeat = roundToGrid(rawStartBeat);
|
|
19
|
+
const durationBeats = Math.max(GRID_STEP_BEATS, roundToGrid(note.durationMs / msPerBeat));
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
pitch: note.pitch,
|
|
23
|
+
startBeat,
|
|
24
|
+
durationBeats,
|
|
25
|
+
velocity: note.velocity,
|
|
26
|
+
expression: {
|
|
27
|
+
timingOffsetMs: (rawStartBeat - startBeat) * msPerBeat,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function roundToGrid(valueInBeats: number): number {
|
|
34
|
+
return Math.round(valueInBeats / GRID_STEP_BEATS) * GRID_STEP_BEATS;
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ChordEvent, SongSection } from "@hum2music/core";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CYCLE_LENGTH_BARS = 4;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects repeating cycles in the bar-level chord sequence and labels them
|
|
7
|
+
* A, B, C, ... reusing a label whenever an earlier cycle's chord sequence
|
|
8
|
+
* repeats exactly. A simple, defensible stand-in for full song-structure
|
|
9
|
+
* estimation — detecting verse/chorus boundaries from audio/lyrics is out
|
|
10
|
+
* of scope for a rule-based system working from chords alone.
|
|
11
|
+
*/
|
|
12
|
+
export function estimateSongStructure(chords: ChordEvent[], cycleLengthBars = DEFAULT_CYCLE_LENGTH_BARS): SongSection[] {
|
|
13
|
+
if (chords.length === 0) return [];
|
|
14
|
+
|
|
15
|
+
const symbols = chords.map((chord) => chord.symbol);
|
|
16
|
+
const sections: SongSection[] = [];
|
|
17
|
+
const seenCycles: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (let start = 0; start < symbols.length; start += cycleLengthBars) {
|
|
20
|
+
const cycle = symbols.slice(start, start + cycleLengthBars);
|
|
21
|
+
const key = cycle.join(",");
|
|
22
|
+
|
|
23
|
+
let labelIndex = seenCycles.indexOf(key);
|
|
24
|
+
if (labelIndex === -1) {
|
|
25
|
+
seenCycles.push(key);
|
|
26
|
+
labelIndex = seenCycles.length - 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
sections.push({ label: indexToLabel(labelIndex), startBarIndex: start, barCount: cycle.length });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return sections;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function indexToLabel(index: number): string {
|
|
36
|
+
return String.fromCharCode(65 + (index % 26));
|
|
37
|
+
}
|
package/src/tempo.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { TempoCandidate } from "@hum2music/core";
|
|
2
|
+
import type { TimedNote } from "./timed-note";
|
|
3
|
+
|
|
4
|
+
export interface TempoEstimationOptions {
|
|
5
|
+
minIntervalMs?: number;
|
|
6
|
+
maxIntervalMs?: number;
|
|
7
|
+
maxCandidates?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds a histogram of inter-onset intervals (read as implied quarter-note
|
|
12
|
+
* BPM) and returns the most frequent values as ranked candidates. Half/double
|
|
13
|
+
* tempo relationships are flagged rather than collapsed, per PRD §7.1.
|
|
14
|
+
*/
|
|
15
|
+
export function estimateTempo(notes: TimedNote[], options: TempoEstimationOptions = {}): TempoCandidate[] {
|
|
16
|
+
const minIntervalMs = options.minIntervalMs ?? 100;
|
|
17
|
+
const maxIntervalMs = options.maxIntervalMs ?? 2000;
|
|
18
|
+
const maxCandidates = options.maxCandidates ?? 5;
|
|
19
|
+
|
|
20
|
+
const onsets = notes.map((note) => note.startMs).sort((a, b) => a - b);
|
|
21
|
+
const intervals: number[] = [];
|
|
22
|
+
for (let i = 1; i < onsets.length; i++) {
|
|
23
|
+
const interval = onsets[i] - onsets[i - 1];
|
|
24
|
+
if (interval >= minIntervalMs && interval <= maxIntervalMs) intervals.push(interval);
|
|
25
|
+
}
|
|
26
|
+
if (intervals.length === 0) return [];
|
|
27
|
+
|
|
28
|
+
const bpmCounts = new Map<number, number>();
|
|
29
|
+
for (const interval of intervals) {
|
|
30
|
+
const bpm = Math.round(60000 / interval);
|
|
31
|
+
bpmCounts.set(bpm, (bpmCounts.get(bpm) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const total = intervals.length;
|
|
35
|
+
const candidates: TempoCandidate[] = [];
|
|
36
|
+
for (const [bpm, count] of [...bpmCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
37
|
+
if (candidates.some((candidate) => isCloseBpm(candidate.bpm, bpm, 4))) continue;
|
|
38
|
+
candidates.push({ bpm, confidence: count / total });
|
|
39
|
+
if (candidates.length >= maxCandidates) break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
linkHalfAndDoubleTempoCandidates(candidates);
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isCloseBpm(a: number, b: number, toleranceBpm: number): boolean {
|
|
47
|
+
return Math.abs(a - b) <= toleranceBpm;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function linkHalfAndDoubleTempoCandidates(candidates: TempoCandidate[]): void {
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
const strongerRelated = candidates.find(
|
|
53
|
+
(other) =>
|
|
54
|
+
other !== candidate &&
|
|
55
|
+
other.confidence >= candidate.confidence &&
|
|
56
|
+
(isCloseBpm(other.bpm, candidate.bpm * 2, other.bpm * 0.05) ||
|
|
57
|
+
isCloseBpm(other.bpm, candidate.bpm / 2, other.bpm * 0.05)),
|
|
58
|
+
);
|
|
59
|
+
if (strongerRelated) candidate.isHalfOrDoubleOf = strongerRelated.bpm;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ChordQuality, chordPitchClasses, pitchClassDistance } from "@hum2music/music-theory";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scores voice-leading smoothness between two chords as the inverse of the
|
|
5
|
+
* average nearest-tone movement (in semitones) from the previous chord's
|
|
6
|
+
* tones to the current chord's tones (PRD §8.3's V term).
|
|
7
|
+
*/
|
|
8
|
+
export function voiceLeadingScore(
|
|
9
|
+
prevRoot: number,
|
|
10
|
+
prevQuality: ChordQuality,
|
|
11
|
+
curRoot: number,
|
|
12
|
+
curQuality: ChordQuality,
|
|
13
|
+
): number {
|
|
14
|
+
const prevTones = chordPitchClasses(prevRoot, prevQuality);
|
|
15
|
+
const curTones = chordPitchClasses(curRoot, curQuality);
|
|
16
|
+
|
|
17
|
+
const totalMovement = prevTones.reduce((sum, prevTone) => {
|
|
18
|
+
const nearestDistance = Math.min(...curTones.map((curTone) => Math.abs(pitchClassDistance(prevTone, curTone))));
|
|
19
|
+
return sum + nearestDistance;
|
|
20
|
+
}, 0);
|
|
21
|
+
|
|
22
|
+
const averageMovement = totalMovement / prevTones.length;
|
|
23
|
+
return 1 - averageMovement / 6;
|
|
24
|
+
}
|
package/README.md
DELETED