basyx commited on
Commit
d2e58d8
·
verified ·
1 Parent(s): d772075

Add additive audio mixing for video SFX

Browse files
Files changed (1) hide show
  1. app/operations/mix_audio.py +100 -0
app/operations/mix_audio.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from app.core.exceptions import InputError
9
+ from app.models.media import InputMedia, OperationResult
10
+ from app.operations.common import execute, output_path, require_inputs
11
+ from app.services.ffmpeg_service import FFmpegService
12
+ from app.services.validator import bounded_number
13
+
14
+
15
+ def _track_params(params: dict[str, Any], count: int) -> list[dict[str, float]]:
16
+ raw = params.get("audio_tracks")
17
+ if raw in (None, ""):
18
+ return [
19
+ {
20
+ "start": bounded_number(params, "start", 0, 0, 86_400),
21
+ "volume": bounded_number(params, "volume", 1, 0, 10),
22
+ }
23
+ for _ in range(count)
24
+ ]
25
+
26
+ if isinstance(raw, str):
27
+ try:
28
+ raw = json.loads(raw)
29
+ except json.JSONDecodeError as exc:
30
+ raise InputError("audio_tracks must be valid JSON") from exc
31
+
32
+ if not isinstance(raw, list) or len(raw) != count:
33
+ raise InputError("audio_tracks must contain one object for each additional audio input")
34
+
35
+ tracks: list[dict[str, float]] = []
36
+ for index, item in enumerate(raw, start=1):
37
+ if not isinstance(item, dict):
38
+ raise InputError(f"audio_tracks[{index - 1}] must be an object")
39
+ try:
40
+ start = bounded_number(item, "start", 0, 0, 86_400)
41
+ volume = bounded_number(item, "volume", 1, 0, 10)
42
+ except InputError as exc:
43
+ raise InputError(f"Invalid audio_tracks[{index - 1}] configuration") from exc
44
+ tracks.append({"start": start, "volume": volume})
45
+ return tracks
46
+
47
+
48
+ async def add_audio(
49
+ ffmpeg: FFmpegService,
50
+ inputs: Sequence[InputMedia],
51
+ params: dict[str, Any],
52
+ output_dir: Path,
53
+ ) -> OperationResult:
54
+ require_inputs(inputs, 2)
55
+ tracks = _track_params(params, len(inputs) - 1)
56
+ output = output_path(output_dir, "audio-mix", "mp4")
57
+
58
+ filters: list[str] = []
59
+ mix_inputs = ["[0:a]"]
60
+ for offset, track in enumerate(tracks, start=1):
61
+ delay_ms = round(track["start"] * 1000)
62
+ filters.append(
63
+ f"[{offset}:a]volume={track['volume']:.6g},adelay={delay_ms}:all=1[a{offset}]"
64
+ )
65
+ mix_inputs.append(f"[a{offset}]")
66
+
67
+ filters.append(
68
+ "".join(mix_inputs)
69
+ + f"amix=inputs={len(mix_inputs)}:duration=first:dropout_transition=0:normalize=0[mixed]"
70
+ )
71
+
72
+ args: list[str | Path] = []
73
+ for media in inputs:
74
+ args.extend(["-i", media.temp_path])
75
+ args.extend(
76
+ [
77
+ "-filter_complex",
78
+ ";".join(filters),
79
+ "-map",
80
+ "0:v:0",
81
+ "-map",
82
+ "[mixed]",
83
+ "-c:v",
84
+ "copy",
85
+ "-c:a",
86
+ "aac",
87
+ "-b:a",
88
+ str(params.get("audio_bitrate", "192k")),
89
+ "-movflags",
90
+ "+faststart",
91
+ output,
92
+ ]
93
+ )
94
+ return await execute(
95
+ ffmpeg,
96
+ args,
97
+ output,
98
+ "video.add_audio",
99
+ {"audio_tracks": tracks, "preserve_video": True, "mix_mode": "additive"},
100
+ )