Gap 11: A/B reference track — /reference-analyze LUFS/dBTP/centroid + RefCompareModal A/B listen (5 test, 174 passed)

This commit is contained in:
2026-08-23 08:34:09 +07:00
parent 3ad379f0ba
commit db12698ba2
6 changed files with 336 additions and 8 deletions
+84
View File
@@ -485,6 +485,90 @@ async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_
user_files.sort(key=lambda x: x["created_at"], reverse=True)
return user_files
class ReferenceAnalyzeRequest(BaseModel):
path: Optional[str] = None # local path (media/browse server mode)
file_id: Optional[str] = None # uploaded/processed file id
def _spectral_centroid_hz(audio, sr):
"""Tone proxy: mean spectral centroid (Hz) over 1 s windows.
Mono downmix + decimate to ~8 kHz (bounded cost). 0.0 for silence."""
import numpy as np
from scipy.signal import resample_poly
x = audio
if x.ndim > 1:
x = np.mean(x, axis=0)
if x.size == 0 or not np.any(x):
return 0.0
g = max(1, int(round(sr / 8000.0)))
x = resample_poly(x, 1, g)
sr8 = float(sr) / g
win = int(round(1.0 * sr8))
if win < 64 or x.size < win:
return 0.0
hann = np.hanning(win)
freqs = np.fft.rfftfreq(win, 1.0 / sr8)
cents = []
step = win // 2
for i in range(0, x.size - win, step):
mag = np.abs(np.fft.rfft(x[i:i + win] * hann))
msum = float(mag.sum())
if msum > 1e-9:
cents.append(float((freqs * mag).sum() / msum))
if not cents:
return 0.0
return float(np.mean(cents))
@router.post("/reference-analyze")
async def analyze_reference(req: ReferenceAnalyzeRequest, current_user: Optional[dict] = Depends(get_optional_user)):
"""Gap 11: A/B reference track - analyze LUFS/dBTP/centroid of a
reference file (local path from media/browse, or uploaded file_id),
return compare metrics + playback url for the A/B toggle."""
import numpy as np
import soundfile as sf
from urllib.parse import quote
from app.core import loudness
path = ""
url = ""
if req.file_id:
path = _resolve_storage_path(req.file_id)
if not path:
raise HTTPException(status_code=404, detail="File not found")
url = "/api/v1/audio/download/%s" % _safe_file_id(req.file_id)
elif req.path:
path = os.path.normpath(req.path)
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Khong tim thay file")
ext = os.path.splitext(path)[1].lower().lstrip(".")
if ext not in ALLOWED_AUDIO_EXTENSIONS:
raise HTTPException(status_code=400, detail="Dinh dang khong ho tro: %s" % ext)
url = "/api/v1/media/file?path=" + quote(path)
else:
raise HTTPException(status_code=400, detail="Can path hoac file_id")
try:
info = sf.info(path)
if info.samplerate and info.frames / info.samplerate > 900:
raise HTTPException(status_code=413, detail="File qua dai (>15 phut)")
data, sr = sf.read(path, dtype="float32", always_2d=True)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail="Khong doc duoc audio: %s" % e)
audio = np.asarray(data.T, dtype=np.float64) # (ch, n)
lufs = loudness.integrated_lufs(audio, sr)
tp = loudness.true_peak_db(audio, sr)
centroid = await asyncio.to_thread(_spectral_centroid_hz, audio, sr)
dur = float(info.frames / max(1, info.samplerate or 1))
return {
"filename": os.path.basename(path),
"duration": round(dur, 2),
"sample_rate": int(info.samplerate or 0),
"channels": int(info.channels or 0),
"lufs": round(lufs, 2) if np.isfinite(lufs) else None,
"true_peak_db": round(tp, 2) if np.isfinite(tp) else None,
"centroid_hz": round(centroid, 0) if centroid > 0 else None,
"url": url,
}
@router.delete("/my-files/{file_id}")
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
+151 -1
View File
@@ -545,6 +545,9 @@ const prerenderNativeTracks = (tracks, bpm) => {
let serverFileIdMap = {};
let audioCtx;
let masterBus = null; // { input, compressor, analyser, output, masteringActive, dryInput, dryOutput }
// Gap 11: last offline master render stats (LUFS/dBTP) cho A/B reference compare
let _lastMasterStats = null;
window.__getLastMasterStats = () => _lastMasterStats;
// Per-track mastering-bypass state (trackId -> bool), kept in sync with the
// tracks state so ANY audio path can route without holding the track object.
@@ -2372,6 +2375,143 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
}, track.name));
};
// ── Gap 11: A/B Reference Compare (so LUFS/dBTP/tone voi track tham chieu) ──
const RefCompareModal = ({ open, onClose }) => {
const [path, setPath] = React.useState('');
const [refStats, setRefStats] = React.useState(null);
const [masterStats, setMasterStats] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [ab, setAb] = React.useState('A');
const audioRef = React.useRef(null);
const fileInputRef = React.useRef(null);
const stopRef = () => {
if (audioRef.current) {
try { audioRef.current.pause(); } catch (e) {}
if (audioRef.current._objUrl) { try { URL.revokeObjectURL(audioRef.current._objUrl); } catch (e) {} }
audioRef.current = null;
}
};
React.useEffect(() => () => stopRef(), []);
React.useEffect(() => {
if (open) {
setMasterStats((window.__getLastMasterStats && window.__getLastMasterStats()) || null);
setRefStats(null);
setAb('A');
stopRef();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const analyze = async (src) => {
setBusy(true);
try {
const res = await window.SonicAPI.referenceAnalyze(src);
setRefStats(res);
setMasterStats((window.__getLastMasterStats && window.__getLastMasterStats()) || null);
} catch (e) {
window.showToast && window.showToast('Analyze loi: ' + (e.message || e), 'error');
} finally {
setBusy(false);
}
};
const onPickFile = (f) => {
if (!f) return;
const fd = new FormData();
fd.append('file', f);
setBusy(true);
const token = localStorage.getItem('sonic_token') || '';
fetch((window.API_BASE_URL || '') + '/api/v1/audio/upload', { method: 'POST', headers: token ? { 'Authorization': 'Bearer ' + token } : {}, body: fd })
.then(r => r.json().then(d => ({ ok: r.ok, d })))
.then(({ ok, d }) => {
if (!ok) throw new Error(d.detail || 'Upload loi');
return analyze({ file_id: d.file_id });
})
.catch(e => { window.showToast && window.showToast('Upload loi: ' + (e.message || e), 'error'); setBusy(false); });
};
const playB = async () => {
if (!refStats) return;
stopRef();
try {
// media/file va download deu can token -> fetch (Authorization header)
// roi play qua blob URL (Audio element khong gui duoc header).
const token = localStorage.getItem('sonic_token') || '';
const resp = await fetch((window.API_BASE_URL || '') + refStats.url, { headers: token ? { 'Authorization': 'Bearer ' + token } : {} });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const blob = await resp.blob();
const objUrl = URL.createObjectURL(blob);
const au = new Audio(objUrl);
au._objUrl = objUrl;
audioRef.current = au;
au.loop = true;
au.play().then(() => setAb('B')).catch(() => {});
} catch (e) {
window.showToast && window.showToast('Play ref loi: ' + (e.message || e), 'error');
}
};
const fmt = (v, suf) => (v === null || v === undefined) ? '--' : v + suf;
const dlt = (a, b) => (a === null || a === undefined || b === null || b === undefined) ? '--' : ((a - b) > 0 ? '+' : '') + (a - b).toFixed(1);
return (
<div className="fixed inset-0 z-[115] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
<div className="w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl p-4 space-y-3 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
<span className="text-xs font-bold text-white tracking-wider">A/B REFERENCE</span>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><i data-lucide="x" className="w-4 h-4"></i></button>
</div>
<div className="flex gap-1.5">
<input value={path} onChange={e => setPath(e.target.value)} placeholder="Local path (media/browse) hoac file_id"
className="flex-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none" />
<button onClick={() => path && analyze({ path })} disabled={busy}
className="px-2 py-1 bg-cyan-700 hover:bg-cyan-600 text-white rounded text-xs font-bold">{busy ? '...' : 'Analyze'}</button>
</div>
<div className="text-[10px] text-slate-500 text-center">- hoac -</div>
<button onClick={() => fileInputRef.current && fileInputRef.current.click()}
className="w-full py-1.5 bg-slate-800 hover:bg-slate-700 text-zinc-200 rounded text-xs font-bold border border-slate-700">Upload reference file</button>
<input ref={fileInputRef} type="file" accept="audio/*,.wav,.mp3,.flac,.ogg,.aiff,.m4a,.aac,.opus" className="hidden"
onChange={e => { const f = e.target.files && e.target.files[0]; onPickFile(f); e.target.value = ''; }} />
{(refStats || masterStats) && (
<div className="grid grid-cols-3 gap-1.5 text-[10px] text-center">
<div className="bg-slate-950 border border-slate-800 rounded p-1.5">
<div className="text-slate-500 font-bold mb-1">METER</div>
<div className="text-emerald-400 font-bold">LUFS</div>
<div className="text-slate-300 font-bold">dBTP</div>
<div className="text-slate-400">Tone</div>
</div>
<div className="bg-slate-950 border border-slate-800 rounded p-1.5">
<div className="text-slate-500 font-bold mb-1">MASTER</div>
<div className="text-cyan-300 font-bold">{fmt(masterStats && masterStats.lufs, '')}</div>
<div className="text-cyan-300 font-bold">{fmt(masterStats && masterStats.true_peak_db, '')}</div>
<div className="text-slate-400">-</div>
</div>
<div className="bg-slate-950 border border-slate-800 rounded p-1.5">
<div className="text-slate-500 font-bold mb-1">REF</div>
<div className="text-fuchsia-300 font-bold">{fmt(refStats && refStats.lufs, '')}</div>
<div className="text-fuchsia-300 font-bold">{fmt(refStats && refStats.true_peak_db, '')}</div>
<div className="text-slate-400">{fmt(refStats && refStats.centroid_hz, 'Hz')}</div>
</div>
</div>
)}
{refStats && (
<div className="text-[10px] text-slate-400 text-center">
Delta: LUFS {dlt(masterStats && masterStats.lufs, refStats.lufs)} / dBTP {dlt(masterStats && masterStats.true_peak_db, refStats.true_peak_db)} &nbsp;(+ = master louder)
<div className="text-[9px] text-slate-500">{refStats.filename} · {refStats.duration}s · {refStats.sample_rate}Hz · {refStats.channels}ch</div>
</div>
)}
<div className="grid grid-cols-2 gap-1.5 pt-1">
<button onClick={() => { stopRef(); setAb('A'); }}
className={`py-1.5 rounded text-[10px] font-bold border ${ab === 'A' ? 'bg-cyan-600 border-cyan-500 text-white' : 'bg-slate-800 text-slate-300 border-slate-700'}`}>A · Master</button>
<button onClick={playB} disabled={!refStats}
className={`py-1.5 rounded text-[10px] font-bold border ${ab === 'B' ? 'bg-fuchsia-600 border-fuchsia-500 text-white' : 'bg-slate-800 text-slate-300 border-slate-700'}`}>B · Reference</button>
</div>
</div>
</div>
);
};
// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ──
const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMasterVolL, masterVolR, setMasterVolR, masterLink, setMasterLink, masterMuted, setMasterMuted, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => {
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
@@ -2388,6 +2528,7 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
const isMasterActive = masteringSettings ? masteringSettings.masterConnected : false;
const panStartYRef = React.useRef(0);
const startPanValRef = React.useRef(0);
const [refOpen, setRefOpen] = React.useState(false);
const ensureAudio = () => {
getAudioContext();
@@ -2500,7 +2641,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
};
}, [masterVolL, masterVolR, masterMuted, isMono]);
return React.createElement("div", {
return React.createElement(React.Fragment, null,
React.createElement("div", {
className: "flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"
},
React.createElement("div", { className: "space-y-1.5 mb-2" },
@@ -2508,6 +2650,10 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
onClick: () => setShowMasteringModal(true),
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"
}, "FX CHAIN"),
React.createElement("button", {
onClick: () => setRefOpen(true),
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-cyan-300 tracking-tight"
}, "A/B REF"),
React.createElement("div", {
className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"
},
@@ -2696,6 +2842,8 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
React.createElement("span", null, "MAIN OUT")
)
)
),
refOpen && React.createElement(RefCompareModal, { open: refOpen, onClose: function() { setRefOpen(false); } })
);
};
@@ -18680,6 +18828,7 @@ const App = () => {
const schema = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
try {
const res = await window.SonicAPI.renderProject(schema, 'preview_' + Date.now() + '.wav');
_lastMasterStats = { lufs: res.lufs, true_peak_db: res.true_peak_db };
return res;
} catch (e) {
window.showToast && window.showToast('Render preview lỗi: ' + (e.message || e), 'error');
@@ -27425,6 +27574,7 @@ const App = () => {
normalize_target: exportSettings.normalizeTarget || '-1dbtp',
mono_check: !!exportSettings.monoCheck
});
_lastMasterStats = { lufs: res.lufs, true_peak_db: res.true_peak_db };
const lufsTxt = (res.lufs !== null && res.lufs !== undefined) ? res.lufs + ' LUFS' : '-- LUFS';
const tpTxt = (res.true_peak_db !== null && res.true_peak_db !== undefined) ? res.true_peak_db + ' dBTP' : '-- dBTP';
showToast(`Mastering xong — ${lufsTxt} / ${tpTxt}${res.mono_warning ? ' • ' + res.mono_warning : ''}`, 'success', 'Tải về', async () => {
File diff suppressed because one or more lines are too long
+2
View File
@@ -59,6 +59,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }),
aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }),
runPythonTool: (toolType, trackId, fileId, timePos = 0.0, freq = 440.0, duration = 2.0, waveType = "sine") => apiRequest('/api/v1/audio/python-tool', { method: 'POST', body: JSON.stringify({ tool_type: toolType, track_id: trackId, file_id: fileId, time_pos: timePos, freq: freq, duration: duration, wave_type: waveType }) }),
// Gap 11: A/B reference track — analyze LUFS/dBTP/centroid (path hoac file_id)
referenceAnalyze: (payload) => apiRequest('/api/v1/audio/reference-analyze', { method: 'POST', body: JSON.stringify(payload) }),
getAIConfigs: () => apiRequest('/api/v1/user/config/ai', { method: 'GET' }),
saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }),
+1 -1
View File
@@ -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=202608212230" defer></script>
<script src="/static/js/app.precompiled.js?v=202608212300" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""Gap 11: A/B reference analyze endpoint (POST /api/v1/audio/reference-analyze).
- path mode: local path (media/browse server mode) -> LUFS/dBTP/centroid + url
- file_id mode: uploaded/processed file
"""
import os
import tempfile
import numpy as np
import soundfile as sf
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def _write_ref_wav(path, dbfs=-20.0, freq=1000.0, sec=2.0, sr=44100):
t = np.arange(int(sr * sec)) / sr
x = (10 ** (dbfs / 20.0)) * np.sin(2 * np.pi * freq * t)
sf.write(path, np.stack([x, x]).T, sr, subtype="FLOAT")
def test_reference_analyze_path():
tmp = os.path.join(tempfile.gettempdir(), "ref_ab_test.wav")
_write_ref_wav(tmp)
r = client.post("/api/v1/audio/reference-analyze", json={"path": tmp})
assert r.status_code == 200, r.text
d = r.json()
assert d["filename"].endswith(".wav")
assert d["sample_rate"] == 44100
assert d["channels"] == 2
assert abs(d["duration"] - 2.0) < 0.05, d
# 1 kHz sine @ -20 dBFS -> ~ -23 LUFS (cung ly luan test_loudness)
assert -25.0 < d["lufs"] < -21.0, d
assert abs(d["true_peak_db"] - (-20.0)) < 0.3, d
# spectral centroid cua sine 1 kHz ~ 1000 Hz
assert 850 < d["centroid_hz"] < 1150, d
assert d["url"].startswith("/api/v1/media/file")
def test_reference_analyze_file_id():
# upload trigger celery analyze_audio_task.delay — stub để test không cần Redis
import types
import app.tasks.worker as worker
_orig = worker.analyze_audio_task
worker.analyze_audio_task = types.SimpleNamespace(delay=lambda file_id: types.SimpleNamespace(id="stub"))
try:
tmp = os.path.join(tempfile.gettempdir(), "ref_ab_upload.wav")
_write_ref_wav(tmp, dbfs=-12.0, freq=440.0)
with open(tmp, "rb") as f:
up = client.post("/api/v1/audio/upload", files={"file": ("ref.wav", f, "audio/wav")})
finally:
worker.analyze_audio_task = _orig
assert up.status_code == 200, up.text
fid = up.json()["file_id"]
r = client.post("/api/v1/audio/reference-analyze", json={"file_id": fid})
assert r.status_code == 200, r.text
d = r.json()
assert d["url"].startswith("/api/v1/audio/download/")
assert -17.5 < d["lufs"] < -13.0, d
def test_reference_analyze_missing():
r = client.post("/api/v1/audio/reference-analyze", json={})
assert r.status_code == 400
def test_reference_analyze_bad_path():
r = client.post("/api/v1/audio/reference-analyze", json={"path": "Z:/no/such/file.wav"})
assert r.status_code == 404
def test_reference_analyze_bad_ext():
tmp = os.path.join(tempfile.gettempdir(), "ref_ab_bad.exe")
with open(tmp, "wb") as f:
f.write(b"MZ\x90\x00")
try:
r = client.post("/api/v1/audio/reference-analyze", json={"path": tmp})
assert r.status_code == 400, r.text
finally:
try:
os.remove(tmp)
except OSError:
pass