Skip to content

Commit c5566c5

Browse files
authored
Merge pull request #155 from TwDover/feat/hum-to-melody
feat/hum-to-melody
2 parents cfa4fb9 + bc58e4d commit c5566c5

13 files changed

Lines changed: 1099 additions & 10 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/*
2+
* Roadmap 8.2 UI check: switch to Full Song mode, open the Advanced details
3+
* section, and confirm the new "…or hum/whistle it" field renders alongside
4+
* the existing melody/groove import rows. Real mic capture can't be driven
5+
* headlessly, so this only verifies the DOM + a click on "● Rec" doesn't throw
6+
* (a permission-denied error in this environment is expected and fine).
7+
*
8+
* `gg` = { evaluate, screenshot, waitReady, raw(method,params), sleep }.
9+
*/
10+
export default async function (gg) {
11+
await gg.evaluate(`
12+
const b = [...document.querySelectorAll('button')].find(b => b.textContent.trim().endsWith('Setup'));
13+
if (b) b.click();
14+
return !!b;
15+
`);
16+
await gg.sleep(400);
17+
18+
const afterSetupClick = await gg.evaluate(`
19+
const sheet = document.querySelector('.sheet-top');
20+
return { className: sheet ? sheet.className : null };
21+
`);
22+
console.log('AFTER_SETUP_CLICK', JSON.stringify(afterSetupClick));
23+
24+
const switched = await gg.evaluate(`
25+
const byTitle = (t) => [...document.querySelectorAll('.mc-title')].find(s => s.textContent.trim() === t);
26+
const el = byTitle('Full Song');
27+
if (el) { el.closest('button').click(); return true; }
28+
return false;
29+
`);
30+
await gg.sleep(400);
31+
32+
const opened = await gg.evaluate(`
33+
const form = document.querySelector('.song-form');
34+
if (!form) return false;
35+
const summary = [...form.querySelectorAll('summary')].find(s => s.textContent.includes('Advanced'));
36+
if (summary) { summary.closest('details').open = true; return true; }
37+
return false;
38+
`);
39+
await gg.sleep(300);
40+
41+
const before = await gg.evaluate(`
42+
const form = document.querySelector('.song-form');
43+
const fields = form ? [...form.querySelectorAll('.field label')].map(l => l.textContent.trim().slice(0, 80)) : [];
44+
const recBtn = form ? [...form.querySelectorAll('.melody-row button')].find(b => b.textContent.includes('Rec')) : null;
45+
return { hasSongForm: !!form, fields, hasRecBtn: !!recBtn, recBtnText: recBtn ? recBtn.textContent.trim() : null };
46+
`);
47+
48+
await gg.evaluate(`
49+
const label = [...document.querySelectorAll('.field label')].find(l => l.textContent.includes('hum/whistle'));
50+
if (label) label.scrollIntoView({ block: 'center' });
51+
return !!label;
52+
`);
53+
await gg.sleep(300);
54+
55+
const drawerState = await gg.evaluate(`
56+
const sheet = document.querySelector('.sheet-top');
57+
if (!sheet) return { found: false };
58+
const rect = sheet.getBoundingClientRect();
59+
return { found: true, className: sheet.className, rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height } };
60+
`);
61+
console.log('DRAWER_STATE', JSON.stringify(drawerState));
62+
63+
await gg.screenshot(process.env.GG_SHOT || '/tmp/hum-ui.png');
64+
65+
const clickResult = await gg.evaluate(`
66+
try {
67+
const form = document.querySelector('.song-form');
68+
const recBtn = form ? [...form.querySelectorAll('.melody-row button')].find(b => b.textContent.includes('Rec')) : null;
69+
if (!recBtn) return { clicked: false };
70+
recBtn.click();
71+
return { clicked: true };
72+
} catch (e) {
73+
return { clicked: false, error: String(e) };
74+
}
75+
`);
76+
await gg.sleep(800);
77+
78+
await gg.sleep(1500); // let a couple seconds of (silent, headless) mic input accumulate
79+
80+
const stopResult = await gg.evaluate(`
81+
const form = document.querySelector('.song-form');
82+
const stopBtn = form ? [...form.querySelectorAll('.melody-row button')].find(b => b.textContent.includes('Stop')) : null;
83+
if (!stopBtn) return { clicked: false };
84+
stopBtn.click();
85+
return { clicked: true };
86+
`);
87+
await gg.sleep(1000); // decode + pitch-detect is synchronous JS but give the mic stream time to close
88+
89+
const after = await gg.evaluate(`
90+
const errEl = document.querySelector('.hum-error');
91+
const capturedEl = document.querySelector('.hum-captured');
92+
const buildBtn = document.querySelector('.sb-generate-btn');
93+
return {
94+
errorText: errEl ? errEl.textContent.trim() : null,
95+
capturedText: capturedEl ? capturedEl.textContent.trim() : null,
96+
buildBtnText: buildBtn ? buildBtn.textContent.trim() : null,
97+
};
98+
`);
99+
await gg.screenshot((process.env.GG_SHOT || '/tmp/hum-ui.png').replace(/\.png$/, '-after-stop.png'));
100+
101+
console.log('SCENARIO', JSON.stringify({ switched, opened, before, clickResult, stopResult, after }, null, 2));
102+
}

