@hum2music/web 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,24 @@
1
1
  {
2
2
  "name": "@hum2music/web",
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": "Hum2Music's localhost UI server (Bun.serve API plus a static, build-free frontend).",
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/web"
11
+ },
12
+ "type": "module",
13
+ "main": "./src/index.ts",
14
+ "types": "./src/index.ts",
15
+ "files": ["src", "public"],
16
+ "dependencies": {
17
+ "@hum2music/audio-analysis": "0.1.0",
18
+ "@hum2music/harmony": "0.1.0",
19
+ "@hum2music/expression": "0.1.0",
20
+ "@hum2music/midi": "0.1.0",
21
+ "@hum2music/arrangement": "0.1.0",
22
+ "@hum2music/rendering": "0.1.0"
23
+ }
24
+ }
package/public/app.js ADDED
@@ -0,0 +1,295 @@
1
+ // Pure audio helpers (no DOM dependency) — exported so they can be unit
2
+ // tested directly under Bun, in addition to running in the browser.
3
+
4
+ export function downmixToMono(audioBuffer) {
5
+ const channels = audioBuffer.numberOfChannels;
6
+ const length = audioBuffer.length;
7
+ const mono = new Float32Array(length);
8
+ for (let channel = 0; channel < channels; channel++) {
9
+ const data = audioBuffer.getChannelData(channel);
10
+ for (let i = 0; i < length; i++) mono[i] += data[i] / channels;
11
+ }
12
+ return mono;
13
+ }
14
+
15
+ export function encodeWavPcm16(samples, sampleRate) {
16
+ const numChannels = 1;
17
+ const bitsPerSample = 16;
18
+ const blockAlign = (numChannels * bitsPerSample) / 8;
19
+ const byteRate = sampleRate * blockAlign;
20
+ const dataSize = samples.length * blockAlign;
21
+
22
+ const buffer = new ArrayBuffer(44 + dataSize);
23
+ const view = new DataView(buffer);
24
+
25
+ writeAscii(view, 0, "RIFF");
26
+ view.setUint32(4, 36 + dataSize, true);
27
+ writeAscii(view, 8, "WAVE");
28
+ writeAscii(view, 12, "fmt ");
29
+ view.setUint32(16, 16, true);
30
+ view.setUint16(20, 1, true);
31
+ view.setUint16(22, numChannels, true);
32
+ view.setUint32(24, sampleRate, true);
33
+ view.setUint32(28, byteRate, true);
34
+ view.setUint16(32, blockAlign, true);
35
+ view.setUint16(34, bitsPerSample, true);
36
+ writeAscii(view, 36, "data");
37
+ view.setUint32(40, dataSize, true);
38
+
39
+ for (let i = 0; i < samples.length; i++) {
40
+ const clamped = Math.max(-1, Math.min(1, samples[i]));
41
+ view.setInt16(44 + i * 2, Math.round(clamped * 32767), true);
42
+ }
43
+
44
+ return buffer;
45
+ }
46
+
47
+ export function writeAscii(view, offset, text) {
48
+ for (let i = 0; i < text.length; i++) view.setUint8(offset + i, text.charCodeAt(i));
49
+ }
50
+
51
+ // Everything below touches the DOM/Web Audio/MediaDevices APIs, so it's
52
+ // skipped entirely when this module is imported in a non-browser test
53
+ // environment (see tests/web/app.test.ts).
54
+ if (typeof document !== "undefined") {
55
+ initUI();
56
+ }
57
+
58
+ function initUI() {
59
+ const fileInput = document.getElementById("file-input");
60
+ const recordButton = document.getElementById("record-button");
61
+ const recordStatus = document.getElementById("record-status");
62
+ const analysisSection = document.getElementById("analysis-section");
63
+ const arrangementSection = document.getElementById("arrangement-section");
64
+ const exportSection = document.getElementById("export-section");
65
+ const pitchCanvas = document.getElementById("pitch-canvas");
66
+ const tempoCandidatesEl = document.getElementById("tempo-candidates");
67
+ const keyCandidatesEl = document.getElementById("key-candidates");
68
+ const notesSummaryEl = document.getElementById("notes-summary");
69
+ const chordEditorEl = document.getElementById("chord-editor");
70
+ const instrumentToggles = Array.from(document.querySelectorAll(".instrument-toggle"));
71
+ const styleSelect = document.getElementById("style-select");
72
+ const songStructureCheckbox = document.getElementById("song-structure-checkbox");
73
+ const humanizeSlider = document.getElementById("humanize-slider");
74
+ const humanizeValueEl = document.getElementById("humanize-value");
75
+ const seedInput = document.getElementById("seed-input");
76
+ const regenerateButton = document.getElementById("regenerate-button");
77
+ const downloadMidiButton = document.getElementById("download-midi-button");
78
+ const downloadWavButton = document.getElementById("download-wav-button");
79
+ const downloadJsonButton = document.getElementById("download-json-button");
80
+
81
+ let lastAnalysis = null;
82
+ let currentChords = [];
83
+ let mediaRecorder = null;
84
+ let recordedChunks = [];
85
+
86
+ fileInput.addEventListener("change", async () => {
87
+ const file = fileInput.files[0];
88
+ if (!file) return;
89
+ const wavBlob = await decodeToWavBlob(await file.arrayBuffer());
90
+ await analyze(wavBlob);
91
+ });
92
+
93
+ recordButton.addEventListener("click", async () => {
94
+ if (mediaRecorder && mediaRecorder.state === "recording") {
95
+ mediaRecorder.stop();
96
+ return;
97
+ }
98
+
99
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
100
+ recordedChunks = [];
101
+ mediaRecorder = new MediaRecorder(stream);
102
+ mediaRecorder.addEventListener("dataavailable", (event) => recordedChunks.push(event.data));
103
+ mediaRecorder.addEventListener("stop", async () => {
104
+ recordStatus.textContent = "解析中...";
105
+ const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType });
106
+ const wavBlob = await decodeToWavBlob(await blob.arrayBuffer());
107
+ await analyze(wavBlob);
108
+ stream.getTracks().forEach((track) => track.stop());
109
+ recordButton.textContent = "マイク録音開始";
110
+ });
111
+
112
+ mediaRecorder.start();
113
+ recordButton.textContent = "録音停止";
114
+ recordStatus.textContent = "録音中...";
115
+ });
116
+
117
+ humanizeSlider.addEventListener("input", () => {
118
+ humanizeValueEl.textContent = humanizeSlider.value;
119
+ });
120
+
121
+ regenerateButton.addEventListener("click", async () => {
122
+ if (!lastAnalysis) return;
123
+ regenerateButton.disabled = true;
124
+ regenerateButton.textContent = "生成中...";
125
+ try {
126
+ await regenerateMidi();
127
+ } finally {
128
+ regenerateButton.disabled = false;
129
+ regenerateButton.textContent = "再生成";
130
+ }
131
+ });
132
+
133
+ downloadMidiButton.addEventListener("click", async () => {
134
+ const blob = await fetchExport("/api/export/midi");
135
+ if (blob) downloadBlob(blob, "song.mid");
136
+ });
137
+
138
+ downloadWavButton.addEventListener("click", async () => {
139
+ const blob = await fetchExport("/api/export/wav");
140
+ if (blob) downloadBlob(blob, "song.wav");
141
+ });
142
+
143
+ downloadJsonButton.addEventListener("click", () => {
144
+ if (!lastAnalysis) return;
145
+ downloadBlob(new Blob([JSON.stringify(lastAnalysis, null, 2)], { type: "application/json" }), "analysis.json");
146
+ });
147
+
148
+ async function decodeToWavBlob(arrayBuffer) {
149
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
150
+ const audioContext = new AudioContextClass();
151
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
152
+ const mono = downmixToMono(audioBuffer);
153
+ const wavBuffer = encodeWavPcm16(mono, audioBuffer.sampleRate);
154
+ await audioContext.close();
155
+ return new Blob([wavBuffer], { type: "audio/wav" });
156
+ }
157
+
158
+ async function analyze(wavBlob) {
159
+ const formData = new FormData();
160
+ formData.append("audio", wavBlob, "input.wav");
161
+
162
+ const response = await fetch("/api/analyze", { method: "POST", body: formData });
163
+ if (!response.ok) {
164
+ const body = await response.json().catch(() => ({ error: response.statusText }));
165
+ alert(`解析に失敗しました: ${body.error ?? response.statusText}`);
166
+ return;
167
+ }
168
+
169
+ lastAnalysis = await response.json();
170
+ currentChords = lastAnalysis.chords.map((chord) => ({ ...chord }));
171
+ renderAnalysis(lastAnalysis);
172
+ renderChordEditor(currentChords);
173
+ analysisSection.hidden = false;
174
+ arrangementSection.hidden = false;
175
+ exportSection.hidden = false;
176
+ }
177
+
178
+ function renderAnalysis(analysis) {
179
+ drawPitchCurve(analysis.pitchCurve, analysis.notes);
180
+ tempoCandidatesEl.textContent = analysis.tempoCandidates
181
+ .map((c) => `${c.bpm} BPM (${Math.round(c.confidence * 100)}%)`)
182
+ .join(", ");
183
+ keyCandidatesEl.textContent = analysis.keyCandidates
184
+ .slice(0, 3)
185
+ .map((c) => `${c.tonic} ${c.mode} (${Math.round(c.confidence * 100)}%)`)
186
+ .join(", ");
187
+ notesSummaryEl.textContent = `検出ノート数: ${analysis.notes.length} / 推定BPM: ${analysis.selectedBpm}`;
188
+ }
189
+
190
+ function renderChordEditor(chords) {
191
+ chordEditorEl.replaceChildren();
192
+ chords.forEach((chord, index) => {
193
+ const label = document.createElement("label");
194
+ label.textContent = `bar ${index + 1}: `;
195
+ const input = document.createElement("input");
196
+ input.type = "text";
197
+ input.value = chord.symbol;
198
+ input.size = 6;
199
+ input.addEventListener("input", () => {
200
+ currentChords[index] = { ...currentChords[index], symbol: input.value };
201
+ });
202
+ label.appendChild(input);
203
+ chordEditorEl.appendChild(label);
204
+ });
205
+ }
206
+
207
+ function drawPitchCurve(pitchCurve, notes) {
208
+ const ctx = pitchCanvas.getContext("2d");
209
+ ctx.clearRect(0, 0, pitchCanvas.width, pitchCanvas.height);
210
+
211
+ const voiced = pitchCurve.filter((frame) => frame.voiced && frame.f0Hz !== null);
212
+ if (voiced.length === 0) return;
213
+
214
+ const maxTimeMs = pitchCurve[pitchCurve.length - 1].timeMs || 1;
215
+ const minHz = Math.min(...voiced.map((frame) => frame.f0Hz));
216
+ const maxHz = Math.max(...voiced.map((frame) => frame.f0Hz));
217
+ const hzRange = maxHz - minHz || 1;
218
+
219
+ ctx.strokeStyle = "#3366cc";
220
+ ctx.beginPath();
221
+ let drawing = false;
222
+ for (const frame of pitchCurve) {
223
+ if (!frame.voiced || frame.f0Hz === null) {
224
+ drawing = false;
225
+ continue;
226
+ }
227
+ const x = (frame.timeMs / maxTimeMs) * pitchCanvas.width;
228
+ const y = pitchCanvas.height - ((frame.f0Hz - minHz) / hzRange) * pitchCanvas.height;
229
+ if (!drawing) {
230
+ ctx.moveTo(x, y);
231
+ drawing = true;
232
+ } else {
233
+ ctx.lineTo(x, y);
234
+ }
235
+ }
236
+ ctx.stroke();
237
+
238
+ ctx.fillStyle = "#cc3366";
239
+ for (const note of notes) {
240
+ const x = (note.startMs / maxTimeMs) * pitchCanvas.width;
241
+ const midiHz = 440 * Math.pow(2, (note.midiPitch - 69) / 12);
242
+ const y = pitchCanvas.height - ((midiHz - minHz) / hzRange) * pitchCanvas.height;
243
+ ctx.fillRect(x - 2, y - 2, 4, 4);
244
+ }
245
+ }
246
+
247
+ function selectedInstruments() {
248
+ return instrumentToggles.filter((toggle) => toggle.checked).map((toggle) => toggle.value);
249
+ }
250
+
251
+ function buildExportRequestBody() {
252
+ return {
253
+ notes: lastAnalysis.notes,
254
+ chords: currentChords,
255
+ instruments: selectedInstruments(),
256
+ style: styleSelect.value,
257
+ songStructure: songStructureCheckbox.checked,
258
+ humanize: Number(humanizeSlider.value),
259
+ seed: Number(seedInput.value),
260
+ bpm: lastAnalysis.selectedBpm,
261
+ };
262
+ }
263
+
264
+ async function regenerateMidi() {
265
+ const response = await fetch("/api/export/midi", {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify(buildExportRequestBody()),
269
+ });
270
+ if (!response.ok) alert("MIDI生成に失敗しました");
271
+ }
272
+
273
+ async function fetchExport(path) {
274
+ if (!lastAnalysis) return null;
275
+ const response = await fetch(path, {
276
+ method: "POST",
277
+ headers: { "Content-Type": "application/json" },
278
+ body: JSON.stringify(buildExportRequestBody()),
279
+ });
280
+ if (!response.ok) {
281
+ alert("書き出しに失敗しました");
282
+ return null;
283
+ }
284
+ return response.blob();
285
+ }
286
+
287
+ function downloadBlob(blob, filename) {
288
+ const url = URL.createObjectURL(blob);
289
+ const anchor = document.createElement("a");
290
+ anchor.href = url;
291
+ anchor.download = filename;
292
+ anchor.click();
293
+ URL.revokeObjectURL(url);
294
+ }
295
+ }
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Hum2Music</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ </head>
9
+ <body>
10
+ <h1>Hum2Music</h1>
11
+ <p class="note">
12
+ 鼻歌から複数トラック(メロディー/ピアノ/ベース/ドラム)の楽曲プレビューを生成します。
13
+ 自動編曲は決定的なルールベースの簡易実装です。
14
+ </p>
15
+
16
+ <section id="input-section">
17
+ <h2>Input</h2>
18
+ <label>WAVファイルを選択: <input type="file" id="file-input" accept="audio/*" /></label>
19
+ <button id="record-button" type="button">マイク録音開始</button>
20
+ <span id="record-status"></span>
21
+ </section>
22
+
23
+ <section id="analysis-section" hidden>
24
+ <h2>Analysis</h2>
25
+ <canvas id="pitch-canvas" width="800" height="200"></canvas>
26
+ <p><strong>BPM候補:</strong> <span id="tempo-candidates"></span></p>
27
+ <p><strong>調性候補:</strong> <span id="key-candidates"></span></p>
28
+ <p id="notes-summary"></p>
29
+ </section>
30
+
31
+ <section id="arrangement-section" hidden>
32
+ <h2>Arrangement</h2>
33
+
34
+ <fieldset>
35
+ <legend>楽器</legend>
36
+ <label><input type="checkbox" class="instrument-toggle" value="piano" checked /> Piano</label>
37
+ <label><input type="checkbox" class="instrument-toggle" value="bass" checked /> Bass</label>
38
+ <label><input type="checkbox" class="instrument-toggle" value="drums" checked /> Drums</label>
39
+ <label><input type="checkbox" class="instrument-toggle" value="guitar" checked /> Guitar</label>
40
+ </fieldset>
41
+
42
+ <fieldset>
43
+ <legend>コード進行(編集可)</legend>
44
+ <div id="chord-editor"></div>
45
+ </fieldset>
46
+
47
+ <label>
48
+ Style:
49
+ <select id="style-select">
50
+ <option value="pop">Pop</option>
51
+ <option value="ballad">Ballad</option>
52
+ </select>
53
+ </label>
54
+ <label><input type="checkbox" id="song-structure-checkbox" /> 曲構造に応じてグルーヴを変化させる</label>
55
+
56
+ <label>
57
+ Humanize: <input type="range" id="humanize-slider" min="0" max="1" step="0.05" value="0" />
58
+ <span id="humanize-value">0</span>
59
+ </label>
60
+ <label>Seed: <input type="number" id="seed-input" value="42" /></label>
61
+ <button id="regenerate-button" type="button">再生成</button>
62
+ </section>
63
+
64
+ <section id="export-section" hidden>
65
+ <h2>Export</h2>
66
+ <button id="download-midi-button" type="button">MIDIダウンロード</button>
67
+ <button id="download-wav-button" type="button">WAVダウンロード</button>
68
+ <button id="download-json-button" type="button">JSONダウンロード</button>
69
+ </section>
70
+
71
+ <script type="module" src="/app.js"></script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,32 @@
1
+ body {
2
+ font-family: system-ui, sans-serif;
3
+ max-width: 840px;
4
+ margin: 2rem auto;
5
+ padding: 0 1rem;
6
+ color: #222;
7
+ }
8
+
9
+ h1 {
10
+ margin-bottom: 0.25rem;
11
+ }
12
+
13
+ .note {
14
+ color: #666;
15
+ font-size: 0.9rem;
16
+ }
17
+
18
+ section {
19
+ border-top: 1px solid #ddd;
20
+ padding: 1rem 0;
21
+ }
22
+
23
+ canvas {
24
+ background: #fafafa;
25
+ border: 1px solid #ddd;
26
+ width: 100%;
27
+ height: 200px;
28
+ }
29
+
30
+ button {
31
+ cursor: pointer;
32
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ decodeWav,
3
+ detectPitchYin,
4
+ removePitchOutliers,
5
+ smoothPitchTrajectory,
6
+ transcribeMelody,
7
+ } from "@hum2music/audio-analysis";
8
+ import { analyzeHarmony, type TimedNote } from "@hum2music/harmony";
9
+
10
+ export async function handleAnalyze(request: Request): Promise<Response> {
11
+ const formData = await request.formData();
12
+ const file = formData.get("audio");
13
+
14
+ if (!(file instanceof Blob)) {
15
+ return jsonError(400, "Missing 'audio' file field");
16
+ }
17
+
18
+ let sampleRate: number;
19
+ let samples: Float32Array;
20
+ try {
21
+ ({ sampleRate, samples } = decodeWav(await file.arrayBuffer()));
22
+ } catch (error) {
23
+ return jsonError(400, `Could not decode audio: ${(error as Error).message}`);
24
+ }
25
+
26
+ // Recomputes the pitch track for display (transcribeMelody runs the same
27
+ // YIN pass internally) — a known inefficiency, acceptable for a local,
28
+ // single-request preview endpoint. See tasks/STATUS.md.
29
+ const pitchCurve = smoothPitchTrajectory(removePitchOutliers(detectPitchYin(samples, sampleRate)));
30
+ const notes = transcribeMelody(samples, sampleRate);
31
+
32
+ if (notes.length === 0) {
33
+ return jsonError(422, "No valid pitch could be detected in the input audio.");
34
+ }
35
+
36
+ const timedNotes: TimedNote[] = notes.map((note) => ({
37
+ pitch: note.midiPitch,
38
+ startMs: note.startMs,
39
+ durationMs: note.durationMs,
40
+ velocity: note.velocity,
41
+ }));
42
+ const harmony = analyzeHarmony(timedNotes);
43
+
44
+ return Response.json({
45
+ sampleRate,
46
+ pitchCurve,
47
+ notes,
48
+ tempoCandidates: harmony.tempoCandidates,
49
+ selectedBpm: harmony.selectedBpm,
50
+ keyCandidates: harmony.keyCandidates,
51
+ chords: harmony.chords,
52
+ });
53
+ }
54
+
55
+ function jsonError(status: number, error: string): Response {
56
+ return Response.json({ error }, { status });
57
+ }
@@ -0,0 +1,28 @@
1
+ import { writeMultiTrackMidiFile } from "@hum2music/midi";
2
+ import { jsonError, resolveComposition, type ExportRequestBody } from "./shared";
3
+
4
+ export async function handleExportMidi(request: Request): Promise<Response> {
5
+ let body: ExportRequestBody;
6
+ try {
7
+ body = (await request.json()) as ExportRequestBody;
8
+ } catch {
9
+ return jsonError(400, "Request body must be JSON");
10
+ }
11
+
12
+ if (!Array.isArray(body.notes) || body.notes.length === 0) {
13
+ return jsonError(400, "'notes' must be a non-empty array");
14
+ }
15
+
16
+ const { tracks, bpm } = resolveComposition(body);
17
+ const midiBytes = writeMultiTrackMidiFile(
18
+ tracks.map((track) => ({ notes: track.notes, channel: track.channel, programNumber: track.programNumber })),
19
+ { bpm },
20
+ );
21
+
22
+ return new Response(midiBytes, {
23
+ headers: {
24
+ "Content-Type": "audio/midi",
25
+ "Content-Disposition": 'attachment; filename="song.mid"',
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,61 @@
1
+ import { encodeWavPcm16, midiNoteToFrequency } from "@hum2music/audio-analysis";
2
+ import {
3
+ classifyDrumNote,
4
+ renderToSamples,
5
+ type DrumRenderNote,
6
+ type PitchedRenderNote,
7
+ type Timbre,
8
+ } from "@hum2music/rendering";
9
+ import { jsonError, resolveComposition, type ExportRequestBody } from "./shared";
10
+
11
+ export async function handleExportWav(request: Request): Promise<Response> {
12
+ let body: ExportRequestBody;
13
+ try {
14
+ body = (await request.json()) as ExportRequestBody;
15
+ } catch {
16
+ return jsonError(400, "Request body must be JSON");
17
+ }
18
+
19
+ if (!Array.isArray(body.notes) || body.notes.length === 0) {
20
+ return jsonError(400, "'notes' must be a non-empty array");
21
+ }
22
+
23
+ const { tracks } = resolveComposition(body);
24
+ const pitchedNotes: PitchedRenderNote[] = [];
25
+ const drumNotes: DrumRenderNote[] = [];
26
+
27
+ for (const track of tracks) {
28
+ if (track.instrument === "drums") {
29
+ for (const note of track.notes) {
30
+ drumNotes.push({
31
+ startMs: note.startMs,
32
+ durationMs: note.durationMs,
33
+ velocity: note.velocity,
34
+ drumType: classifyDrumNote(note.pitch),
35
+ });
36
+ }
37
+ } else {
38
+ const timbre = track.instrument as Timbre;
39
+ for (const note of track.notes) {
40
+ pitchedNotes.push({
41
+ startMs: note.startMs,
42
+ durationMs: note.durationMs,
43
+ velocity: note.velocity,
44
+ frequencyHz: midiNoteToFrequency(note.pitch),
45
+ timbre,
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ const seed = typeof body.seed === "number" ? body.seed : undefined;
52
+ const samples = renderToSamples(pitchedNotes, drumNotes, { seed });
53
+ const wavBytes = encodeWavPcm16(samples, 44100);
54
+
55
+ return new Response(wavBytes, {
56
+ headers: {
57
+ "Content-Type": "audio/wav",
58
+ "Content-Disposition": 'attachment; filename="song.wav"',
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ ARRANGEABLE_INSTRUMENTS,
3
+ composeTracks,
4
+ type ArrangeableInstrument,
5
+ } from "@hum2music/arrangement";
6
+ import type { ChordEvent } from "@hum2music/core";
7
+ import { analyzeHarmony, type TimedNote } from "@hum2music/harmony";
8
+ import { noteEventsToMidiNotes, type MidiNoteEvent } from "@hum2music/midi";
9
+
10
+ export interface ExportRequestNote {
11
+ midiPitch: number;
12
+ startMs: number;
13
+ durationMs: number;
14
+ velocity: number;
15
+ }
16
+
17
+ export interface ExportRequestBody {
18
+ notes?: ExportRequestNote[];
19
+ /** Omit (or leave empty) for melody-only export; provide to arrange piano/bass/drums. May be user-edited. */
20
+ chords?: ChordEvent[];
21
+ instruments?: string[];
22
+ humanize?: number;
23
+ seed?: number;
24
+ bpm?: number;
25
+ /** "pop" (default) or "ballad"; see packages/arrangement/src/style.ts. */
26
+ style?: string;
27
+ /** When true, humanize intensity varies by detected repeated section instead of staying flat. */
28
+ songStructure?: boolean;
29
+ }
30
+
31
+ export interface ResolvedTrack {
32
+ instrument: string;
33
+ notes: MidiNoteEvent[];
34
+ channel: number;
35
+ programNumber?: number;
36
+ }
37
+
38
+ export function jsonError(status: number, error: string): Response {
39
+ return Response.json({ error }, { status });
40
+ }
41
+
42
+ /**
43
+ * Resolves a request into ms-based, export-ready tracks. Shared by
44
+ * /api/export/midi and /api/export/wav so both honor the same instrument
45
+ * selection and chord progression (auto-detected or user-edited).
46
+ *
47
+ * - No chords and no humanize: raw passthrough of the posted notes (no
48
+ * quantization), matching the CLI's `transcribe` default behavior.
49
+ * - No chords but humanize > 0: quantizes the melody (needed for
50
+ * humanization) but arranges no instrument tracks.
51
+ * - Chords present: full melody + requested-instrument arrangement.
52
+ */
53
+ export function resolveComposition(body: ExportRequestBody): { tracks: ResolvedTrack[]; bpm: number } {
54
+ const notes = body.notes ?? [];
55
+ const humanizeAmount = typeof body.humanize === "number" ? body.humanize : 0;
56
+ const seed = typeof body.seed === "number" ? body.seed : undefined;
57
+ const hasChords = Array.isArray(body.chords) && body.chords.length > 0;
58
+
59
+ if (!hasChords && humanizeAmount <= 0) {
60
+ const bpm = body.bpm ?? 120;
61
+ const rawNotes: MidiNoteEvent[] = notes.map((note) => ({
62
+ pitch: note.midiPitch,
63
+ startMs: note.startMs,
64
+ durationMs: note.durationMs,
65
+ velocity: note.velocity,
66
+ }));
67
+ return { tracks: [{ instrument: "melody", notes: rawNotes, channel: 0 }], bpm };
68
+ }
69
+
70
+ const timedNotes: TimedNote[] = notes.map((note) => ({
71
+ pitch: note.midiPitch,
72
+ startMs: note.startMs,
73
+ durationMs: note.durationMs,
74
+ velocity: note.velocity,
75
+ }));
76
+ const harmony = analyzeHarmony(timedNotes, { overrideBpm: body.bpm });
77
+ const bpm = harmony.selectedBpm;
78
+
79
+ const composed = composeTracks({
80
+ melody: harmony.melody,
81
+ chords: body.chords ?? [],
82
+ instruments: hasChords ? parseInstruments(body.instruments) : [],
83
+ humanizeAmount,
84
+ seed,
85
+ bpm,
86
+ style: body.style,
87
+ songSections: body.songStructure ? harmony.songSections : undefined,
88
+ });
89
+
90
+ const tracks: ResolvedTrack[] = composed.map((track) => ({
91
+ instrument: track.instrument,
92
+ notes: noteEventsToMidiNotes(track.notes, bpm),
93
+ channel: track.channel,
94
+ programNumber: track.programNumber,
95
+ }));
96
+
97
+ return { tracks, bpm };
98
+ }
99
+
100
+ function parseInstruments(raw: string[] | undefined): ArrangeableInstrument[] {
101
+ if (!raw) return [...ARRANGEABLE_INSTRUMENTS];
102
+ return raw.filter((value): value is ArrangeableInstrument =>
103
+ (ARRANGEABLE_INSTRUMENTS as readonly string[]).includes(value),
104
+ );
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./server";
package/src/server.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { handleAnalyze } from "./api/analyze";
4
+ import { handleExportMidi } from "./api/export-midi";
5
+ import { handleExportWav } from "./api/export-wav";
6
+
7
+ const PUBLIC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../public");
8
+
9
+ export function startServer(port: number): ReturnType<typeof Bun.serve> {
10
+ return Bun.serve({
11
+ port,
12
+ async fetch(request) {
13
+ const url = new URL(request.url);
14
+
15
+ if (request.method === "POST" && url.pathname === "/api/analyze") {
16
+ return handleAnalyze(request);
17
+ }
18
+ if (request.method === "POST" && url.pathname === "/api/export/midi") {
19
+ return handleExportMidi(request);
20
+ }
21
+ if (request.method === "POST" && url.pathname === "/api/export/wav") {
22
+ return handleExportWav(request);
23
+ }
24
+
25
+ return serveStatic(url.pathname);
26
+ },
27
+ });
28
+ }
29
+
30
+ async function serveStatic(pathname: string): Promise<Response> {
31
+ const relativePath = pathname === "/" ? "/index.html" : pathname;
32
+ const filePath = resolve(PUBLIC_DIR, "." + relativePath);
33
+
34
+ if (!filePath.startsWith(PUBLIC_DIR)) {
35
+ return new Response("Forbidden", { status: 403 });
36
+ }
37
+
38
+ const file = Bun.file(filePath);
39
+ if (!(await file.exists())) {
40
+ return new Response("Not found", { status: 404 });
41
+ }
42
+ return new Response(file);
43
+ }
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.