@hum2music/expression 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 +17 -4
- package/src/drum-humanization.ts +31 -0
- package/src/dynamics.ts +53 -0
- package/src/groove.ts +23 -0
- package/src/humanize-params.ts +32 -0
- package/src/humanize.ts +35 -0
- package/src/index.ts +8 -0
- package/src/pitch.ts +28 -0
- package/src/rng.ts +26 -0
- package/src/timing.ts +49 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hum2music/expression",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic, seeded Humanization/expression engine for Hum2Music (swing, timing jitter, velocity, vibrato).",
|
|
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/expression"
|
|
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
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { InstrumentHumanizeParams } from "./humanize-params";
|
|
2
|
+
|
|
3
|
+
/** A realized set of humanization values for a single drum hit (PRD §10.5). */
|
|
4
|
+
export interface DrumHumanization {
|
|
5
|
+
timingOffsetMs: number;
|
|
6
|
+
velocityOffset: number;
|
|
7
|
+
swingAmount: number;
|
|
8
|
+
ghostNoteProbability: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Computes one drum hit's humanization. Not yet wired into a drum
|
|
13
|
+
* arranger (that's future arrangement work), but implemented and tested
|
|
14
|
+
* standalone now. `beatPositionInBar` damps the random range on downbeats,
|
|
15
|
+
* same rule-dependent approach as melody timing/velocity humanization.
|
|
16
|
+
*/
|
|
17
|
+
export function computeDrumHumanization(
|
|
18
|
+
params: InstrumentHumanizeParams,
|
|
19
|
+
beatPositionInBar: number,
|
|
20
|
+
random: () => number,
|
|
21
|
+
): DrumHumanization {
|
|
22
|
+
const isDownbeat = Math.abs(beatPositionInBar % 4) < 1e-6;
|
|
23
|
+
const positionalScale = isDownbeat ? 0.3 : 1;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
timingOffsetMs: (random() * 2 - 1) * params.timingJitterMs * positionalScale,
|
|
27
|
+
velocityOffset: (random() * 2 - 1) * params.velocityJitterAmount * positionalScale,
|
|
28
|
+
swingAmount: params.swingAmount,
|
|
29
|
+
ghostNoteProbability: params.ghostNoteProbability,
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/dynamics.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { InstrumentHumanizeParams } from "./humanize-params";
|
|
3
|
+
|
|
4
|
+
const BEATS_PER_BAR = 4;
|
|
5
|
+
const POSITION_EPSILON_BEATS = 1e-6;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accents downbeats (start of each bar) and adds a smaller, seeded
|
|
9
|
+
* velocity variation elsewhere — the accent is a deterministic rule on
|
|
10
|
+
* metrical position, with randomness only filling in the remaining
|
|
11
|
+
* natural variation, damped on strong beats (PRD §11.2 Velocity Accent).
|
|
12
|
+
*/
|
|
13
|
+
export function applyVelocityHumanization(
|
|
14
|
+
notes: NoteEvent[],
|
|
15
|
+
params: InstrumentHumanizeParams,
|
|
16
|
+
random: () => number,
|
|
17
|
+
): NoteEvent[] {
|
|
18
|
+
if (params.velocityJitterAmount <= 0) return notes;
|
|
19
|
+
|
|
20
|
+
return notes.map((note) => {
|
|
21
|
+
const positionInBar = note.startBeat % BEATS_PER_BAR;
|
|
22
|
+
const isDownbeat = Math.abs(positionInBar) < POSITION_EPSILON_BEATS;
|
|
23
|
+
const accent = isDownbeat ? params.velocityJitterAmount : 0;
|
|
24
|
+
const jitterScale = isDownbeat ? 0.3 : 1;
|
|
25
|
+
const jitter = (random() * 2 - 1) * params.velocityJitterAmount * 0.5 * jitterScale;
|
|
26
|
+
|
|
27
|
+
return { ...note, velocity: clampVelocity(note.velocity + accent + jitter) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Applies a single swell (crescendo into decrescendo) across the given
|
|
33
|
+
* notes, as a simplified stand-in for phrase-level dynamics until
|
|
34
|
+
* phrase-boundary detection exists.
|
|
35
|
+
*/
|
|
36
|
+
export function applyDynamicArc(notes: NoteEvent[], amount: number): NoteEvent[] {
|
|
37
|
+
if (notes.length < 2 || amount <= 0) return notes;
|
|
38
|
+
|
|
39
|
+
const firstBeat = notes[0].startBeat;
|
|
40
|
+
const lastBeat = notes[notes.length - 1].startBeat;
|
|
41
|
+
const span = lastBeat - firstBeat || 1;
|
|
42
|
+
const maxArcVelocity = 10 * amount;
|
|
43
|
+
|
|
44
|
+
return notes.map((note) => {
|
|
45
|
+
const progress = (note.startBeat - firstBeat) / span;
|
|
46
|
+
const arcOffset = Math.sin(progress * Math.PI) * maxArcVelocity;
|
|
47
|
+
return { ...note, velocity: clampVelocity(note.velocity + arcOffset) };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clampVelocity(velocity: number): number {
|
|
52
|
+
return Math.min(127, Math.max(1, Math.round(velocity)));
|
|
53
|
+
}
|
package/src/groove.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SongSection } from "@hum2music/core";
|
|
2
|
+
|
|
3
|
+
const REPEAT_INTENSITY_BOOST = 0.15;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Looks up which song section a bar falls in and, if that section's label
|
|
7
|
+
* has occurred earlier (a repeated cycle — e.g. the second time through a
|
|
8
|
+
* chorus), nudges the humanize intensity up a little rather than leaving
|
|
9
|
+
* every repeat identically flat. A simple, rule-based "advanced groove"
|
|
10
|
+
* variation driven by song structure rather than randomness.
|
|
11
|
+
*/
|
|
12
|
+
export function deriveSectionalHumanizeAmount(baseAmount: number, sections: SongSection[], barIndex: number): number {
|
|
13
|
+
const sectionIndex = sections.findIndex(
|
|
14
|
+
(section) => barIndex >= section.startBarIndex && barIndex < section.startBarIndex + section.barCount,
|
|
15
|
+
);
|
|
16
|
+
if (sectionIndex === -1) return baseAmount;
|
|
17
|
+
|
|
18
|
+
const label = sections[sectionIndex].label;
|
|
19
|
+
const isRepeat = sections.slice(0, sectionIndex).some((section) => section.label === label);
|
|
20
|
+
if (!isRepeat) return baseAmount;
|
|
21
|
+
|
|
22
|
+
return Math.min(1, baseAmount + REPEAT_INTENSITY_BOOST);
|
|
23
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface InstrumentHumanizeParams {
|
|
2
|
+
timingJitterMs: number;
|
|
3
|
+
velocityJitterAmount: number;
|
|
4
|
+
swingAmount: number;
|
|
5
|
+
ghostNoteProbability: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Baseline (fully-humanized, `--humanize 1`) parameters per instrument.
|
|
10
|
+
* A single `--humanize` value maps to different concrete ranges per
|
|
11
|
+
* instrument rather than one shared random multiplier (PRD §11.3) —
|
|
12
|
+
* e.g. drums tolerate more timing/velocity variation than a sustained
|
|
13
|
+
* melody. Proposed values, to be tuned against real recordings.
|
|
14
|
+
*/
|
|
15
|
+
const INSTRUMENT_BASELINES: Record<string, InstrumentHumanizeParams> = {
|
|
16
|
+
melody: { timingJitterMs: 8, velocityJitterAmount: 6, swingAmount: 0.1, ghostNoteProbability: 0 },
|
|
17
|
+
piano: { timingJitterMs: 10, velocityJitterAmount: 10, swingAmount: 0.1, ghostNoteProbability: 0.05 },
|
|
18
|
+
bass: { timingJitterMs: 6, velocityJitterAmount: 8, swingAmount: 0.1, ghostNoteProbability: 0 },
|
|
19
|
+
drums: { timingJitterMs: 15, velocityJitterAmount: 12, swingAmount: 0.2, ghostNoteProbability: 0.15 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function deriveHumanizeParams(humanizeAmount: number, instrument: string): InstrumentHumanizeParams {
|
|
23
|
+
const baseline = INSTRUMENT_BASELINES[instrument] ?? INSTRUMENT_BASELINES.melody;
|
|
24
|
+
const amount = Math.min(1, Math.max(0, humanizeAmount));
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
timingJitterMs: baseline.timingJitterMs * amount,
|
|
28
|
+
velocityJitterAmount: baseline.velocityJitterAmount * amount,
|
|
29
|
+
swingAmount: baseline.swingAmount * amount,
|
|
30
|
+
ghostNoteProbability: baseline.ghostNoteProbability * amount,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/humanize.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import { applyDynamicArc, applyVelocityHumanization } from "./dynamics";
|
|
3
|
+
import { deriveHumanizeParams } from "./humanize-params";
|
|
4
|
+
import { applyVibrato } from "./pitch";
|
|
5
|
+
import { createSeededRandom, deriveSeed } from "./rng";
|
|
6
|
+
import { applySwing, applyTimingJitter } from "./timing";
|
|
7
|
+
|
|
8
|
+
export interface HumanizeOptions {
|
|
9
|
+
seed: number;
|
|
10
|
+
humanizeAmount: number;
|
|
11
|
+
bpm: number;
|
|
12
|
+
instrument?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VIBRATO_DEPTH_CENTS_AT_FULL_HUMANIZE = 20;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Applies the full expression/humanization pipeline to a quantized melody:
|
|
19
|
+
* swing, seeded timing/velocity variation, a phrase-level dynamic arc, and
|
|
20
|
+
* vibrato on sustained notes. Each layer is independently seeded from the
|
|
21
|
+
* same top-level seed so results are fully reproducible (PRD §15.1).
|
|
22
|
+
*/
|
|
23
|
+
export function humanizeMelody(notes: NoteEvent[], options: HumanizeOptions): NoteEvent[] {
|
|
24
|
+
const instrument = options.instrument ?? "melody";
|
|
25
|
+
const params = deriveHumanizeParams(options.humanizeAmount, instrument);
|
|
26
|
+
const random = createSeededRandom(deriveSeed(options.seed, instrument));
|
|
27
|
+
|
|
28
|
+
let result = notes;
|
|
29
|
+
result = applySwing(result, params.swingAmount);
|
|
30
|
+
result = applyTimingJitter(result, params, random);
|
|
31
|
+
result = applyVelocityHumanization(result, params, random);
|
|
32
|
+
result = applyDynamicArc(result, options.humanizeAmount);
|
|
33
|
+
result = applyVibrato(result, VIBRATO_DEPTH_CENTS_AT_FULL_HUMANIZE * options.humanizeAmount, options.bpm);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
package/src/index.ts
ADDED
package/src/pitch.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NoteEvent, PitchCurveSample } from "@hum2music/core";
|
|
2
|
+
|
|
3
|
+
const MIN_DURATION_BEATS_FOR_VIBRATO = 1;
|
|
4
|
+
const VIBRATO_RATE_HZ = 5.5;
|
|
5
|
+
const SAMPLE_INTERVAL_MS = 20;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Synthesizes vibrato as a sampled pitch-deviation curve (cents over time)
|
|
9
|
+
* on sustained notes, for MIDI export to turn into pitch bend. Short notes
|
|
10
|
+
* are left alone — vibrato needs enough duration to be perceptible.
|
|
11
|
+
*/
|
|
12
|
+
export function applyVibrato(notes: NoteEvent[], depthCents: number, bpm: number): NoteEvent[] {
|
|
13
|
+
if (depthCents <= 0) return notes;
|
|
14
|
+
const msPerBeat = 60000 / bpm;
|
|
15
|
+
|
|
16
|
+
return notes.map((note) => {
|
|
17
|
+
if (note.durationBeats < MIN_DURATION_BEATS_FOR_VIBRATO) return note;
|
|
18
|
+
|
|
19
|
+
const durationMs = note.durationBeats * msPerBeat;
|
|
20
|
+
const pitchCurve: PitchCurveSample[] = [];
|
|
21
|
+
for (let offsetMs = 0; offsetMs <= durationMs; offsetMs += SAMPLE_INTERVAL_MS) {
|
|
22
|
+
const phase = (2 * Math.PI * VIBRATO_RATE_HZ * offsetMs) / 1000;
|
|
23
|
+
pitchCurve.push({ offsetMs, cents: Math.sin(phase) * depthCents });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { ...note, expression: { ...note.expression, pitchCurve } };
|
|
27
|
+
});
|
|
28
|
+
}
|
package/src/rng.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Deterministic PRNG (mulberry32): same seed always produces the same sequence. */
|
|
2
|
+
export function createSeededRandom(seed: number): () => number {
|
|
3
|
+
let state = seed >>> 0;
|
|
4
|
+
return function random(): number {
|
|
5
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
6
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
7
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
8
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Derives a sub-seed from a top-level seed and any number of identifying
|
|
14
|
+
* parts (e.g. track name, bar index), so different tracks/bars get
|
|
15
|
+
* independent-looking but still fully reproducible randomness from one
|
|
16
|
+
* `--seed` value.
|
|
17
|
+
*/
|
|
18
|
+
export function deriveSeed(seed: number, ...parts: Array<string | number>): number {
|
|
19
|
+
let hash = seed >>> 0;
|
|
20
|
+
const text = parts.join("|");
|
|
21
|
+
for (let i = 0; i < text.length; i++) {
|
|
22
|
+
hash ^= text.charCodeAt(i);
|
|
23
|
+
hash = Math.imul(hash, 16777619);
|
|
24
|
+
}
|
|
25
|
+
return hash >>> 0;
|
|
26
|
+
}
|
package/src/timing.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { InstrumentHumanizeParams } from "./humanize-params";
|
|
3
|
+
|
|
4
|
+
const EIGHTH_NOTE_BEATS = 0.5;
|
|
5
|
+
/** At full swingAmount, an upbeat eighth note is delayed by half an eighth note (a sixteenth). */
|
|
6
|
+
const SWING_DELAY_FRACTION_OF_EIGHTH = 0.5;
|
|
7
|
+
const POSITION_EPSILON_BEATS = 1e-6;
|
|
8
|
+
|
|
9
|
+
/** Delays upbeat eighth notes to create a swing feel; on-the-beat notes are untouched. */
|
|
10
|
+
export function applySwing(notes: NoteEvent[], swingAmount: number): NoteEvent[] {
|
|
11
|
+
if (swingAmount <= 0) return notes;
|
|
12
|
+
|
|
13
|
+
return notes.map((note) => {
|
|
14
|
+
const positionInBeat = note.startBeat % 1;
|
|
15
|
+
const isUpbeatEighth = Math.abs(positionInBeat - 0.5) < POSITION_EPSILON_BEATS;
|
|
16
|
+
if (!isUpbeatEighth) return note;
|
|
17
|
+
|
|
18
|
+
const delayBeats = swingAmount * EIGHTH_NOTE_BEATS * SWING_DELAY_FRACTION_OF_EIGHTH;
|
|
19
|
+
return { ...note, startBeat: note.startBeat + delayBeats };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Adds small, seeded timing deviations, layered on top of any existing
|
|
25
|
+
* `timingOffsetMs` (e.g. the original humming's micro-timing preserved
|
|
26
|
+
* from quantization) rather than overwriting it. Deviations are scaled
|
|
27
|
+
* down on strong metrical positions (on the beat) versus weak ones (off
|
|
28
|
+
* the beat) — the jitter's *range* is rule-based, not an unconditional
|
|
29
|
+
* flat random shift applied everywhere (PRD §10.5, §11.3).
|
|
30
|
+
*/
|
|
31
|
+
export function applyTimingJitter(
|
|
32
|
+
notes: NoteEvent[],
|
|
33
|
+
params: InstrumentHumanizeParams,
|
|
34
|
+
random: () => number,
|
|
35
|
+
): NoteEvent[] {
|
|
36
|
+
if (params.timingJitterMs <= 0) return notes;
|
|
37
|
+
|
|
38
|
+
return notes.map((note) => {
|
|
39
|
+
const isOnBeat = Math.abs(note.startBeat % 1) < POSITION_EPSILON_BEATS;
|
|
40
|
+
const positionalScale = isOnBeat ? 0.4 : 1;
|
|
41
|
+
const jitterMs = (random() * 2 - 1) * params.timingJitterMs * positionalScale;
|
|
42
|
+
const existingOffsetMs = note.expression?.timingOffsetMs ?? 0;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...note,
|
|
46
|
+
expression: { ...note.expression, timingOffsetMs: existingOffsetMs + jitterMs },
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
package/README.md
DELETED