‎backend/app/api/routes_song.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
RebuildSongProgressionRequest, RearrangeSongSectionsRequest,
3232
AudioClipInfo, PartAutomation,
3333
NoteRegionInfo, SaveNoteRegionRequest, MoveNoteRegionRequest,
34-
SetNoteRegionLoopRequest, NoteRegionMutationResponse)
34+
SetNoteRegionLoopRequest, NoteRegionMutationResponse,
35+
BuildSongFromNotesRequest)
3536
from app.services.style_loader import load_style
3637
from app.services.progression_import import parse_progression, ROMAN_TOKEN_RE
3738
from app.services.midi_writer import (NoteEvent, write_midi, rebuild_combined_from_parts,
@@ -580,6 +581,39 @@ async def build_song_from_melody(
580581
return _do_build_song(req, user_progression=progression, hook_melody=melody)
581582

582583

584+
@router.post("/build-song-from-notes", response_model=BuildSongResponse)
585+
def build_song_from_notes(req: BuildSongFromNotesRequest):
586+
"""Build a full song around a hummed/whistled melody (roadmap 8.2).
587+
588+
The frontend pitch-detects mic audio into a note list client-side (YIN +
589+
note segmentation, see frontend/src/utils/pitchDetect.ts) and posts it
590+
here as plain note data instead of a MIDI file. From here it's the exact
591+
same seam as build-song-from-melody: detect the key, derive a supporting
592+
progression, and use the notes as the song's chorus hook.
593+
"""
594+
from app.services.melody_import import detect_key, derive_progression
595+
596+
melody = [NoteEvent(n.pitch, n.start, n.duration, n.velocity, 2) for n in req.notes]
597+
key, scale = detect_key(melody)
598+
progression = derive_progression(melody, key, scale)
599+
600+
try:
601+
style = load_style(req.style_id)
602+
except ValueError as e:
603+
raise HTTPException(status_code=404, detail=str(e))
604+
bpm_min, bpm_max = style.get("bpm_range", [40, 240])
605+
bpm = int(max(bpm_min, min(bpm_max, req.bpm)))
606+
607+
build_req = BuildSongRequest(
608+
style_id=req.style_id, key=key, scale=scale, bpm=bpm,
609+
complexity=req.complexity, variation=req.variation, humanize=req.humanize,
610+
parts=req.parts, template=req.template, seed=req.seed, use_priors=req.use_priors,
611+
chorus_key_shift=req.chorus_key_shift, final_chorus_lift=req.final_chorus_lift,
612+
tempo_automation=req.tempo_automation,
613+
)
614+
return _do_build_song(build_req, user_progression=progression, hook_melody=melody)
615+
616+
583617
def _mine_uploaded_groove(data: bytes) -> dict | None:
584618
"""Mine a finalized groove (derived drum fields + feel + fills) from an
585619
uploaded drum-MIDI file, reusing the corpus miner (app.mining.drums). Returns

‎backend/app/models/schemas.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,35 @@ class BuildSongRequest(BaseModel):
186186
progression_text: Optional[str] = Field(default=None, max_length=200) # free-typed roman numerals or chord names ("Am F C G"); parsed server-side into progression_override
187187

188188

189+
class HummedNote(BaseModel):
190+
"""One note from client-side pitch detection (roadmap 8.2 — see
191+
frontend/src/utils/pitchDetect.ts). Units match NoteEvent: beats, not
192+
seconds — the frontend quantizes to a grid before posting."""
193+
pitch: int = Field(..., ge=0, le=127)
194+
start: float = Field(..., ge=0.0)
195+
duration: float = Field(..., gt=0.0)
196+
velocity: int = Field(default=90, ge=1, le=127)
197+
198+
199+
class BuildSongFromNotesRequest(BaseModel):
200+
"""Build a song around a hummed/whistled melody, pitch-detected client-side
201+
(roadmap 8.2). Mirrors build-song-from-melody's fields, minus the MIDI file —
202+
a plain note list stands in for it."""
203+
notes: List[HummedNote] = Field(..., min_length=4, max_length=256)
204+
style_id: str
205+
template: str = "verse_chorus"
206+
parts: List[str] = ["chords", "bass", "melody", "drums", "pads"]
207+
complexity: float = Field(default=0.6, ge=0.0, le=1.0)
208+
variation: float = Field(default=0.4, ge=0.0, le=1.0)
209+
humanize: float = Field(default=0.5, ge=0.0, le=1.0)
210+
use_priors: bool = False
211+
chorus_key_shift: Optional[int] = Field(default=None, ge=-12, le=12)
212+
final_chorus_lift: Optional[int] = Field(default=None, ge=-12, le=12)
213+
tempo_automation: float = Field(default=0.5, ge=0.0, le=1.0)
214+
bpm: int = Field(default=100, ge=40, le=240)
215+
seed: Optional[int] = None
216+
217+
189218
class RegenerateSongPartRequest(BaseModel):
190219
generation_id: str
191220
part: str

‎backend/tests/test_song_features.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,68 @@ def test_build_song_from_melody_end_to_end():
305305
assert fi.part == "drums"
306306

307307

308+
# ── Hum/whistle → melody (8.2) ───────────────────────────────────────────────
309+
310+
def _hummed_notes(key_root=60, minor=False) -> list["HummedNote"]:
311+
"""The same 4-bar diatonic melody as _melody_bytes, as HummedNote objects —
312+
stands in for what usePitchDetect.ts would post after pitch-detecting a
313+
hummed take."""
314+
from app.models.schemas import HummedNote
315+
sc = [0, 2, 3, 5, 7, 8, 10] if minor else [0, 2, 4, 5, 7, 9, 11]
316+
seqs = [[0, 2, 4, 2], [0, 3, 5, 3], [4, 2, 1, 0], [0, 1, 2, 0]]
317+
notes = []
318+
for bar, steps in enumerate(seqs):
319+
for q, s in enumerate(steps):
320+
notes.append(HummedNote(pitch=key_root + sc[s], start=bar * 4 + q, duration=0.9, velocity=90))
321+
return notes
322+
323+
324+
def test_build_song_from_notes_end_to_end():
325+
"""A hummed note list (no MIDI file involved) reaches the same seam as
326+
build-song-from-melody: key detection, progression derivation, and the
327+
notes become the chorus hook."""
328+
from app.models.schemas import BuildSongFromNotesRequest
329+
from app.api.routes_song import build_song_from_notes
330+
331+
notes = _hummed_notes()
332+
r = build_song_from_notes(BuildSongFromNotesRequest(
333+
notes=notes, style_id="lofi", template="compact",
334+
parts=["chords", "bass", "melody", "drums"], complexity=0.6, variation=0.4,
335+
humanize=0.5, use_priors=False, chorus_key_shift=0, final_chorus_lift=0,
336+
tempo_automation=0.5, bpm=100, seed=64))
337+
assert r.key.startswith("C major")
338+
d = EXPORTS_DIR / r.generation_id
339+
340+
chorus = next(s for s in r.sections if s.section_type == "chorus")
341+
mid = mido.MidiFile(str(d / "melody.mid"))
342+
tpb = mid.ticks_per_beat
343+
mid_notes = []
344+
for tr in mid.tracks:
345+
t = 0
346+
for msg in tr:
347+
t += msg.time
348+
if msg.type == "note_on" and msg.velocity > 0:
349+
mid_notes.append((t / tpb, msg.note))
350+
lo, hi = chorus.start_bar * 4, (chorus.start_bar + chorus.bars) * 4
351+
chorus_notes = {(round((t - lo) % 16, 1), p) for t, p in mid_notes if lo <= t < hi}
352+
hook_set = {(round(n.start, 1), n.pitch) for n in notes}
353+
matched = sum(1 for ev in hook_set if ev in chorus_notes)
354+
assert matched / len(hook_set) >= 0.9, "chorus should carry the hummed hook"
355+
356+
357+
def test_build_song_from_notes_clamps_bpm_to_style_range():
358+
from app.models.schemas import BuildSongFromNotesRequest
359+
from app.api.routes_song import build_song_from_notes
360+
361+
r = build_song_from_notes(BuildSongFromNotesRequest(
362+
notes=_hummed_notes(), style_id="lofi", template="compact",
363+
parts=["chords", "bass", "melody", "drums"], use_priors=False,
364+
bpm=240, seed=65)) # above lofi's bpm_range (70-90)
365+
from app.services.style_loader import load_style
366+
bpm_min, bpm_max = load_style("lofi").get("bpm_range", [40, 240])
367+
assert bpm_min <= r.bpm <= bpm_max
368+
369+
308370
# ── Seed from a progression (5.4a) ───────────────────────────────────────────
309371

310372
def test_build_song_with_progression_text_pins_harmony():

‎frontend/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎frontend/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "genregrid-frontend",
33
"private": true,
4-
"version": "0.12.0",
4+
"version": "0.13.0",
55
"desktopName": "genregrid.desktop",
66
"license": "GPL-3.0-or-later",
77
"description": "Genregrid — A Midi Loop Generator",

0 commit comments

Comments
 (0)