@hum2music/arrangement 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 +20 -4
- package/src/bass.ts +50 -0
- package/src/compose.ts +85 -0
- package/src/drums.ts +59 -0
- package/src/guitar.ts +100 -0
- package/src/index.ts +8 -0
- package/src/piano.ts +105 -0
- package/src/sectional-humanize.ts +59 -0
- package/src/style.ts +55 -0
- package/src/types.ts +11 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hum2music/arrangement",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rule-based Piano/Bass/Drums/Guitar arrangers and style profiles 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/arrangement"
|
|
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
|
+
"@hum2music/expression": "0.1.0",
|
|
20
|
+
"@hum2music/harmony": "0.1.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/bass.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent } from "@hum2music/core";
|
|
2
|
+
import { parseChordSymbol, pitchClassOf } from "@hum2music/music-theory";
|
|
3
|
+
import type { ArrangementContext, InstrumentArranger } from "./types";
|
|
4
|
+
|
|
5
|
+
const ROOT_OCTAVE_BASE_PITCH = 36; // the C in "C2", i.e. lowestPitchInOctave(0, ...) = 36
|
|
6
|
+
const VELOCITY = 75;
|
|
7
|
+
const APPROACH_VELOCITY = 65;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Root-driven bass: holds the chord root through beats 1-2, repeats it on
|
|
11
|
+
* beat 3, then on beat 4 plays a chromatic approach note into the next
|
|
12
|
+
* chord's root (or holds the root again on the final bar). Lands on the
|
|
13
|
+
* same strong beats (1 and 3) as the drum arranger's kick, approximating
|
|
14
|
+
* "kick sync" without reading the drum track directly (PRD §9.3).
|
|
15
|
+
*/
|
|
16
|
+
export function generateBassArrangement(chords: ChordEvent[]): NoteEvent[] {
|
|
17
|
+
const notes: NoteEvent[] = [];
|
|
18
|
+
|
|
19
|
+
chords.forEach((chord, index) => {
|
|
20
|
+
const parsed = parseChordSymbol(chord.symbol);
|
|
21
|
+
if (!parsed) return;
|
|
22
|
+
|
|
23
|
+
const quarter = chord.durationBeats / 4;
|
|
24
|
+
const rootPitch = lowestPitchInOctave(parsed.root);
|
|
25
|
+
|
|
26
|
+
notes.push(note(rootPitch, chord.startBeat, quarter * 2, VELOCITY));
|
|
27
|
+
notes.push(note(rootPitch, chord.startBeat + quarter * 2, quarter, VELOCITY));
|
|
28
|
+
|
|
29
|
+
const nextChord = chords[index + 1];
|
|
30
|
+
const nextParsed = nextChord ? parseChordSymbol(nextChord.symbol) : null;
|
|
31
|
+
const lastBeatPitch = nextParsed ? lowestPitchInOctave(nextParsed.root) - 1 : rootPitch;
|
|
32
|
+
const lastBeatVelocity = nextParsed ? APPROACH_VELOCITY : VELOCITY;
|
|
33
|
+
|
|
34
|
+
notes.push(note(lastBeatPitch, chord.startBeat + quarter * 3, quarter, lastBeatVelocity));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return notes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lowestPitchInOctave(pitchClass: number): number {
|
|
41
|
+
return ROOT_OCTAVE_BASE_PITCH + pitchClassOf(pitchClass);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function note(pitch: number, startBeat: number, durationBeats: number, velocity: number): NoteEvent {
|
|
45
|
+
return { pitch, startBeat, durationBeats, velocity };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const bassArranger: InstrumentArranger = {
|
|
49
|
+
generate: (context: ArrangementContext) => generateBassArrangement(context.chords),
|
|
50
|
+
};
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent, SongSection } from "@hum2music/core";
|
|
2
|
+
import { generateBassArrangement } from "./bass";
|
|
3
|
+
import { generateDrumArrangement } from "./drums";
|
|
4
|
+
import { generateGuitarArrangement } from "./guitar";
|
|
5
|
+
import { generatePianoArrangement } from "./piano";
|
|
6
|
+
import { applySectionalHumanization } from "./sectional-humanize";
|
|
7
|
+
import { resolveStyleProfile, type StyleProfile } from "./style";
|
|
8
|
+
|
|
9
|
+
export const ARRANGEABLE_INSTRUMENTS = ["piano", "bass", "drums", "guitar"] as const;
|
|
10
|
+
export type ArrangeableInstrument = (typeof ARRANGEABLE_INSTRUMENTS)[number];
|
|
11
|
+
|
|
12
|
+
export interface ComposedTrack {
|
|
13
|
+
instrument: "melody" | ArrangeableInstrument;
|
|
14
|
+
notes: NoteEvent[];
|
|
15
|
+
channel: number;
|
|
16
|
+
programNumber?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ComposeTracksOptions {
|
|
20
|
+
melody: NoteEvent[];
|
|
21
|
+
chords: ChordEvent[];
|
|
22
|
+
instruments?: Iterable<ArrangeableInstrument>;
|
|
23
|
+
humanizeAmount?: number;
|
|
24
|
+
seed?: number;
|
|
25
|
+
bpm: number;
|
|
26
|
+
/** Defaults to the "pop" profile; see style.ts. Governs pattern choice, voicing extensions, drum density, and GM program numbers. */
|
|
27
|
+
style?: StyleProfile | string;
|
|
28
|
+
/** When given (and non-empty), humanization intensity varies per bar by repeated section (see sectional-humanize.ts) instead of one flat amount. */
|
|
29
|
+
songSections?: SongSection[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_SEED = 42;
|
|
33
|
+
const CHANNELS: Record<ArrangeableInstrument, number> = { piano: 1, bass: 2, drums: 9, guitar: 3 };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the melody track plus any requested arranged instrument tracks
|
|
37
|
+
* (piano/bass/drums/guitar), optionally humanized per-track. Shared by the
|
|
38
|
+
* CLI's `generate` command and the web UI's export endpoints so
|
|
39
|
+
* arrangement + humanization logic lives in exactly one place.
|
|
40
|
+
*/
|
|
41
|
+
export function composeTracks(options: ComposeTracksOptions): ComposedTrack[] {
|
|
42
|
+
const style = resolveStyle(options.style);
|
|
43
|
+
const included = new Set(options.instruments ?? ARRANGEABLE_INSTRUMENTS);
|
|
44
|
+
|
|
45
|
+
const tracks: ComposedTrack[] = [{ instrument: "melody", notes: options.melody, channel: 0 }];
|
|
46
|
+
|
|
47
|
+
if (included.has("piano")) {
|
|
48
|
+
const notes = generatePianoArrangement(options.chords, {
|
|
49
|
+
pattern: style.pianoPattern,
|
|
50
|
+
allowExtensions: style.pianoAllowExtensions,
|
|
51
|
+
});
|
|
52
|
+
tracks.push({ instrument: "piano", notes, channel: CHANNELS.piano, programNumber: style.programNumbers.piano });
|
|
53
|
+
}
|
|
54
|
+
if (included.has("bass")) {
|
|
55
|
+
const notes = generateBassArrangement(options.chords);
|
|
56
|
+
tracks.push({ instrument: "bass", notes, channel: CHANNELS.bass, programNumber: style.programNumbers.bass });
|
|
57
|
+
}
|
|
58
|
+
if (included.has("drums")) {
|
|
59
|
+
const notes = generateDrumArrangement(options.chords, { density: style.drumDensity });
|
|
60
|
+
tracks.push({ instrument: "drums", notes, channel: CHANNELS.drums });
|
|
61
|
+
}
|
|
62
|
+
if (included.has("guitar")) {
|
|
63
|
+
const notes = generateGuitarArrangement(options.chords, { pattern: style.guitarPattern });
|
|
64
|
+
tracks.push({ instrument: "guitar", notes, channel: CHANNELS.guitar, programNumber: style.programNumbers.guitar });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const humanizeAmount = options.humanizeAmount ?? 0;
|
|
68
|
+
if (humanizeAmount <= 0) return tracks;
|
|
69
|
+
|
|
70
|
+
const seed = options.seed ?? DEFAULT_SEED;
|
|
71
|
+
const sections = options.songSections ?? [];
|
|
72
|
+
return tracks.map((track) => ({
|
|
73
|
+
...track,
|
|
74
|
+
notes: applySectionalHumanization(track.notes, sections, {
|
|
75
|
+
baseAmount: Math.min(1, humanizeAmount * style.humanizeMultiplier),
|
|
76
|
+
seed,
|
|
77
|
+
bpm: options.bpm,
|
|
78
|
+
instrument: track.instrument,
|
|
79
|
+
}),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveStyle(style: StyleProfile | string | undefined): StyleProfile {
|
|
84
|
+
return typeof style === "string" || style === undefined ? resolveStyleProfile(style) : style;
|
|
85
|
+
}
|
package/src/drums.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { ArrangementContext, InstrumentArranger } from "./types";
|
|
3
|
+
|
|
4
|
+
/** General MIDI percussion map note numbers. */
|
|
5
|
+
export const KICK_PITCH = 36;
|
|
6
|
+
export const SNARE_PITCH = 38;
|
|
7
|
+
export const CLOSED_HIHAT_PITCH = 42;
|
|
8
|
+
|
|
9
|
+
const HIT_DURATION_BEATS = 0.1;
|
|
10
|
+
const VELOCITY = { kick: 100, snare: 95, hihat: 70 };
|
|
11
|
+
|
|
12
|
+
export interface DrumArrangementOptions {
|
|
13
|
+
/** "sparse" drops to a half-time feel (kick on 1 only, snare on 3 only, quarter-note hi-hat) for e.g. a ballad. */
|
|
14
|
+
density?: "normal" | "sparse";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One bar of the basic 4/4 backbeat per bar (kick on 1 & 3, snare on 2 & 4,
|
|
19
|
+
* eighth-note closed hi-hat throughout), per PRD §10.3's example pattern.
|
|
20
|
+
* `density: "sparse"` is the one documented variation beyond this fixed
|
|
21
|
+
* template; further variation is left to future work.
|
|
22
|
+
*/
|
|
23
|
+
export function generateDrumArrangement(chords: ChordEvent[], options: DrumArrangementOptions = {}): NoteEvent[] {
|
|
24
|
+
const density = options.density ?? "normal";
|
|
25
|
+
const notes: NoteEvent[] = [];
|
|
26
|
+
|
|
27
|
+
for (const chord of chords) {
|
|
28
|
+
const quarter = chord.durationBeats / 4;
|
|
29
|
+
const eighth = quarter / 2;
|
|
30
|
+
|
|
31
|
+
if (density === "sparse") {
|
|
32
|
+
notes.push(hit(KICK_PITCH, chord.startBeat, VELOCITY.kick));
|
|
33
|
+
notes.push(hit(SNARE_PITCH, chord.startBeat + quarter * 2, VELOCITY.snare));
|
|
34
|
+
for (let quarterIndex = 0; quarterIndex < 4; quarterIndex++) {
|
|
35
|
+
notes.push(hit(CLOSED_HIHAT_PITCH, chord.startBeat + quarterIndex * quarter, VELOCITY.hihat));
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
notes.push(hit(KICK_PITCH, chord.startBeat, VELOCITY.kick));
|
|
41
|
+
notes.push(hit(KICK_PITCH, chord.startBeat + quarter * 2, VELOCITY.kick));
|
|
42
|
+
notes.push(hit(SNARE_PITCH, chord.startBeat + quarter, VELOCITY.snare));
|
|
43
|
+
notes.push(hit(SNARE_PITCH, chord.startBeat + quarter * 3, VELOCITY.snare));
|
|
44
|
+
|
|
45
|
+
for (let eighthIndex = 0; eighthIndex < 8; eighthIndex++) {
|
|
46
|
+
notes.push(hit(CLOSED_HIHAT_PITCH, chord.startBeat + eighthIndex * eighth, VELOCITY.hihat));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return notes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hit(pitch: number, startBeat: number, velocity: number): NoteEvent {
|
|
54
|
+
return { pitch, startBeat, durationBeats: HIT_DURATION_BEATS, velocity };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const drumsArranger: InstrumentArranger = {
|
|
58
|
+
generate: (context: ArrangementContext) => generateDrumArrangement(context.chords),
|
|
59
|
+
};
|
package/src/guitar.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent } from "@hum2music/core";
|
|
2
|
+
import { chordPitchClasses, parseChordSymbol, pitchClassOf } from "@hum2music/music-theory";
|
|
3
|
+
import type { ArrangementContext, InstrumentArranger } from "./types";
|
|
4
|
+
|
|
5
|
+
/** Standard tuning, low to high string (open-string MIDI pitches): E2 A2 D3 G3 B3 E4. */
|
|
6
|
+
const OPEN_STRING_PITCHES = [40, 45, 50, 55, 59, 64];
|
|
7
|
+
/** Max frets apart a single hand position can comfortably cover. */
|
|
8
|
+
const MAX_FRET_SPAN = 3;
|
|
9
|
+
/** How far up the neck to search for a playable voicing before giving up on a string. */
|
|
10
|
+
const MAX_FRET_SEARCH = 9;
|
|
11
|
+
|
|
12
|
+
const DOWN_STROKE_VELOCITY = 80;
|
|
13
|
+
const UP_STROKE_VELOCITY = 60;
|
|
14
|
+
const STROKE_DURATION_FRACTION = 0.9;
|
|
15
|
+
|
|
16
|
+
export interface GuitarArrangementOptions {
|
|
17
|
+
pattern?: "strum" | "arpeggio";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Finds a playable chord shape (one fret-window position, each string
|
|
22
|
+
* either muted or fretted within that window) and plays it as either a
|
|
23
|
+
* strummed down/up pattern or a simple arpeggio. Never generates a shape
|
|
24
|
+
* that would require a fret span wider than a hand can cover (PRD §9.4).
|
|
25
|
+
*/
|
|
26
|
+
export function generateGuitarArrangement(chords: ChordEvent[], options: GuitarArrangementOptions = {}): NoteEvent[] {
|
|
27
|
+
const pattern = options.pattern ?? "strum";
|
|
28
|
+
const notes: NoteEvent[] = [];
|
|
29
|
+
|
|
30
|
+
for (const chord of chords) {
|
|
31
|
+
const parsed = parseChordSymbol(chord.symbol);
|
|
32
|
+
if (!parsed) continue;
|
|
33
|
+
|
|
34
|
+
const chordTones = new Set(chordPitchClasses(parsed.root, parsed.quality));
|
|
35
|
+
const voicing = findPlayableVoicing(chordTones);
|
|
36
|
+
if (voicing.length === 0) continue;
|
|
37
|
+
|
|
38
|
+
const quarter = chord.durationBeats / 4;
|
|
39
|
+
if (pattern === "arpeggio") {
|
|
40
|
+
voicing.forEach((pitch, index) => {
|
|
41
|
+
const beatOffset = (index % 4) * quarter;
|
|
42
|
+
notes.push({
|
|
43
|
+
pitch,
|
|
44
|
+
startBeat: chord.startBeat + beatOffset,
|
|
45
|
+
durationBeats: quarter * STROKE_DURATION_FRACTION,
|
|
46
|
+
velocity: DOWN_STROKE_VELOCITY,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (let beatIndex = 0; beatIndex < 4; beatIndex++) {
|
|
53
|
+
const isDownStroke = beatIndex % 2 === 0;
|
|
54
|
+
const velocity = isDownStroke ? DOWN_STROKE_VELOCITY : UP_STROKE_VELOCITY;
|
|
55
|
+
for (const pitch of voicing) {
|
|
56
|
+
notes.push({
|
|
57
|
+
pitch,
|
|
58
|
+
startBeat: chord.startBeat + beatIndex * quarter,
|
|
59
|
+
durationBeats: quarter * STROKE_DURATION_FRACTION,
|
|
60
|
+
velocity,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return notes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Scans fret-window positions and returns the one that sounds the most strings. */
|
|
70
|
+
function findPlayableVoicing(chordTones: Set<number>): number[] {
|
|
71
|
+
let best: number[] = [];
|
|
72
|
+
|
|
73
|
+
for (let windowStart = 0; windowStart <= MAX_FRET_SEARCH; windowStart++) {
|
|
74
|
+
const pitches: number[] = [];
|
|
75
|
+
for (const openPitch of OPEN_STRING_PITCHES) {
|
|
76
|
+
const fret = findFretInWindow(openPitch, windowStart, chordTones);
|
|
77
|
+
if (fret !== null) pitches.push(openPitch + fret);
|
|
78
|
+
}
|
|
79
|
+
if (pitches.length > best.length) best = pitches;
|
|
80
|
+
if (best.length === OPEN_STRING_PITCHES.length) break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function findFretInWindow(openPitch: number, windowStart: number, chordTones: Set<number>): number | null {
|
|
87
|
+
const frets = windowStart === 0 ? [0, 1, 2, 3] : range(windowStart, windowStart + MAX_FRET_SPAN);
|
|
88
|
+
for (const fret of frets) {
|
|
89
|
+
if (chordTones.has(pitchClassOf(openPitch + fret))) return fret;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function range(start: number, end: number): number[] {
|
|
95
|
+
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const guitarArranger: InstrumentArranger = {
|
|
99
|
+
generate: (context: ArrangementContext) => generateGuitarArrangement(context.chords),
|
|
100
|
+
};
|
package/src/index.ts
ADDED
package/src/piano.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent } from "@hum2music/core";
|
|
2
|
+
import { chordPitchClasses, parseChordSymbol, pitchClassOf } from "@hum2music/music-theory";
|
|
3
|
+
import type { ArrangementContext, InstrumentArranger } from "./types";
|
|
4
|
+
|
|
5
|
+
const MIN_PITCH = 48; // C3
|
|
6
|
+
const MAX_PITCH = 72; // C5
|
|
7
|
+
const VELOCITY = 70;
|
|
8
|
+
const NINTH_INTERVAL_SEMITONES = 2;
|
|
9
|
+
|
|
10
|
+
export interface PianoArrangementOptions {
|
|
11
|
+
pattern?: "block" | "arpeggio";
|
|
12
|
+
/** Lets the voicing search also place the 9th, for a richer/"more advanced" sound (docs/specs/style-profiles.md). */
|
|
13
|
+
allowExtensions?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* For each bar, picks the voicing (octave placement of the chord's tones,
|
|
18
|
+
* optionally plus the 9th) within the piano's range with the smallest total
|
|
19
|
+
* movement from the previous bar's voicing, rather than always using the
|
|
20
|
+
* same inversion (PRD §9.2). Played either as a sustained block chord or a
|
|
21
|
+
* simple ascending arpeggio across the bar.
|
|
22
|
+
*/
|
|
23
|
+
export function generatePianoArrangement(chords: ChordEvent[], options: PianoArrangementOptions = {}): NoteEvent[] {
|
|
24
|
+
const pattern = options.pattern ?? "block";
|
|
25
|
+
const notes: NoteEvent[] = [];
|
|
26
|
+
let previousVoicing: number[] | null = null;
|
|
27
|
+
|
|
28
|
+
for (const chord of chords) {
|
|
29
|
+
const parsed = parseChordSymbol(chord.symbol);
|
|
30
|
+
if (!parsed) continue;
|
|
31
|
+
|
|
32
|
+
const toneClasses = chordPitchClasses(parsed.root, parsed.quality);
|
|
33
|
+
const availableTones = options.allowExtensions
|
|
34
|
+
? [...toneClasses, pitchClassOf(parsed.root + NINTH_INTERVAL_SEMITONES)]
|
|
35
|
+
: toneClasses;
|
|
36
|
+
|
|
37
|
+
const voicing = chooseVoicing(availableTones, previousVoicing);
|
|
38
|
+
previousVoicing = voicing;
|
|
39
|
+
|
|
40
|
+
if (pattern === "arpeggio") {
|
|
41
|
+
const quarter = chord.durationBeats / 4;
|
|
42
|
+
voicing.forEach((pitch, index) => {
|
|
43
|
+
notes.push({
|
|
44
|
+
pitch,
|
|
45
|
+
startBeat: chord.startBeat + (index % 4) * quarter,
|
|
46
|
+
durationBeats: quarter * 0.9,
|
|
47
|
+
velocity: VELOCITY,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const pitch of voicing) {
|
|
54
|
+
notes.push({ pitch, startBeat: chord.startBeat, durationBeats: chord.durationBeats, velocity: VELOCITY });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return notes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function chooseVoicing(toneClasses: number[], previousVoicing: number[] | null): number[] {
|
|
62
|
+
const candidates = enumerateVoicingsInRange(toneClasses);
|
|
63
|
+
if (candidates.length === 0) return toneClasses.map((pc) => pc + 60);
|
|
64
|
+
if (!previousVoicing) return candidates[0];
|
|
65
|
+
|
|
66
|
+
let best = candidates[0];
|
|
67
|
+
let bestMovement = totalVoiceMovement(previousVoicing, best);
|
|
68
|
+
for (const candidate of candidates.slice(1)) {
|
|
69
|
+
const movement = totalVoiceMovement(previousVoicing, candidate);
|
|
70
|
+
if (movement < bestMovement) {
|
|
71
|
+
best = candidate;
|
|
72
|
+
bestMovement = movement;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return best;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function enumerateVoicingsInRange(toneClasses: number[]): number[][] {
|
|
79
|
+
const optionsPerTone = toneClasses.map((pitchClass) => {
|
|
80
|
+
const options: number[] = [];
|
|
81
|
+
for (let pitch = MIN_PITCH; pitch <= MAX_PITCH; pitch++) {
|
|
82
|
+
if (pitchClassOf(pitch) === pitchClass) options.push(pitch);
|
|
83
|
+
}
|
|
84
|
+
return options;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
let combinations: number[][] = [[]];
|
|
88
|
+
for (const options of optionsPerTone) {
|
|
89
|
+
const next: number[][] = [];
|
|
90
|
+
for (const combo of combinations) {
|
|
91
|
+
for (const option of options) next.push([...combo, option]);
|
|
92
|
+
}
|
|
93
|
+
combinations = next;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return combinations.map((combo) => [...combo].sort((a, b) => a - b));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function totalVoiceMovement(previous: number[], candidate: number[]): number {
|
|
100
|
+
return candidate.reduce((sum, pitch) => sum + Math.min(...previous.map((p) => Math.abs(p - pitch))), 0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const pianoArranger: InstrumentArranger = {
|
|
104
|
+
generate: (context: ArrangementContext) => generatePianoArrangement(context.chords),
|
|
105
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { NoteEvent, SongSection } from "@hum2music/core";
|
|
2
|
+
import { deriveSectionalHumanizeAmount, humanizeMelody } from "@hum2music/expression";
|
|
3
|
+
|
|
4
|
+
const BEATS_PER_BAR = 4;
|
|
5
|
+
|
|
6
|
+
export interface SectionalHumanizeOptions {
|
|
7
|
+
baseAmount: number;
|
|
8
|
+
seed: number;
|
|
9
|
+
bpm: number;
|
|
10
|
+
instrument: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Humanizes a track bar-by-bar, varying intensity by song section (via
|
|
15
|
+
* deriveSectionalHumanizeAmount) instead of applying one flat amount to
|
|
16
|
+
* the whole track. Note: each bar re-seeds its own random stream from the
|
|
17
|
+
* same track sub-seed, so notes at the same position-within-bar across
|
|
18
|
+
* different bars can draw correlated (not fully independent) jitter —
|
|
19
|
+
* still fully deterministic/reproducible, just less varied than a single
|
|
20
|
+
* continuous stream would be. Documented tradeoff, not a bug.
|
|
21
|
+
*/
|
|
22
|
+
export function applySectionalHumanization(
|
|
23
|
+
notes: NoteEvent[],
|
|
24
|
+
sections: SongSection[],
|
|
25
|
+
options: SectionalHumanizeOptions,
|
|
26
|
+
): NoteEvent[] {
|
|
27
|
+
if (options.baseAmount <= 0) return notes;
|
|
28
|
+
if (sections.length === 0) {
|
|
29
|
+
return humanizeMelody(notes, {
|
|
30
|
+
seed: options.seed,
|
|
31
|
+
humanizeAmount: options.baseAmount,
|
|
32
|
+
bpm: options.bpm,
|
|
33
|
+
instrument: options.instrument,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const notesByBar = new Map<number, NoteEvent[]>();
|
|
38
|
+
for (const note of notes) {
|
|
39
|
+
const barIndex = Math.floor(note.startBeat / BEATS_PER_BAR);
|
|
40
|
+
const bucket = notesByBar.get(barIndex) ?? [];
|
|
41
|
+
bucket.push(note);
|
|
42
|
+
notesByBar.set(barIndex, bucket);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const result: NoteEvent[] = [];
|
|
46
|
+
for (const [barIndex, barNotes] of notesByBar) {
|
|
47
|
+
const amount = deriveSectionalHumanizeAmount(options.baseAmount, sections, barIndex);
|
|
48
|
+
result.push(
|
|
49
|
+
...humanizeMelody(barNotes, {
|
|
50
|
+
seed: options.seed,
|
|
51
|
+
humanizeAmount: amount,
|
|
52
|
+
bpm: options.bpm,
|
|
53
|
+
instrument: options.instrument,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
package/src/style.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ProgressionSearchWeights } from "@hum2music/harmony";
|
|
2
|
+
|
|
3
|
+
export type StyleName = "pop" | "ballad";
|
|
4
|
+
export const STYLE_NAMES: StyleName[] = ["pop", "ballad"];
|
|
5
|
+
|
|
6
|
+
export interface StyleProfile {
|
|
7
|
+
name: StyleName;
|
|
8
|
+
/** Passed through to searchChordProgression; style-dependent weighting stands in for the S term (PRD §8.3). */
|
|
9
|
+
harmonyWeights: ProgressionSearchWeights;
|
|
10
|
+
pianoPattern: "block" | "arpeggio";
|
|
11
|
+
/** Whether the piano voicing may add the 9th as an extra available tone, beyond the chord's own tones. */
|
|
12
|
+
pianoAllowExtensions: boolean;
|
|
13
|
+
guitarPattern: "strum" | "arpeggio";
|
|
14
|
+
drumDensity: "normal" | "sparse";
|
|
15
|
+
humanizeMultiplier: number;
|
|
16
|
+
programNumbers: { piano: number; bass: number; guitar: number };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A style is a weighting/parameter profile layered on top of the existing
|
|
21
|
+
* engines, not a new generation algorithm, to keep everything deterministic
|
|
22
|
+
* (PRD §15.1) and the engines swappable (docs/specs/style-profiles.md).
|
|
23
|
+
* Proposed values below are unvalidated starting points.
|
|
24
|
+
*/
|
|
25
|
+
export const STYLE_PROFILES: Record<StyleName, StyleProfile> = {
|
|
26
|
+
pop: {
|
|
27
|
+
name: "pop",
|
|
28
|
+
harmonyWeights: { melodyFit: 0.5, harmonicNaturalness: 0.3, voiceLeading: 0.2 },
|
|
29
|
+
pianoPattern: "block",
|
|
30
|
+
pianoAllowExtensions: false,
|
|
31
|
+
guitarPattern: "strum",
|
|
32
|
+
drumDensity: "normal",
|
|
33
|
+
humanizeMultiplier: 1,
|
|
34
|
+
programNumbers: { piano: 0, bass: 33, guitar: 29 },
|
|
35
|
+
},
|
|
36
|
+
ballad: {
|
|
37
|
+
name: "ballad",
|
|
38
|
+
harmonyWeights: { melodyFit: 0.55, harmonicNaturalness: 0.25, voiceLeading: 0.2 },
|
|
39
|
+
pianoPattern: "arpeggio",
|
|
40
|
+
pianoAllowExtensions: true,
|
|
41
|
+
guitarPattern: "arpeggio",
|
|
42
|
+
drumDensity: "sparse",
|
|
43
|
+
humanizeMultiplier: 0.7,
|
|
44
|
+
programNumbers: { piano: 0, bass: 32, guitar: 24 },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function resolveStyleProfile(name: string | undefined): StyleProfile {
|
|
49
|
+
if (name && isStyleName(name)) return STYLE_PROFILES[name];
|
|
50
|
+
return STYLE_PROFILES.pop;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isStyleName(name: string): name is StyleName {
|
|
54
|
+
return (STYLE_NAMES as string[]).includes(name);
|
|
55
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChordEvent, NoteEvent } from "@hum2music/core";
|
|
2
|
+
|
|
3
|
+
/** Everything an arranger needs: a chord progression (one chord per bar, per harmony's grouping). */
|
|
4
|
+
export interface ArrangementContext {
|
|
5
|
+
chords: ChordEvent[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Common interface so each instrument's arranger is independently swappable (PRD §9.1). */
|
|
9
|
+
export interface InstrumentArranger {
|
|
10
|
+
generate(context: ArrangementContext): NoteEvent[];
|
|
11
|
+
}
|
package/README.md
DELETED