Gap 11: A/B reference track — /reference-analyze LUFS/dBTP/centroid + RefCompareModal A/B listen (5 test, 174 passed)
This commit is contained in:
@@ -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
@@ -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)} (+ = 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
@@ -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 }) }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user