Gap 13: BWF/INFO metadata cho WAV export — wav_bwf.patch_bwf_metadata chen bext + LIST/INFO (INAM/IART/ISRC/ICMT) truoc data chunk, giu nguyen PCM bits; render_project(metadata=) + RenderRequest.metadata + cloud render task; ExportModal Title/Artist/ISRC; test_66 (178 passed)
This commit is contained in:
@@ -616,6 +616,8 @@ class RenderRequest(BaseModel):
|
||||
normalize: Optional[bool] = False
|
||||
normalize_target: Optional[str] = "-1dbtp"
|
||||
mono_check: Optional[bool] = False
|
||||
# Gap 13: BWF/INFO metadata (title/artist/isrc/comment/description/...)
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
|
||||
class FxGuiRequest(BaseModel):
|
||||
@@ -1212,6 +1214,7 @@ def render_project(
|
||||
dither=bool(req.dither),
|
||||
normalize=bool(req.normalize),
|
||||
normalize_target=req.normalize_target or "-1dbtp",
|
||||
metadata=req.metadata or None,
|
||||
)
|
||||
# EBU R128 loudness + true peak (D1) — trả kèm response để mastering
|
||||
# panel hiển thị LUFS/dBTP ngay sau export mà không cần đọc lại file.
|
||||
|
||||
@@ -546,6 +546,7 @@ async def cleanup_all_backups(req: CleanupBackupsRequest, current_user: dict = D
|
||||
|
||||
class RenderProjectRequest(BaseModel):
|
||||
sample_rate: Optional[int] = 44100
|
||||
metadata: Optional[dict] = None # Gap 13: BWF/INFO metadata (title/artist/isrc/...)
|
||||
|
||||
@router.post("/cloud/{project_id}/render")
|
||||
async def render_project_endpoint(project_id: str, req: RenderProjectRequest, current_user: dict = Depends(get_current_user)):
|
||||
@@ -568,7 +569,8 @@ async def render_project_endpoint(project_id: str, req: RenderProjectRequest, cu
|
||||
project_id=project_id,
|
||||
project_name=row["name"],
|
||||
project_json_str=row["data_json"],
|
||||
sample_rate=req.sample_rate or 44100
|
||||
sample_rate=req.sample_rate or 44100,
|
||||
metadata=req.metadata or None
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -839,7 +839,9 @@ class PythonRenderEngine:
|
||||
def render_project(self, project_json: dict, output_filepath: str,
|
||||
bit_depth: int = 32, out_sample_rate: int = 0,
|
||||
dither: bool = False, normalize: bool = False,
|
||||
normalize_target: str = "-1dbtp"):
|
||||
normalize_target: str = "-1dbtp",
|
||||
metadata: dict = None):
|
||||
# Gap 13: BWF/INFO metadata — tham số phụ, không đổi hành vi cũ khi None.
|
||||
with RENDER_LOCK:
|
||||
bpm = project_json["metadata"]["bpm"]
|
||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||
@@ -963,4 +965,12 @@ class PythonRenderEngine:
|
||||
out_sr, subtype="PCM_24")
|
||||
else:
|
||||
sf.write(output_filepath, master_buffer.T, out_sr, subtype="FLOAT")
|
||||
# Gap 13: BWF/INFO chunks (bext + LIST/INFO title/artist/ISRC) —
|
||||
# chen sau khi ghi PCM, loi metadata khong lam hong file audio.
|
||||
if metadata:
|
||||
try:
|
||||
from app.core.wav_bwf import patch_bwf_metadata
|
||||
patch_bwf_metadata(output_filepath, metadata)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] BWF metadata skipped: %s", e)
|
||||
return output_filepath
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Gap 13: BWF/INFO metadata chunks cho WAV export (EBU Tech 3285 + RIFF INFO).
|
||||
|
||||
sf.write chi ghi fmt/data — ham nay chen bext (602 bytes fixed + coding history)
|
||||
va LIST/INFO (INAM title, IART artist, ISRC, ICMT comment) ngay truoc data chunk
|
||||
roi ghi lai file. Pure stdlib (struct/datetime).
|
||||
"""
|
||||
import datetime
|
||||
import struct
|
||||
|
||||
_BEXT_FIXED = 602
|
||||
|
||||
|
||||
def _pad(text, length):
|
||||
raw = text.encode("utf-8", errors="replace")
|
||||
return (raw[:length] + b"\x00" * length)[:length]
|
||||
|
||||
|
||||
def _chunk(fourcc, payload):
|
||||
pad = b"\x00" if len(payload) % 2 else b""
|
||||
return fourcc + struct.pack("<I", len(payload)) + payload + pad
|
||||
|
||||
|
||||
def _info_sub(fourcc, text):
|
||||
if not text:
|
||||
return b""
|
||||
return _chunk(fourcc, _pad(text, len(text.encode("utf-8", errors="replace")) + 1))
|
||||
|
||||
|
||||
def _bext(md, now):
|
||||
fixed = bytearray(_BEXT_FIXED)
|
||||
fixed[0:256] = _pad(md.get("description", ""), 256)
|
||||
fixed[256:288] = _pad(md.get("originator", "SonicForgeStudio"), 32)
|
||||
fixed[288:320] = _pad(md.get("originator_reference", ""), 32)
|
||||
fixed[320:330] = _pad((md.get("date") or now.strftime("%Y-%m-%d")), 10)
|
||||
fixed[330:338] = _pad((md.get("time") or now.strftime("%H:%M:%S")), 8)
|
||||
struct.pack_into("<II", fixed, 338, 0, 0) # time reference low/high
|
||||
struct.pack_into("<H", fixed, 346, 1) # version = 1
|
||||
fixed[348:412] = bytes(64) # UMID (zero)
|
||||
struct.pack_into("<hhhhh", fixed, 412, -32768, -32768, -32768, -32768, -32768) # loudness: 0x8000 = unset
|
||||
fixed[422:602] = bytes(180) # reserved
|
||||
coding = md.get("coding_history", "").encode("utf-8", errors="replace")
|
||||
return bytes(fixed) + coding
|
||||
|
||||
|
||||
def patch_bwf_metadata(path, metadata=None):
|
||||
"""Chen bext + LIST/INFO vao WAV tai path (rewrite in-place, giu nguyen PCM bits).
|
||||
|
||||
metadata keys: title, artist, isrc, comment, description, originator,
|
||||
originator_reference, date (YYYY-MM-DD), time (HH:MM:SS), coding_history.
|
||||
Bo qua neu file khong phai RIFF/WAVE hop le hoac metadata rong.
|
||||
"""
|
||||
if not metadata:
|
||||
return
|
||||
md = {k: (v or "").strip() for k, v in metadata.items() if v}
|
||||
if not md:
|
||||
return
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WAVE":
|
||||
return
|
||||
|
||||
off = 12
|
||||
pre = b""
|
||||
data_chunk = None
|
||||
while off + 8 <= len(data):
|
||||
cid = data[off:off + 4]
|
||||
size = struct.unpack("<I", data[off + 4:off + 8])[0]
|
||||
body = data[off + 8:off + 8 + size]
|
||||
if cid == b"data":
|
||||
data_chunk = (cid, size, body)
|
||||
break
|
||||
pre += _chunk(cid, body)
|
||||
off += 8 + size + (size % 2)
|
||||
if data_chunk is None:
|
||||
return
|
||||
|
||||
now = datetime.datetime.now()
|
||||
info_payload = b"INFO" + b"".join([
|
||||
_info_sub(b"INAM", md.get("title", "")),
|
||||
_info_sub(b"IART", md.get("artist", "")),
|
||||
_info_sub(b"ISRC", md.get("isrc", "")),
|
||||
_info_sub(b"ICMT", md.get("comment", "")),
|
||||
])
|
||||
extra = _chunk(b"bext", _bext(md, now)) + _chunk(b"LIST", info_payload)
|
||||
new_data = data[:12] + pre + extra + _chunk(data_chunk[0], data_chunk[2])
|
||||
new_data = new_data[:4] + struct.pack("<I", len(new_data) - 8) + new_data[8:]
|
||||
with open(path, "wb") as f:
|
||||
f.write(new_data)
|
||||
+31
-2
@@ -12361,6 +12361,26 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 pt-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5">
|
||||
<div>
|
||||
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Title (BWF/INFO)</label>
|
||||
<input type="text" value={exportSettings.metaTitle} maxLength="254"
|
||||
onChange={e => setExportSettings(p => ({ ...p, metaTitle: e.target.value }))}
|
||||
placeholder="Tên bài" className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Artist</label>
|
||||
<input type="text" value={exportSettings.metaArtist} maxLength="254"
|
||||
onChange={e => setExportSettings(p => ({ ...p, metaArtist: e.target.value }))}
|
||||
placeholder="Nghệ sĩ" className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">ISRC</label>
|
||||
<input type="text" value={exportSettings.metaIsrc} maxLength="12"
|
||||
onChange={e => setExportSettings(p => ({ ...p, metaIsrc: e.target.value.toUpperCase() }))}
|
||||
placeholder="US-ABC-26-00001" className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||
<button onClick={onBounce} disabled={isExporting}
|
||||
title="Bounce realtime — file WAV đầy đủ MIDI + FX Rack + Mastering Chain (chạy lại project thật)"
|
||||
@@ -18141,7 +18161,11 @@ const App = () => {
|
||||
dither: false,
|
||||
normalize: false,
|
||||
normalizeTarget: '-1dbtp',
|
||||
monoCheck: true
|
||||
monoCheck: true,
|
||||
// Gap 13: BWF/INFO metadata cho WAV mastering export
|
||||
metaTitle: '',
|
||||
metaArtist: '',
|
||||
metaIsrc: ''
|
||||
});
|
||||
const [serverStatus, setServerStatus] = useState('checking...');
|
||||
const _lan = window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.lan;
|
||||
@@ -27715,7 +27739,12 @@ const App = () => {
|
||||
dither: !!exportSettings.dither,
|
||||
normalize: !!exportSettings.normalize,
|
||||
normalize_target: exportSettings.normalizeTarget || '-1dbtp',
|
||||
mono_check: !!exportSettings.monoCheck
|
||||
mono_check: !!exportSettings.monoCheck,
|
||||
metadata: {
|
||||
title: exportSettings.metaTitle || '',
|
||||
artist: exportSettings.metaArtist || '',
|
||||
isrc: exportSettings.metaIsrc || ''
|
||||
}
|
||||
});
|
||||
_lastMasterStats = { lufs: res.lufs, true_peak_db: res.true_peak_db };
|
||||
const lufsTxt = (res.lufs !== null && res.lufs !== undefined) ? res.lufs + ' LUFS' : '-- LUFS';
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -430,7 +430,7 @@ def cleanup_expired_files_task(max_age_hours: int = 24):
|
||||
|
||||
|
||||
@_task
|
||||
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100):
|
||||
def render_project_task(project_id: str, project_name: str, project_json_str: str, sample_rate: int = 44100, metadata: dict = None):
|
||||
"""
|
||||
Task Celery để kết xuất dự án ngoại tuyến (Offline Project Mixdown) áp dụng specs 30_DAW_ARCHITECT.md.
|
||||
"""
|
||||
@@ -442,7 +442,7 @@ def render_project_task(project_id: str, project_name: str, project_json_str: st
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, output_filename)
|
||||
|
||||
engine = PythonRenderEngine(sample_rate=sample_rate)
|
||||
engine.render_project(project_json, output_path)
|
||||
engine.render_project(project_json, output_path, metadata=metadata or None)
|
||||
|
||||
return {
|
||||
"project_id": project_id,
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/services/chordTheory.js?v=202608211300"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212325" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212340" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -857,3 +857,82 @@ def test_65_master_volume_automation(monkeypatch, tmp_path, fx_bridge):
|
||||
assert env0 == pytest.approx(base0, rel=0.05), f"đoạn 0dB phải giữ nguyên: {env0} vs {base0}"
|
||||
ratio = env1 / max(env0, 1e-9)
|
||||
assert 0.20 < ratio < 0.30, f"envelope -12dB → gain ~0.25: {ratio}"
|
||||
|
||||
def test_66_bwf_metadata(monkeypatch, tmp_path):
|
||||
"""Gap 13: render_project(metadata=...) chen bext + LIST/INFO vao WAV —
|
||||
title/artist/isrc doc lai duoc, PCM bytes khong doi."""
|
||||
monkeypatch.delenv("SF_BRIDGE_PATH", raising=False)
|
||||
from app.core.render_engine import PythonRenderEngine
|
||||
engine = PythonRenderEngine()
|
||||
|
||||
def project():
|
||||
track = _midi_track()
|
||||
track["volume_db"] = -12.0
|
||||
return {
|
||||
"metadata": {"bpm": 120.0, "time_signature_numerator": 4},
|
||||
"main_session": {
|
||||
"length_bars": 1.0,
|
||||
"tracks": [track],
|
||||
"master": {"fx_chain": [], "bypass": False, "volume_db": 0.0},
|
||||
},
|
||||
"section_store": {},
|
||||
}
|
||||
|
||||
def chunks(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
assert data[:4] == b"RIFF" and data[8:12] == b"WAVE"
|
||||
out = {}
|
||||
off = 12
|
||||
while off + 8 <= len(data):
|
||||
cid = data[off:off + 4]
|
||||
size = int.from_bytes(data[off + 4:off + 8], "little")
|
||||
body = data[off + 8:off + 8 + size]
|
||||
if cid == b"data":
|
||||
out[cid] = body
|
||||
break
|
||||
out[cid] = body
|
||||
off += 8 + size + (size % 2)
|
||||
return out
|
||||
|
||||
out_plain = str(tmp_path / "plain.wav")
|
||||
out_meta = str(tmp_path / "meta.wav")
|
||||
engine.render_project(project(), out_plain)
|
||||
engine.render_project(project(), out_meta, metadata={
|
||||
"title": "Test Song",
|
||||
"artist": "Penguin",
|
||||
"isrc": "US-ABC-26-00001",
|
||||
"comment": "Gap 13 check",
|
||||
"description": "BWF description",
|
||||
"originator": "SonicForgeStudio",
|
||||
"originator_reference": "proj-123",
|
||||
})
|
||||
# Khong metadata → khong bext/LIST (backward compatible)
|
||||
cp = chunks(out_plain)
|
||||
assert b"bext" not in cp and b"LIST" not in cp
|
||||
|
||||
cm = chunks(out_meta)
|
||||
assert b"bext" in cm and b"LIST" in cm
|
||||
# bext: description (0:256) + originator (256:288) + originator_reference (288:320)
|
||||
bext = cm[b"bext"]
|
||||
assert bext[0:256].split(b"\x00")[0] == b"BWF description"
|
||||
assert bext[256:288].split(b"\x00")[0] == b"SonicForgeStudio"
|
||||
assert bext[288:320].split(b"\x00")[0] == b"proj-123"
|
||||
# INFO: INAM/IART/ISRC subchunks
|
||||
info = cm[b"LIST"]
|
||||
assert info[:4] == b"INFO"
|
||||
subs = {}
|
||||
off = 4
|
||||
while off + 8 <= len(info):
|
||||
sid = info[off:off + 4]
|
||||
size = int.from_bytes(info[off + 4:off + 8], "little")
|
||||
subs[sid] = info[off + 8:off + 8 + size].rstrip(b"\x00")
|
||||
off += 8 + size + (size % 2)
|
||||
assert subs[b"INAM"] == b"Test Song"
|
||||
assert subs[b"IART"] == b"Penguin"
|
||||
assert subs[b"ISRC"] == b"US-ABC-26-00001"
|
||||
# PCM giong nhau (metadata khong lam doi audio bits)
|
||||
d_plain, sr_plain = _read_wav(out_plain)
|
||||
d_meta, sr_meta = _read_wav(out_meta)
|
||||
assert sr_meta == sr_plain
|
||||
assert np.allclose(d_meta, d_plain, atol=0.0, rtol=0.0)
|
||||
|
||||
Reference in New Issue
Block a user