From f0cb06268e1a0604f93bd9c9c06bd671122d51ed Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 10 May 2026 18:52:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(music-lab):=20music=5Fbatch=5Fjobs=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20+=20=EC=9E=A5=EB=A5=B4=EB=B3=84?= =?UTF-8?q?=20=EB=9E=9C=EB=8D=A4=20=ED=92=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- music-lab/app/db.py | 103 +++++++++++++++++++++++++++++++ music-lab/app/random_pools.py | 69 +++++++++++++++++++++ music-lab/tests/test_batch_db.py | 96 ++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 music-lab/app/random_pools.py create mode 100644 music-lab/tests/test_batch_db.py diff --git a/music-lab/app/db.py b/music-lab/app/db.py index 744d463..06b4077 100644 --- a/music-lab/app/db.py +++ b/music-lab/app/db.py @@ -264,6 +264,27 @@ def init_db() -> None: ) """) + # ── music_batch_jobs 테이블 ────────────────────────────────────── + conn.execute(""" + CREATE TABLE IF NOT EXISTS music_batch_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + genre TEXT NOT NULL, + count INTEGER NOT NULL, + target_duration_sec INTEGER NOT NULL DEFAULT 180, + auto_pipeline INTEGER NOT NULL DEFAULT 1, + completed INTEGER NOT NULL DEFAULT 0, + track_ids_json TEXT NOT NULL DEFAULT '[]', + current_track_index INTEGER NOT NULL DEFAULT 0, + current_track_status TEXT, + status TEXT NOT NULL DEFAULT 'queued', + error TEXT, + compile_job_id INTEGER, + pipeline_id INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + # ── YouTube pipeline 테이블 (5개) ───────────────────────────────── # track_id는 nullable: compile_job_id로 입력하는 essential mix 모드 지원 conn.execute(""" @@ -1257,3 +1278,85 @@ def get_oauth_token() -> Optional[Dict[str, Any]]: def delete_oauth_token() -> None: with _conn() as conn: conn.execute("DELETE FROM youtube_oauth_tokens") + + +# ── music_batch_jobs CRUD ───────────────────────────────────────────────────── + +_BATCH_ALLOWED_COLS = frozenset([ + "completed", "track_ids_json", "current_track_index", + "current_track_status", "status", "error", + "compile_job_id", "pipeline_id", +]) + + +def create_batch_job(genre: str, count: int, target_duration_sec: int = 180, + auto_pipeline: bool = True) -> int: + with _conn() as conn: + now = _now() + cur = conn.cursor() + cur.execute(""" + INSERT INTO music_batch_jobs + (genre, count, target_duration_sec, auto_pipeline, + status, created_at, updated_at) + VALUES (?, ?, ?, ?, 'queued', ?, ?) + """, (genre, count, target_duration_sec, 1 if auto_pipeline else 0, now, now)) + return cur.lastrowid + + +def get_batch_job(batch_id: int) -> dict | None: + with _conn() as conn: + row = conn.execute( + "SELECT * FROM music_batch_jobs WHERE id = ?", (batch_id,) + ).fetchone() + if not row: + return None + d = dict(row) + d["track_ids"] = json.loads(d.get("track_ids_json") or "[]") + return d + + +def update_batch_job(batch_id: int, **fields) -> None: + unknown = set(fields) - _BATCH_ALLOWED_COLS + if unknown: + raise ValueError(f"unknown batch job columns: {unknown}") + if not fields: + return + cols = ", ".join(f"{k} = ?" for k in fields) + vals = list(fields.values()) + [_now(), batch_id] + with _conn() as conn: + conn.execute( + f"UPDATE music_batch_jobs SET {cols}, updated_at = ? WHERE id = ?", + vals, + ) + + +def append_batch_track(batch_id: int, track_id: int) -> None: + """track_ids_json에 새 track_id 추가 + completed 증가 (atomic).""" + with _conn() as conn: + row = conn.execute( + "SELECT track_ids_json, completed FROM music_batch_jobs WHERE id = ?", + (batch_id,), + ).fetchone() + if not row: + return + ids = json.loads(row["track_ids_json"] or "[]") + ids.append(track_id) + conn.execute( + "UPDATE music_batch_jobs SET track_ids_json = ?, completed = ?, updated_at = ? WHERE id = ?", + (json.dumps(ids), row["completed"] + 1, _now(), batch_id), + ) + + +def list_batch_jobs(active_only: bool = False) -> list[dict]: + sql = "SELECT * FROM music_batch_jobs" + if active_only: + sql += " WHERE status NOT IN ('failed','cancelled','piped')" + sql += " ORDER BY created_at DESC" + with _conn() as conn: + rows = conn.execute(sql).fetchall() + out = [] + for r in rows: + d = dict(r) + d["track_ids"] = json.loads(d.get("track_ids_json") or "[]") + out.append(d) + return out diff --git a/music-lab/app/random_pools.py b/music-lab/app/random_pools.py new file mode 100644 index 0000000..9ea8236 --- /dev/null +++ b/music-lab/app/random_pools.py @@ -0,0 +1,69 @@ +"""장르별 음악 파라미터 랜덤 풀 — 음악적으로 어울리는 결과 유도.""" +import random + +POOLS = { + "lo-fi": { + "moods": ["chill", "relaxing", "dreamy", "melancholic", "mellow", "nostalgic", "peaceful"], + "instruments_pool": ["piano", "synth", "drums", "vinyl", "rhodes", "soft bass", "ambient pads"], + "instruments_count": (3, 4), + "bpm": (70, 90), + "keys": ["C", "D", "F", "G", "A"], + "scales": ["minor", "major"], + "prompt_modifiers": ["cozy bedroom vibes", "rainy night", "late night study", "cafe ambience"], + }, + "phonk": { + "moods": ["dark", "aggressive", "moody", "intense", "hypnotic"], + "instruments_pool": ["808 bass", "hi-hat", "synth lead", "vocal chops", "bass drops", "trap drums"], + "instruments_count": (3, 4), + "bpm": (130, 160), + "keys": ["C", "D", "F", "G"], + "scales": ["minor"], + "prompt_modifiers": ["drift atmosphere", "dark neon", "midnight drive"], + }, + "ambient": { + "moods": ["peaceful", "meditative", "ethereal", "spacious", "dreamy"], + "instruments_pool": ["pad synths", "atmospheric guitar", "soft strings", "field recordings", "drone bass"], + "instruments_count": (2, 3), + "bpm": (50, 75), + "keys": ["C", "D", "E", "G", "A"], + "scales": ["major", "minor"], + "prompt_modifiers": ["misty mountain morning", "deep space", "still water", "forest dawn"], + }, + "pop": { + "moods": ["uplifting", "happy", "energetic", "romantic", "catchy"], + "instruments_pool": ["acoustic guitar", "piano", "drums", "bass", "synth", "vocals harmonies"], + "instruments_count": (3, 5), + "bpm": (95, 130), + "keys": ["C", "D", "E", "F", "G", "A"], + "scales": ["major"], + "prompt_modifiers": ["radio-ready", "summer vibe", "feel-good"], + }, + "default": { + "moods": ["chill", "relaxing", "uplifting", "mellow"], + "instruments_pool": ["piano", "synth", "drums", "guitar", "bass", "strings"], + "instruments_count": (3, 4), + "bpm": (80, 110), + "keys": ["C", "D", "F", "G", "A"], + "scales": ["minor", "major"], + "prompt_modifiers": [""], + }, +} + + +def randomize(genre: str, rng=None) -> dict: + """장르 → 랜덤 음악 파라미터 1세트. + + 반환: {moods, instruments, bpm, key, scale, prompt_modifier} + """ + rng = rng or random.Random() + pool = POOLS.get(genre.lower(), POOLS["default"]) + n_instr = rng.randint(*pool["instruments_count"]) + instruments = rng.sample(pool["instruments_pool"], min(n_instr, len(pool["instruments_pool"]))) + return { + "moods": [rng.choice(pool["moods"])], + "instruments": instruments, + "bpm": rng.randint(*pool["bpm"]), + "key": rng.choice(pool["keys"]), + "scale": rng.choice(pool["scales"]), + "prompt_modifier": rng.choice(pool["prompt_modifiers"]), + } diff --git a/music-lab/tests/test_batch_db.py b/music-lab/tests/test_batch_db.py new file mode 100644 index 0000000..0dcfdef --- /dev/null +++ b/music-lab/tests/test_batch_db.py @@ -0,0 +1,96 @@ +import pytest +from app import db + + +@pytest.fixture +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(db, "DB_PATH", str(tmp_path / "music.db")) + db.init_db() + return db + + +def test_create_batch_job(fresh_db): + bid = db.create_batch_job(genre="lo-fi", count=10) + j = db.get_batch_job(bid) + assert j["genre"] == "lo-fi" + assert j["count"] == 10 + assert j["status"] == "queued" + assert j["track_ids"] == [] + assert j["auto_pipeline"] == 1 + assert j["target_duration_sec"] == 180 + + +def test_create_batch_job_no_auto_pipeline(fresh_db): + bid = db.create_batch_job(genre="phonk", count=5, auto_pipeline=False) + j = db.get_batch_job(bid) + assert j["auto_pipeline"] == 0 + + +def test_update_batch_job(fresh_db): + bid = db.create_batch_job(genre="phonk", count=5) + db.update_batch_job(bid, status="generating", current_track_index=2) + j = db.get_batch_job(bid) + assert j["status"] == "generating" + assert j["current_track_index"] == 2 + + +def test_update_batch_rejects_unknown_col(fresh_db): + bid = db.create_batch_job(genre="lo-fi", count=1) + with pytest.raises(ValueError): + db.update_batch_job(bid, evil_col="x") + + +def test_append_batch_track(fresh_db): + bid = db.create_batch_job(genre="lo-fi", count=3) + db.append_batch_track(bid, 101) + db.append_batch_track(bid, 102) + j = db.get_batch_job(bid) + assert j["track_ids"] == [101, 102] + assert j["completed"] == 2 + + +def test_list_batch_jobs_active_filter(fresh_db): + b1 = db.create_batch_job(genre="lo-fi", count=1) + b2 = db.create_batch_job(genre="phonk", count=1) + db.update_batch_job(b1, status="failed") + actives = db.list_batch_jobs(active_only=True) + assert all(j["status"] not in ("failed",) for j in actives) + assert any(j["id"] == b2 for j in actives) + assert not any(j["id"] == b1 for j in actives) + + +def test_random_pools_lofi(): + from app.random_pools import randomize, POOLS + import random + rng = random.Random(42) + result = randomize("lo-fi", rng) + assert result["bpm"] in range(70, 91) + assert result["key"] in POOLS["lo-fi"]["keys"] + assert result["scale"] in POOLS["lo-fi"]["scales"] + assert len(result["moods"]) == 1 + assert result["moods"][0] in POOLS["lo-fi"]["moods"] + assert 3 <= len(result["instruments"]) <= 4 + + +def test_random_pools_phonk(): + from app.random_pools import randomize + import random + rng = random.Random(0) + result = randomize("phonk", rng) + assert result["bpm"] in range(130, 161) + assert result["scale"] == "minor" + + +def test_random_pools_unknown_genre_uses_default(): + from app.random_pools import randomize + import random + result = randomize("nonexistent", random.Random(0)) + assert result["bpm"] in range(80, 111) + + +def test_random_pools_seed_reproducible(): + from app.random_pools import randomize + import random + a = randomize("lo-fi", random.Random(123)) + b = randomize("lo-fi", random.Random(123)) + assert a == b