hum2music 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/README.md CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # hum2music
2
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.
3
+ Turn a hummed melody into a multi-track MIDI/WAV song — no generative AI, a
4
+ local, rule-based engine built on signal processing and music theory.
5
+
6
+ Full docs, architecture, and source: https://github.com/hum2music/hum2music
7
+
8
+ ## Requirements
9
+
10
+ This CLI runs on [Bun](https://bun.sh) (the published entry point is a `.ts`
11
+ file executed directly by Bun, not pre-compiled JavaScript).
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ bunx hum2music transcribe input.wav -o melody.mid
17
+ bunx hum2music analyze input.wav --chords
18
+ bunx hum2music generate input.wav -o song.mid --wav song.wav
19
+ bunx hum2music serve --port 3000
20
+ ```
21
+
22
+ ## License
23
+
24
+ Apache License 2.0
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "hum2music",
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": "Turn a hummed melody into a multi-track MIDI/WAV song — no generative AI, a local, rule-based CLI built on music theory and signal processing.",
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": "apps/cli"
11
+ },
12
+ "keywords": [
13
+ "midi",
14
+ "music",
15
+ "cli",
16
+ "humming",
17
+ "melody-transcription",
18
+ "music-theory",
19
+ "composition"
20
+ ],
21
+ "type": "module",
22
+ "bin": {
23
+ "hum2music": "./src/index.ts"
24
+ },
25
+ "files": ["src", "README.md"],
26
+ "dependencies": {
27
+ "@hum2music/audio-analysis": "0.1.0",
28
+ "@hum2music/midi": "0.1.0",
29
+ "@hum2music/harmony": "0.1.0",
30
+ "@hum2music/expression": "0.1.0",
31
+ "@hum2music/web": "0.1.0",
32
+ "@hum2music/arrangement": "0.1.0",
33
+ "@hum2music/rendering": "0.1.0"
34
+ }
35
+ }
@@ -0,0 +1,88 @@
1
+ import { parseArgs } from "node:util";
2
+ import { decodeWav, transcribeMelody } from "@hum2music/audio-analysis";
3
+ import { analyzeHarmony, type TimedNote } from "@hum2music/harmony";
4
+
5
+ export async function runAnalyze(args: string[]): Promise<void> {
6
+ const { values, positionals } = parseArgs({
7
+ args,
8
+ options: {
9
+ chords: { type: "boolean", default: false },
10
+ json: { type: "boolean", default: false },
11
+ bpm: { type: "string" },
12
+ },
13
+ allowPositionals: true,
14
+ });
15
+
16
+ const inputPath = positionals[0];
17
+ if (!inputPath) {
18
+ console.error("Usage: hum2music analyze <input.wav> [--chords] [--json] [--bpm <number>]");
19
+ process.exit(1);
20
+ }
21
+
22
+ const overrideBpm = values.bpm ? Number(values.bpm) : undefined;
23
+
24
+ const buffer = await Bun.file(inputPath).arrayBuffer();
25
+ const { sampleRate, samples } = decodeWav(buffer);
26
+ const transcribedNotes = transcribeMelody(samples, sampleRate);
27
+
28
+ if (transcribedNotes.length === 0) {
29
+ console.error("No valid pitch could be detected in the input audio.");
30
+ process.exit(1);
31
+ }
32
+
33
+ const timedNotes: TimedNote[] = transcribedNotes.map((note) => ({
34
+ pitch: note.midiPitch,
35
+ startMs: note.startMs,
36
+ durationMs: note.durationMs,
37
+ velocity: note.velocity,
38
+ }));
39
+
40
+ const analysis = analyzeHarmony(timedNotes, overrideBpm);
41
+
42
+ if (values.json) {
43
+ console.log(
44
+ JSON.stringify(
45
+ {
46
+ tempoCandidates: analysis.tempoCandidates,
47
+ selectedBpm: analysis.selectedBpm,
48
+ keyCandidates: analysis.keyCandidates,
49
+ melody: analysis.melody,
50
+ chords: values.chords ? analysis.chords : undefined,
51
+ songSections: values.chords ? analysis.songSections : undefined,
52
+ },
53
+ null,
54
+ 2,
55
+ ),
56
+ );
57
+ return;
58
+ }
59
+
60
+ console.log(`Tempo candidates: ${formatTempoCandidates(analysis.tempoCandidates)}`);
61
+ console.log(`Selected BPM: ${analysis.selectedBpm}`);
62
+ console.log(`Key candidates: ${formatKeyCandidates(analysis.keyCandidates)}`);
63
+
64
+ if (values.chords) {
65
+ console.log("Chord progression:");
66
+ for (const chord of analysis.chords) {
67
+ console.log(
68
+ ` bar @ beat ${chord.startBeat}: ${chord.symbol} (confidence ${chord.confidence.toFixed(2)})`,
69
+ );
70
+ }
71
+ console.log(`Song structure: ${formatSongSections(analysis.songSections)}`);
72
+ }
73
+ }
74
+
75
+ function formatSongSections(sections: ReturnType<typeof analyzeHarmony>["songSections"]): string {
76
+ return sections.map((s) => s.label).join("") || "(none)";
77
+ }
78
+
79
+ function formatTempoCandidates(candidates: ReturnType<typeof analyzeHarmony>["tempoCandidates"]): string {
80
+ return candidates.map((c) => `${c.bpm} BPM (${(c.confidence * 100).toFixed(0)}%)`).join(", ");
81
+ }
82
+
83
+ function formatKeyCandidates(candidates: ReturnType<typeof analyzeHarmony>["keyCandidates"]): string {
84
+ return candidates
85
+ .slice(0, 3)
86
+ .map((c) => `${c.tonic} ${c.mode} (${(c.confidence * 100).toFixed(0)}%)`)
87
+ .join(", ");
88
+ }
@@ -0,0 +1,131 @@
1
+ import { parseArgs } from "node:util";
2
+ import {
3
+ ARRANGEABLE_INSTRUMENTS,
4
+ composeTracks,
5
+ type ArrangeableInstrument,
6
+ type ComposedTrack,
7
+ } from "@hum2music/arrangement";
8
+ import { decodeWav, encodeWavPcm16, midiNoteToFrequency, transcribeMelody } from "@hum2music/audio-analysis";
9
+ import { analyzeHarmony, type TimedNote } from "@hum2music/harmony";
10
+ import { noteEventsToMidiNotes, writeMultiTrackMidiFile, type MidiNoteEvent, type MidiTrack } from "@hum2music/midi";
11
+ import {
12
+ classifyDrumNote,
13
+ renderToSamples,
14
+ type DrumRenderNote,
15
+ type PitchedRenderNote,
16
+ type Timbre,
17
+ } from "@hum2music/rendering";
18
+
19
+ const DEFAULT_SEED = 42;
20
+
21
+ export async function runGenerate(args: string[]): Promise<void> {
22
+ const { values, positionals } = parseArgs({
23
+ args,
24
+ options: {
25
+ output: { type: "string", short: "o" },
26
+ wav: { type: "string" },
27
+ instruments: { type: "string" },
28
+ humanize: { type: "string" },
29
+ seed: { type: "string" },
30
+ bpm: { type: "string" },
31
+ style: { type: "string" },
32
+ "song-structure": { type: "boolean", default: false },
33
+ },
34
+ allowPositionals: true,
35
+ });
36
+
37
+ const inputPath = positionals[0];
38
+ const outputPath = values.output;
39
+ if (!inputPath || !outputPath) {
40
+ console.error(
41
+ "Usage: hum2music generate <input.wav> -o <song.mid> [--wav <song.wav>] [--instruments piano,bass,drums,guitar] [--style pop|ballad] [--song-structure] [--humanize <0-1>] [--seed <number>] [--bpm <number>]",
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ const buffer = await Bun.file(inputPath).arrayBuffer();
47
+ const { sampleRate, samples } = decodeWav(buffer);
48
+ const transcribedNotes = transcribeMelody(samples, sampleRate);
49
+
50
+ if (transcribedNotes.length === 0) {
51
+ console.error("No valid pitch could be detected in the input audio.");
52
+ process.exit(1);
53
+ }
54
+
55
+ const timedNotes: TimedNote[] = transcribedNotes.map((note) => ({
56
+ pitch: note.midiPitch,
57
+ startMs: note.startMs,
58
+ durationMs: note.durationMs,
59
+ velocity: note.velocity,
60
+ }));
61
+
62
+ const overrideBpm = values.bpm !== undefined ? Number(values.bpm) : undefined;
63
+ const { melody, chords, songSections, selectedBpm } = analyzeHarmony(timedNotes, { overrideBpm });
64
+
65
+ const seed = values.seed !== undefined ? Number(values.seed) : DEFAULT_SEED;
66
+ const tracks = composeTracks({
67
+ melody,
68
+ chords,
69
+ instruments: parseInstruments(values.instruments),
70
+ humanizeAmount: values.humanize !== undefined ? Number(values.humanize) : 0,
71
+ seed,
72
+ bpm: selectedBpm,
73
+ style: values.style,
74
+ songSections: values["song-structure"] ? songSections : undefined,
75
+ });
76
+
77
+ const midiTracks: MidiTrack[] = tracks.map((track) => ({
78
+ notes: noteEventsToMidiNotes(track.notes, selectedBpm),
79
+ channel: track.channel,
80
+ programNumber: track.programNumber,
81
+ }));
82
+ const midiBytes = writeMultiTrackMidiFile(midiTracks, { bpm: selectedBpm });
83
+ await Bun.write(outputPath, midiBytes);
84
+ console.log(`Wrote ${midiTracks.length} track(s) to ${outputPath}`);
85
+
86
+ if (values.wav) {
87
+ const wavBytes = renderTracksToWav(tracks, selectedBpm, seed);
88
+ await Bun.write(values.wav, wavBytes);
89
+ console.log(`Wrote WAV preview to ${values.wav}`);
90
+ }
91
+ }
92
+
93
+ function parseInstruments(raw: string | undefined): ArrangeableInstrument[] {
94
+ if (raw === undefined) return [...ARRANGEABLE_INSTRUMENTS];
95
+ return raw
96
+ .split(",")
97
+ .map((value) => value.trim())
98
+ .filter((value): value is ArrangeableInstrument => (ARRANGEABLE_INSTRUMENTS as readonly string[]).includes(value));
99
+ }
100
+
101
+ function renderTracksToWav(tracks: ComposedTrack[], bpm: number, seed: number): ArrayBuffer {
102
+ const pitchedNotes: PitchedRenderNote[] = [];
103
+ const drumNotes: DrumRenderNote[] = [];
104
+
105
+ for (const track of tracks) {
106
+ const midiNotes: MidiNoteEvent[] = noteEventsToMidiNotes(track.notes, bpm);
107
+ if (track.instrument === "drums") {
108
+ for (const note of midiNotes) {
109
+ drumNotes.push({
110
+ startMs: note.startMs,
111
+ durationMs: note.durationMs,
112
+ velocity: note.velocity,
113
+ drumType: classifyDrumNote(note.pitch),
114
+ });
115
+ }
116
+ } else {
117
+ const timbre = track.instrument as Timbre;
118
+ for (const note of midiNotes) {
119
+ pitchedNotes.push({
120
+ startMs: note.startMs,
121
+ durationMs: note.durationMs,
122
+ velocity: note.velocity,
123
+ frequencyHz: midiNoteToFrequency(note.pitch),
124
+ timbre,
125
+ });
126
+ }
127
+ }
128
+ }
129
+
130
+ return encodeWavPcm16(renderToSamples(pitchedNotes, drumNotes, { seed }), 44100);
131
+ }
@@ -0,0 +1,17 @@
1
+ import { parseArgs } from "node:util";
2
+ import { startServer } from "@hum2music/web";
3
+
4
+ const DEFAULT_PORT = 3000;
5
+
6
+ export async function runServe(args: string[]): Promise<void> {
7
+ const { values } = parseArgs({
8
+ args,
9
+ options: {
10
+ port: { type: "string" },
11
+ },
12
+ });
13
+
14
+ const port = values.port !== undefined ? Number(values.port) : DEFAULT_PORT;
15
+ startServer(port);
16
+ console.log(`hum2music UI available at http://localhost:${port}`);
17
+ }
@@ -0,0 +1,70 @@
1
+ import { parseArgs } from "node:util";
2
+ import { decodeWav, transcribeMelody } from "@hum2music/audio-analysis";
3
+ import { analyzeHarmony, type TimedNote } from "@hum2music/harmony";
4
+ import { humanizeMelody } from "@hum2music/expression";
5
+ import { noteEventsToMidiNotes, writeStandardMidiFile } from "@hum2music/midi";
6
+
7
+ const DEFAULT_SEED = 42;
8
+
9
+ export async function runTranscribe(args: string[]): Promise<void> {
10
+ const { values, positionals } = parseArgs({
11
+ args,
12
+ options: {
13
+ output: { type: "string", short: "o" },
14
+ humanize: { type: "string" },
15
+ seed: { type: "string" },
16
+ },
17
+ allowPositionals: true,
18
+ });
19
+
20
+ const inputPath = positionals[0];
21
+ const outputPath = values.output;
22
+
23
+ if (!inputPath || !outputPath) {
24
+ console.error("Usage: hum2music transcribe <input.wav> -o <output.mid> [--humanize <0-1>] [--seed <number>]");
25
+ process.exit(1);
26
+ }
27
+
28
+ const buffer = await Bun.file(inputPath).arrayBuffer();
29
+ const { sampleRate, samples } = decodeWav(buffer);
30
+ const transcribedNotes = transcribeMelody(samples, sampleRate);
31
+
32
+ if (transcribedNotes.length === 0) {
33
+ console.error("No valid pitch could be detected in the input audio.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const midiBytes =
38
+ values.humanize !== undefined
39
+ ? buildHumanizedMidi(transcribedNotes, Number(values.humanize), values.seed !== undefined ? Number(values.seed) : DEFAULT_SEED)
40
+ : writeStandardMidiFile(
41
+ transcribedNotes.map((note) => ({
42
+ pitch: note.midiPitch,
43
+ startMs: note.startMs,
44
+ durationMs: note.durationMs,
45
+ velocity: note.velocity,
46
+ })),
47
+ );
48
+
49
+ await Bun.write(outputPath, midiBytes);
50
+ console.log(`Wrote ${transcribedNotes.length} note(s) to ${outputPath}`);
51
+ }
52
+
53
+ function buildHumanizedMidi(
54
+ transcribedNotes: ReturnType<typeof transcribeMelody>,
55
+ humanizeAmount: number,
56
+ seed: number,
57
+ ): Uint8Array {
58
+ const timedNotes: TimedNote[] = transcribedNotes.map((note) => ({
59
+ pitch: note.midiPitch,
60
+ startMs: note.startMs,
61
+ durationMs: note.durationMs,
62
+ velocity: note.velocity,
63
+ }));
64
+
65
+ const { melody, selectedBpm } = analyzeHarmony(timedNotes);
66
+ const humanized = humanizeMelody(melody, { seed, humanizeAmount, bpm: selectedBpm, instrument: "melody" });
67
+ const midiNotes = noteEventsToMidiNotes(humanized, selectedBpm);
68
+
69
+ return writeStandardMidiFile(midiNotes, { bpm: selectedBpm });
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bun
2
+ import { runAnalyze } from "./commands/analyze";
3
+ import { runGenerate } from "./commands/generate";
4
+ import { runServe } from "./commands/serve";
5
+ import { runTranscribe } from "./commands/transcribe";
6
+
7
+ async function main() {
8
+ const [command, ...rest] = process.argv.slice(2);
9
+
10
+ switch (command) {
11
+ case "transcribe":
12
+ await runTranscribe(rest);
13
+ return;
14
+ case "analyze":
15
+ await runAnalyze(rest);
16
+ return;
17
+ case "generate":
18
+ await runGenerate(rest);
19
+ return;
20
+ case "serve":
21
+ await runServe(rest);
22
+ return;
23
+ default:
24
+ console.error(`Unknown command: ${command ?? "(none)"}`);
25
+ console.error("Usage: hum2music transcribe <input.wav> -o <output.mid>");
26
+ process.exit(1);
27
+ }
28
+ }
29
+
30
+ main();