feat(music-lab): music_batch_jobs 테이블 + 장르별 랜덤 풀
This commit is contained in:
@@ -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개) ─────────────────────────────────
|
# ── YouTube pipeline 테이블 (5개) ─────────────────────────────────
|
||||||
# track_id는 nullable: compile_job_id로 입력하는 essential mix 모드 지원
|
# track_id는 nullable: compile_job_id로 입력하는 essential mix 모드 지원
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
@@ -1257,3 +1278,85 @@ def get_oauth_token() -> Optional[Dict[str, Any]]:
|
|||||||
def delete_oauth_token() -> None:
|
def delete_oauth_token() -> None:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
conn.execute("DELETE FROM youtube_oauth_tokens")
|
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
|
||||||
|
|||||||
69
music-lab/app/random_pools.py
Normal file
69
music-lab/app/random_pools.py
Normal file
@@ -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"]),
|
||||||
|
}
|
||||||
96
music-lab/tests/test_batch_db.py
Normal file
96
music-lab/tests/test_batch_db.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user