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"]