implemented 15,12,18 didn't check 18
This commit is contained in:
@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.audio_cleaner import clean_audio, detect_silence_ranges, is_deepfilter_available
|
||||
from services.audio_cleaner import clean_audio, detect_silence_ranges, is_deepfilter_available, normalize_audio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -164,3 +164,30 @@ async def get_waveform_audio(request: Request, path: str = Query(...)):
|
||||
)
|
||||
_waveform_cache[cache_key] = str(out_wav)
|
||||
return FileResponse(str(out_wav), media_type="audio/wav")
|
||||
|
||||
|
||||
class NormalizeRequest(BaseModel):
|
||||
input_path: str
|
||||
output_path: Optional[str] = None
|
||||
target_lufs: float = -14.0
|
||||
|
||||
|
||||
@router.post("/audio/normalize")
|
||||
async def normalize_audio_endpoint(req: NormalizeRequest):
|
||||
"""Normalize audio loudness to a target LUFS level using FFmpeg loudnorm."""
|
||||
if req.target_lufs < -70 or req.target_lufs > 0:
|
||||
raise HTTPException(status_code=400, detail="target_lufs must be between -70 and 0")
|
||||
try:
|
||||
output = normalize_audio(
|
||||
req.input_path,
|
||||
req.output_path or "",
|
||||
target_lufs=req.target_lufs,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"output_path": output,
|
||||
"target_lufs": req.target_lufs,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Audio normalization failed: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@ -158,3 +158,125 @@ def detect_silence_ranges(input_path: str, min_silence_ms: int, silence_db: floa
|
||||
silence_db,
|
||||
)
|
||||
return ranges
|
||||
|
||||
|
||||
def normalize_audio(
|
||||
input_path: str,
|
||||
output_path: str = "",
|
||||
target_lufs: float = -14.0,
|
||||
) -> str:
|
||||
"""
|
||||
Normalize audio loudness to a target LUFS level using FFmpeg's loudnorm filter.
|
||||
|
||||
Args:
|
||||
input_path: Path to the input audio/video file.
|
||||
output_path: Path for the normalized output. Auto-generated if empty.
|
||||
target_lufs: Target integrated loudness in LUFS.
|
||||
Common targets: -14 (YouTube), -16 (Spotify), -23 (broadcast).
|
||||
|
||||
Returns: path to the normalized audio file.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
inp = Path(input_path)
|
||||
if not output_path:
|
||||
output_path = str(inp.with_stem(inp.stem + "_normalized"))
|
||||
|
||||
# Two-pass loudnorm: first pass measures loudness, second pass applies correction.
|
||||
# First pass: measure only (print_format=json)
|
||||
measure_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af", f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5:print_format=json",
|
||||
"-f", "null",
|
||||
"-",
|
||||
]
|
||||
logger.info("Running loudnorm first pass (measurement): %s", " ".join(measure_cmd))
|
||||
measure_result = subprocess.run(measure_cmd, capture_output=True, text=True)
|
||||
|
||||
# Parse measured parameters from stderr (loudnorm outputs JSON to stderr)
|
||||
measured = _parse_loudnorm_measurement(measure_result.stderr)
|
||||
if not measured:
|
||||
logger.warning(
|
||||
"loudnorm measurement failed or produced no output; "
|
||||
"falling back to single-pass normalization"
|
||||
)
|
||||
# Single-pass fallback
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af", f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5",
|
||||
"-c:v", "copy",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Audio normalization failed: {result.stderr[-300:]}")
|
||||
logger.info("Single-pass normalized audio saved to %s", output_path)
|
||||
return output_path
|
||||
|
||||
# Second pass: apply normalization using measured values
|
||||
input_i = measured.get("input_i", target_lufs)
|
||||
input_lra = measured.get("input_lra", 7.0)
|
||||
input_tp = measured.get("input_tp", -1.5)
|
||||
input_thresh = measured.get("input_thresh", -30.0)
|
||||
offset = measured.get("target_offset", 0.0)
|
||||
|
||||
apply_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(inp),
|
||||
"-af",
|
||||
(
|
||||
f"loudnorm=I={target_lufs}:LRA=7:TP=-1.5:"
|
||||
f"measured_I={input_i}:measured_LRA={input_lra}:"
|
||||
f"measured_TP={input_tp}:measured_thresh={input_thresh}:"
|
||||
f"offset={offset}:linear=true:print_format=summary"
|
||||
),
|
||||
"-c:v", "copy",
|
||||
output_path,
|
||||
]
|
||||
logger.info("Running loudnorm second pass (apply): %s", " ".join(apply_cmd))
|
||||
result = subprocess.run(apply_cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Audio normalization (apply) failed: {result.stderr[-300:]}")
|
||||
|
||||
logger.info(
|
||||
"Normalized audio saved to %s (target=%s LUFS, measured_I=%s)",
|
||||
output_path,
|
||||
target_lufs,
|
||||
input_i,
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def _parse_loudnorm_measurement(stderr_output: str) -> dict:
|
||||
"""Parse loudnorm JSON measurement output from FFmpeg stderr."""
|
||||
import json
|
||||
|
||||
# loudnorm prints JSON block between "Parsed_loudnorm" lines
|
||||
lines = stderr_output.split("\n")
|
||||
json_lines = []
|
||||
in_json = False
|
||||
for line in lines:
|
||||
if "Parsed_loudnorm" in line and "}" in line:
|
||||
# Single-line JSON
|
||||
try:
|
||||
start = line.index("{")
|
||||
end = line.rindex("}") + 1
|
||||
return json.loads(line[start:end])
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
continue
|
||||
if "{" in line and not in_json:
|
||||
in_json = True
|
||||
if in_json:
|
||||
json_lines.append(line)
|
||||
if in_json and "}" in line:
|
||||
in_json = False
|
||||
break
|
||||
|
||||
if json_lines:
|
||||
try:
|
||||
return json.loads("".join(json_lines))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user