@hum2music/rendering 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 CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@hum2music/rendering",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Minimal deterministic software synth used for Hum2Music's WAV preview export.",
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/rendering"
11
+ },
12
+ "type": "module",
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "files": ["src"],
16
+ "dependencies": {
17
+ "@hum2music/expression": "0.1.0"
18
+ }
19
+ }
@@ -0,0 +1,16 @@
1
+ import type { DrumType } from "./types";
2
+
3
+ /** General MIDI percussion note numbers our drum arranger emits (PRD §10.2). */
4
+ const DRUM_TYPE_BY_PITCH: Record<number, DrumType> = {
5
+ 35: "kick",
6
+ 36: "kick",
7
+ 38: "snare",
8
+ 40: "snare",
9
+ 42: "hihat",
10
+ 44: "hihat",
11
+ 46: "hihat",
12
+ };
13
+
14
+ export function classifyDrumNote(pitch: number): DrumType {
15
+ return DRUM_TYPE_BY_PITCH[pitch] ?? "hihat";
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types";
2
+ export * from "./classify-drum-note";
3
+ export * from "./synth";
package/src/synth.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { createSeededRandom } from "@hum2music/expression";
2
+ import type { DrumRenderNote, DrumType, PitchedRenderNote, Timbre } from "./types";
3
+
4
+ export interface RenderOptions {
5
+ sampleRate?: number;
6
+ /** Seeds the noise generator used for drum synthesis, for reproducibility (PRD §15.1). */
7
+ seed?: number;
8
+ }
9
+
10
+ const DEFAULT_SAMPLE_RATE = 44100;
11
+ const DEFAULT_SEED = 42;
12
+ const TAIL_MS = 300;
13
+
14
+ interface HarmonicPartial {
15
+ multiplier: number;
16
+ amplitude: number;
17
+ }
18
+
19
+ interface TimbreProfile {
20
+ partials: HarmonicPartial[];
21
+ /** Exponential amplitude decay over the note's duration; 0 = sustained (no extra decay beyond the envelope). */
22
+ decayRatePerSec: number;
23
+ }
24
+
25
+ /**
26
+ * Rough, additive-synthesis stand-ins for each instrument's character —
27
+ * not sampled/physically modeled, just enough harmonic content and decay
28
+ * shape to tell them apart by ear (melody: near-pure tone; piano/guitar:
29
+ * brighter with a plucky decay; bass: a touch of overtone, mostly sustained).
30
+ */
31
+ const TIMBRE_PROFILES: Record<Timbre, TimbreProfile> = {
32
+ melody: { partials: [{ multiplier: 1, amplitude: 1 }, { multiplier: 2, amplitude: 0.15 }], decayRatePerSec: 0 },
33
+ piano: {
34
+ partials: [
35
+ { multiplier: 1, amplitude: 1 },
36
+ { multiplier: 2, amplitude: 0.5 },
37
+ { multiplier: 3, amplitude: 0.25 },
38
+ { multiplier: 4, amplitude: 0.12 },
39
+ ],
40
+ decayRatePerSec: 2.5,
41
+ },
42
+ bass: { partials: [{ multiplier: 1, amplitude: 1 }, { multiplier: 2, amplitude: 0.3 }], decayRatePerSec: 0.8 },
43
+ guitar: {
44
+ partials: [{ multiplier: 1, amplitude: 1 }, { multiplier: 2, amplitude: 0.4 }, { multiplier: 3, amplitude: 0.2 }],
45
+ decayRatePerSec: 3,
46
+ },
47
+ };
48
+
49
+ /**
50
+ * Minimal software synth: additive-harmonic oscillators (with a short
51
+ * attack/release envelope and a per-timbre decay) for pitched notes, and
52
+ * simple decaying tone/noise bursts for drum hits. Not meant to sound
53
+ * "produced" — just an audible, deterministic, instrument-distinguishable
54
+ * preview, per PRD §13.3's explicit MVP bar on audio quality.
55
+ */
56
+ export function renderToSamples(
57
+ pitchedNotes: PitchedRenderNote[],
58
+ drumNotes: DrumRenderNote[],
59
+ options: RenderOptions = {},
60
+ ): Float32Array {
61
+ const sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
62
+ const random = createSeededRandom(options.seed ?? DEFAULT_SEED);
63
+
64
+ const endMs =
65
+ Math.max(
66
+ 0,
67
+ ...pitchedNotes.map((note) => note.startMs + note.durationMs),
68
+ ...drumNotes.map((note) => note.startMs + note.durationMs),
69
+ ) + TAIL_MS;
70
+ const output = new Float32Array(Math.ceil((endMs / 1000) * sampleRate));
71
+
72
+ for (const note of pitchedNotes) renderPitchedNote(output, note, sampleRate);
73
+ for (const note of drumNotes) renderDrumNote(output, note, sampleRate, random);
74
+
75
+ normalizeInPlace(output);
76
+ return output;
77
+ }
78
+
79
+ function renderPitchedNote(output: Float32Array, note: PitchedRenderNote, sampleRate: number): void {
80
+ const profile = TIMBRE_PROFILES[note.timbre ?? "melody"];
81
+ const partialAmplitudeSum = profile.partials.reduce((sum, partial) => sum + partial.amplitude, 0);
82
+
83
+ const startSample = Math.max(0, Math.round((note.startMs / 1000) * sampleRate));
84
+ const durationSamples = Math.max(1, Math.round((note.durationMs / 1000) * sampleRate));
85
+ const attackSamples = Math.min(durationSamples, Math.round(sampleRate * 0.01));
86
+ const releaseSamples = Math.min(durationSamples, Math.round(sampleRate * 0.05));
87
+ const baseAmplitude = (note.velocity / 127) * 0.25;
88
+
89
+ for (let i = 0; i < durationSamples; i++) {
90
+ const index = startSample + i;
91
+ if (index >= output.length) break;
92
+
93
+ const envelope = envelopeAt(i, durationSamples, attackSamples, releaseSamples);
94
+ const time = i / sampleRate;
95
+ const decay = profile.decayRatePerSec > 0 ? Math.exp(-time * profile.decayRatePerSec) : 1;
96
+
97
+ let partialSum = 0;
98
+ for (const partial of profile.partials) {
99
+ partialSum += Math.sin(2 * Math.PI * note.frequencyHz * partial.multiplier * time) * partial.amplitude;
100
+ }
101
+
102
+ output[index] += (partialSum / partialAmplitudeSum) * baseAmplitude * envelope * decay;
103
+ }
104
+ }
105
+
106
+ function envelopeAt(i: number, duration: number, attack: number, release: number): number {
107
+ if (i < attack) return i / attack;
108
+ if (i > duration - release) return Math.max(0, (duration - i) / release);
109
+ return 1;
110
+ }
111
+
112
+ const DRUM_PROFILES: Record<DrumType, { durationSec: number; decayRate: number; amplitude: number }> = {
113
+ kick: { durationSec: 0.15, decayRate: 20, amplitude: 0.5 },
114
+ snare: { durationSec: 0.15, decayRate: 15, amplitude: 0.4 },
115
+ hihat: { durationSec: 0.05, decayRate: 60, amplitude: 0.2 },
116
+ };
117
+
118
+ function renderDrumNote(output: Float32Array, note: DrumRenderNote, sampleRate: number, random: () => number): void {
119
+ const profile = DRUM_PROFILES[note.drumType];
120
+ const startSample = Math.max(0, Math.round((note.startMs / 1000) * sampleRate));
121
+ const durationSamples = Math.round(profile.durationSec * sampleRate);
122
+ const amplitude = (note.velocity / 127) * profile.amplitude;
123
+
124
+ for (let i = 0; i < durationSamples; i++) {
125
+ const index = startSample + i;
126
+ if (index >= output.length) break;
127
+ const time = i / sampleRate;
128
+ const decay = Math.exp(-time * profile.decayRate);
129
+ const signal =
130
+ note.drumType === "kick"
131
+ ? Math.sin(2 * Math.PI * (120 * Math.pow(0.08, time / profile.durationSec)) * time)
132
+ : random() * 2 - 1;
133
+
134
+ output[index] += signal * amplitude * decay;
135
+ }
136
+ }
137
+
138
+ function normalizeInPlace(samples: Float32Array): void {
139
+ let peak = 0;
140
+ for (const sample of samples) peak = Math.max(peak, Math.abs(sample));
141
+ if (peak <= 1) return;
142
+
143
+ const scale = 0.98 / peak;
144
+ for (let i = 0; i < samples.length; i++) samples[i] *= scale;
145
+ }
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ export type DrumType = "kick" | "snare" | "hihat";
2
+ export type Timbre = "melody" | "piano" | "bass" | "guitar";
3
+
4
+ export interface PitchedRenderNote {
5
+ startMs: number;
6
+ durationMs: number;
7
+ velocity: number;
8
+ frequencyHz: number;
9
+ /** Defaults to "melody" (a near-pure tone) if omitted. */
10
+ timbre?: Timbre;
11
+ }
12
+
13
+ export interface DrumRenderNote {
14
+ startMs: number;
15
+ durationMs: number;
16
+ velocity: number;
17
+ drumType: DrumType;
18
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.