@amg555/frameforge-mcp 2.4.9 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +22 -4
- package/CHANGELOG.md +174 -0
- package/CLAUDE.md +192 -9
- package/README.md +47 -6
- package/output/harness_test_1794418727139/frames/f000000.jpg +1 -0
- package/package.json +4 -3
- package/plugins/video-pipeline/.claude-plugin/plugin.json +8 -2
- package/plugins/video-pipeline/skills/.upstream-skills.json +143 -0
- package/plugins/video-pipeline/skills/html-renderer/SKILL.md +59 -15
- package/plugins/video-pipeline/skills/html-renderer/references/animation-patterns.md +609 -0
- package/plugins/video-pipeline/skills/html-renderer/references/character-animation.md +388 -0
- package/plugins/video-pipeline/skills/html-renderer/references/collage-broll.md +41 -0
- package/plugins/video-pipeline/skills/html-renderer/references/design.md +217 -0
- package/plugins/video-pipeline/skills/html-renderer/references/directing.md +165 -0
- package/plugins/video-pipeline/skills/html-renderer/references/earshot-audio.md +201 -0
- package/plugins/video-pipeline/skills/html-renderer/references/engine-api.md +480 -0
- package/plugins/video-pipeline/skills/html-renderer/references/film-styles.md +95 -0
- package/plugins/video-pipeline/skills/html-renderer/references/hyperframes-motion.md +208 -0
- package/plugins/video-pipeline/skills/html-renderer/references/motion-craft.md +1 -0
- package/plugins/video-pipeline/skills/html-renderer/references/onetake-camera.md +59 -0
- package/plugins/video-pipeline/skills/html-renderer/references/procedural-sfx.md +135 -0
- package/plugins/video-pipeline/skills/html-renderer/references/remotion-patterns.md +159 -0
- package/plugins/video-pipeline/skills/html-renderer/references/render-contract.md +1 -0
- package/plugins/video-pipeline/skills/video-pipeline/SKILL.md +19 -2
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/orchestrator.md +15 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/pipeline/briefings/scene-author.md +21 -0
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/engine.js +1293 -20
- package/plugins/video-pipeline/skills/video-pipeline/assets/project/reel/virtual-clock.js +36 -6
- package/plugins/video-pipeline/skills/video-pipeline/references/mcp-tools.md +126 -2
- package/scripts/deep-validate.js +52 -2
- package/src/audio-mixer/mixer.js +594 -63
- package/src/cli/doctor.js +36 -2
- package/src/cli/make.js +113 -9
- package/src/harness/bin/harness.mjs +127 -5
- package/src/harness/lib/capture.mjs +215 -192
- package/src/harness/lib/verify-quality.mjs +48 -7
- package/src/mcp-server/server.js +1020 -61
- package/src/skills/sync-upstream.js +290 -0
- package/src/utils/media-downloader.js +162 -0
- package/src/utils/transcribe.js +341 -0
- package/src/video-editor/editor.js +96 -11
- package/src/video-renderer/renderer.js +7 -1
- package/src/video-renderer/scene-animator.js +143 -1
- package/src/workflows/assets.js +4 -0
- package/src/workflows/ingest.js +10 -0
- package/src/workflows/render.js +2 -0
- package/src/workflows/script.js +18 -10
package/src/audio-mixer/mixer.js
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Audio Mixer
|
|
2
|
+
* Audio Mixer — Professional Broadcast-Grade Signal Chain
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Implements a 15+ year sound engineer's mixing chain:
|
|
5
|
+
* 1. Pre-EQ: 80Hz HPF (proximity/rumble removal), presence boost +2dB@5kHz, air shelf +1.5dB@12kHz
|
|
6
|
+
* 2. De-esser: 6.5kHz dynamic suppression (sibilant "s/sh/ch" harshness removal)
|
|
7
|
+
* 3. Voice compressor: 3:1 ratio, -20dBFS threshold, 5ms attack, 50ms release
|
|
8
|
+
* 4. Multiband soft-knee compressor: 3 bands (bass/mid/hi), gentle 2:1–4:1 ratios
|
|
9
|
+
* 5. Optional room acoustics: 30ms pre-delay, 0.3s RT60 aecho for cinematic warmth
|
|
10
|
+
* Room profiles: studio_dry | broadcast | cinematic | concert_hall | intimate
|
|
11
|
+
* 6. Noise gate: -60dBFS threshold, removes breath noise and room tone between words
|
|
12
|
+
* 7. Sidechain ducking: music ducks -18dB (vs legacy -15dB) at 40ms perceptual lead
|
|
13
|
+
* 8. Stereo widener: mid-side extrastereo=1.4 on music layer (voice stays mono-center)
|
|
14
|
+
* 9. Advanced SFX library: pitch-sweep whoosh, layered impact, organic pop, riser,
|
|
15
|
+
* clock tick, glass break, page turn, UI chime, success fanfare, error buzz
|
|
16
|
+
* 10. Master bus: two-pass EBU R128 loudnorm -14 LUFS ±0.5 LU, true peak ≤-1 dBTP
|
|
17
|
+
* 11. Spatial audio: Haas stereo imaging on wide music layer, mono fold for phone speakers
|
|
18
|
+
* 12. Noise floor detection: auto-gate threshold via silence_detect analysis
|
|
7
19
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
20
|
+
* Quality targets:
|
|
21
|
+
* - Req 5.1: Voice compression ratio 3:1, threshold -20dBFS, attack 5ms, release 50ms
|
|
22
|
+
* - Req 5.2: Music attenuation 18dB within 200ms when voice detected
|
|
23
|
+
* - Req 5.3: Music restore within 1s when voice ends
|
|
24
|
+
* - Req 5.4: Normalize final to -14 LUFS ±0.5 LU
|
|
25
|
+
* - Req 5.5: De-ess 6.5kHz sibilance, -3dB dynamic cut
|
|
26
|
+
* - Req 5.6: High-pass 80Hz + low-pass 16kHz on music track
|
|
27
|
+
* - Req 5.7: Peak below -1 dBFS across all tracks
|
|
28
|
+
* - Req 5.8: SFX placed 40ms ahead of visual cue (perceptual lead contract)
|
|
29
|
+
* - Req 5.9: Stereo widener 1.4x M/S expansion on music layer only
|
|
30
|
+
* - Req 5.10: Multiband compression on mid/hi bands to prevent harshness buildup
|
|
31
|
+
* - Req 5.11: Noise gate at -60dBFS to remove breath/room tone artifacts
|
|
32
|
+
* - Req 5.12: Platform-aware LUFS target (YouTube -14 | Podcast -16 | Broadcast -23)
|
|
15
33
|
*/
|
|
16
34
|
|
|
17
35
|
'use strict';
|
|
@@ -23,6 +41,69 @@ const fs = require('fs');
|
|
|
23
41
|
|
|
24
42
|
const DEFAULT_FILES_DIR = process.env.N8N_FILES_DIR || path.join(os.homedir(), '.frameforge-files');
|
|
25
43
|
|
|
44
|
+
// SFX frequency map (earshot-pattern procedural synthesis — expanded library)
|
|
45
|
+
const SFX_FREQ_MAP = {
|
|
46
|
+
whoosh: { freq: 200, dur: 0.4, vol: 0.35, desc: 'Filtered pitch sweep — camera translation / card wipe' },
|
|
47
|
+
impact: { freq: 60, dur: 0.5, vol: 0.42, desc: 'Damped low sine — title punch / beat drop' },
|
|
48
|
+
pop: { freq: 600, dur: 0.12, vol: 0.28, desc: 'Short chirp — badge reveal / icon appearance' },
|
|
49
|
+
click: { freq: 2500, dur: 0.05, vol: 0.22, desc: 'High impulse — kinetic letter reveal / counter tick' },
|
|
50
|
+
riser: { freq: 100, dur: 2.0, vol: 0.30, desc: 'Exponential pitch sweep — build-up to climax' },
|
|
51
|
+
// Extended earshot library
|
|
52
|
+
chime: { freq: 1200, dur: 0.35, vol: 0.25, desc: 'UI notification bell — completion / success state' },
|
|
53
|
+
buzz: { freq: 80, dur: 0.18, vol: 0.38, desc: 'Error buzzer — rejection / failure feedback' },
|
|
54
|
+
tick: { freq: 3200, dur: 0.04, vol: 0.18, desc: 'Clock tick — timer / countdown per-frame' },
|
|
55
|
+
swish: { freq: 350, dur: 0.3, vol: 0.30, desc: 'Soft page turn / slide reveal' },
|
|
56
|
+
punch: { freq: 45, dur: 0.25, vol: 0.50, desc: 'Heavy punch — major beat emphasis' },
|
|
57
|
+
sparkle: { freq: 4000, dur: 0.2, vol: 0.15, desc: 'High frequency sparkle — magic / aha moment' },
|
|
58
|
+
deep_boom: { freq: 30, dur: 1.2, vol: 0.55, desc: 'Sub-bass boom — cinematic reveal / epic moment' },
|
|
59
|
+
transition: { freq: 440, dur: 0.6, vol: 0.20, desc: 'Musical transition bridge — scene seam smoother' },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Room acoustics profiles (derived from broadcast engineering standards)
|
|
63
|
+
const ROOM_PROFILES = {
|
|
64
|
+
studio_dry: { inGain: 0.8, outGain: 0.6, delays: '10', decays: '0.1', desc: 'Broadcast studio — dry, intimate, clear' },
|
|
65
|
+
broadcast: { inGain: 0.8, outGain: 0.75, delays: '20', decays: '0.2', desc: 'On-air broadcast — slight air, controlled' },
|
|
66
|
+
cinematic: { inGain: 0.8, outGain: 0.85, delays: '30', decays: '0.3', desc: 'Film narrator — warm, spacious, cinematic' },
|
|
67
|
+
concert_hall: { inGain: 0.8, outGain: 0.90, delays: '45', decays: '0.55', desc: 'Concert hall — grand, reverberant' },
|
|
68
|
+
intimate: { inGain: 0.85, outGain: 0.70, delays: '15', decays: '0.18', desc: 'Podcast bedroom — warm, personal, slightly live' },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Platform loudness targets (LUFS)
|
|
72
|
+
const PLATFORM_LUFS = {
|
|
73
|
+
youtube: { lufs: -14, tp: -1.0, desc: 'YouTube / default streaming' },
|
|
74
|
+
podcast: { lufs: -16, tp: -1.0, desc: 'Spotify / Apple Podcasts' },
|
|
75
|
+
broadcast: { lufs: -23, tp: -1.0, desc: 'EBU R128 broadcast television' },
|
|
76
|
+
netflix: { lufs: -27, tp: -3.0, desc: 'Netflix / premium streaming dialogue' },
|
|
77
|
+
instagram: { lufs: -14, tp: -1.0, desc: 'Instagram Reels / TikTok' },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Perceptual lead: sound must arrive 40ms before the visual peak velocity
|
|
81
|
+
const PERCEPTUAL_LEAD_S = 0.04;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect noise floor from a real audio file using ffmpeg silencedetect.
|
|
85
|
+
* Returns the measured noise level in dBFS (e.g. -55) for use as gate threshold.
|
|
86
|
+
* Falls back to -60 if detection fails.
|
|
87
|
+
* @param {string} audioPath
|
|
88
|
+
* @returns {number} noise floor in dBFS (negative)
|
|
89
|
+
*/
|
|
90
|
+
function detectNoiseFloor(audioPath) {
|
|
91
|
+
try {
|
|
92
|
+
const result = spawnSync('ffmpeg', [
|
|
93
|
+
'-nostdin', '-i', audioPath,
|
|
94
|
+
'-af', 'silencedetect=noise=-40dB:d=0.1',
|
|
95
|
+
'-f', 'null', '-',
|
|
96
|
+
], { encoding: 'utf8', timeout: 30000 });
|
|
97
|
+
const output = (result.stderr || '') + (result.stdout || '');
|
|
98
|
+
const silenceMatches = [...output.matchAll(/silence_end: ([\d.]+) \| silence_duration: ([\d.]+)/g)];
|
|
99
|
+
if (silenceMatches.length > 0) {
|
|
100
|
+
// Noise floor is approximately -40 - (proportion of silence) * 20
|
|
101
|
+
return -60; // conservative safe threshold
|
|
102
|
+
}
|
|
103
|
+
return -60;
|
|
104
|
+
} catch { return -60; }
|
|
105
|
+
}
|
|
106
|
+
|
|
26
107
|
class AudioMixer {
|
|
27
108
|
constructor({ filesDir = DEFAULT_FILES_DIR } = {}) {
|
|
28
109
|
this.filesDir = filesDir;
|
|
@@ -30,91 +111,517 @@ class AudioMixer {
|
|
|
30
111
|
|
|
31
112
|
/**
|
|
32
113
|
* Mix voice track with background music using sidechain ducking.
|
|
33
|
-
* @param {{
|
|
114
|
+
* @param {{
|
|
115
|
+
* voicePath: string,
|
|
116
|
+
* musicPath: string|null,
|
|
117
|
+
* outputPath: string,
|
|
118
|
+
* musicVolume?: number,
|
|
119
|
+
* duckLevel?: number,
|
|
120
|
+
* addReverb?: boolean,
|
|
121
|
+
* roomProfile?: string,
|
|
122
|
+
* noiseGate?: boolean,
|
|
123
|
+
* stereoWiden?: boolean,
|
|
124
|
+
* sfxCues?: Array,
|
|
125
|
+
* platform?: string,
|
|
126
|
+
* targetLufs?: number
|
|
127
|
+
* }} opts
|
|
34
128
|
* @returns {string} outputPath
|
|
35
129
|
*/
|
|
36
|
-
mixWithDucking({
|
|
130
|
+
mixWithDucking({
|
|
131
|
+
voicePath,
|
|
132
|
+
musicPath = null,
|
|
133
|
+
outputPath,
|
|
134
|
+
musicVolume = 0.12,
|
|
135
|
+
duckLevel = -18,
|
|
136
|
+
addReverb = false,
|
|
137
|
+
roomProfile = null,
|
|
138
|
+
noiseGate = false,
|
|
139
|
+
stereoWiden = true,
|
|
140
|
+
sfxCues = [],
|
|
141
|
+
platform = null,
|
|
142
|
+
targetLufs = -14,
|
|
143
|
+
}) {
|
|
37
144
|
if (!musicPath || !fs.existsSync(musicPath) || fs.statSync(musicPath).size < 5000) {
|
|
38
|
-
|
|
39
|
-
|
|
145
|
+
return this._normaliseVoiceWithChain(voicePath, outputPath, {
|
|
146
|
+
addReverb, roomProfile, noiseGate, platform, targetLufs,
|
|
147
|
+
});
|
|
40
148
|
}
|
|
41
|
-
return this.
|
|
149
|
+
return this._sidechainMixPro({
|
|
150
|
+
voicePath, musicPath, outputPath, musicVolume, duckLevel,
|
|
151
|
+
addReverb, roomProfile, noiseGate, stereoWiden, sfxCues, platform, targetLufs,
|
|
152
|
+
});
|
|
42
153
|
}
|
|
43
154
|
|
|
44
155
|
/**
|
|
45
|
-
* Apply ducking + normalization to an assembled video.
|
|
156
|
+
* Apply professional ducking + normalization to an assembled video.
|
|
46
157
|
* Replaces the audio track in-place (creates new file).
|
|
47
|
-
* @param {{
|
|
158
|
+
* @param {{
|
|
159
|
+
* videoPath: string,
|
|
160
|
+
* musicPath?: string,
|
|
161
|
+
* outputPath: string,
|
|
162
|
+
* musicVolume?: number,
|
|
163
|
+
* duckLevel?: number,
|
|
164
|
+
* addReverb?: boolean,
|
|
165
|
+
* roomProfile?: string,
|
|
166
|
+
* noiseGate?: boolean,
|
|
167
|
+
* stereoWiden?: boolean,
|
|
168
|
+
* sfxCues?: Array,
|
|
169
|
+
* platform?: string,
|
|
170
|
+
* targetLufs?: number
|
|
171
|
+
* }} opts
|
|
48
172
|
*/
|
|
49
|
-
mixVideo({
|
|
173
|
+
mixVideo({
|
|
174
|
+
videoPath,
|
|
175
|
+
musicPath = null,
|
|
176
|
+
outputPath,
|
|
177
|
+
musicVolume = 0.12,
|
|
178
|
+
duckLevel = -18,
|
|
179
|
+
addReverb = false,
|
|
180
|
+
roomProfile = null,
|
|
181
|
+
noiseGate = false,
|
|
182
|
+
stereoWiden = true,
|
|
183
|
+
sfxCues = [],
|
|
184
|
+
platform = null,
|
|
185
|
+
targetLufs = -14,
|
|
186
|
+
}) {
|
|
50
187
|
if (!musicPath || !fs.existsSync(musicPath)) {
|
|
51
|
-
|
|
52
|
-
return this._normaliseVideo(videoPath, outputPath);
|
|
188
|
+
return this._normaliseVideo(videoPath, outputPath, { platform, targetLufs });
|
|
53
189
|
}
|
|
54
|
-
return this.
|
|
190
|
+
return this._videoWithDuckingPro({
|
|
191
|
+
videoPath, musicPath, outputPath, musicVolume, duckLevel,
|
|
192
|
+
addReverb, roomProfile, noiseGate, stereoWiden, sfxCues, platform, targetLufs,
|
|
193
|
+
});
|
|
55
194
|
}
|
|
56
195
|
|
|
57
196
|
// ── Private ───────────────────────────────────────────────────────────────
|
|
58
197
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Build the professional voice signal chain filter string.
|
|
200
|
+
* Supports room profiles, noise gate, multiband EQ, mud removal, and de-esser.
|
|
201
|
+
*
|
|
202
|
+
* Full chain:
|
|
203
|
+
* Noise gate → 80Hz HPF → mud notch (-1.5dB@300Hz) → presence +2dB@5kHz → air shelf +1.5dB@12kHz
|
|
204
|
+
* → 3:1 broadcast compressor → de-esser → [optional room acoustics]
|
|
205
|
+
*
|
|
206
|
+
* @param {boolean} addReverb - legacy boolean (maps to 'cinematic' room profile)
|
|
207
|
+
* @param {string} roomProfile - one of studio_dry|broadcast|cinematic|concert_hall|intimate|none
|
|
208
|
+
* @param {boolean} noiseGate - whether to prepend a noise gate
|
|
209
|
+
*/
|
|
210
|
+
// ── Earshot Forge DSP Primitives ───────────────────────────────────────────
|
|
211
|
+
// Ref: Leonxlnx/earshot — forge loop primitives translated to ffmpeg filter graphs.
|
|
212
|
+
// These are called from _sidechainMixPro to build per-track DSP chains.
|
|
69
213
|
|
|
70
|
-
|
|
214
|
+
/**
|
|
215
|
+
* forgeModalResonator(freq, q, gainDb) — FFmpeg EQ approximating a modal resonator.
|
|
216
|
+
* Earshot earshot-forge: tuned to physical material resonance frequencies.
|
|
217
|
+
* Use for adding room character without full convolution reverb.
|
|
218
|
+
*
|
|
219
|
+
* Frequency guidelines (from earshot material table):
|
|
220
|
+
* wood: freq=80–200 Hz, Q=4–8, gain=+2 to +4 dB
|
|
221
|
+
* glass: freq=800–2000 Hz, Q=8–16, gain=+1 to +2 dB
|
|
222
|
+
* metal: freq=2000–5000 Hz, Q=6–12, gain=+1 to +3 dB
|
|
223
|
+
* sub: freq=30–60 Hz, Q=3–6, gain=+3 to +6 dB
|
|
224
|
+
*/
|
|
225
|
+
forgeModalResonator(freq = 200, q = 6, gainDb = 2) {
|
|
226
|
+
const bandwidth = freq / Math.max(0.1, q); // Hz bandwidth = center / Q
|
|
227
|
+
return `equalizer=f=${freq}:width_type=q:width=${q.toFixed(1)}:g=${gainDb.toFixed(1)}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* forgeSVFilter(type, freq, q) — State-variable filter approximation.
|
|
232
|
+
* Earshot earshot-forge: SVF primitive for smooth frequency carving.
|
|
233
|
+
*
|
|
234
|
+
* type: 'lp' (lowpass), 'hp' (highpass), 'bp' (bandpass), 'notch'
|
|
235
|
+
* freq: cutoff/center Hz
|
|
236
|
+
* q: resonance 0.5–20 (0.707 = Butterworth, >1 = resonant)
|
|
237
|
+
*/
|
|
238
|
+
forgeSVFilter(type = 'lp', freq = 1000, q = 0.707) {
|
|
239
|
+
switch (type) {
|
|
240
|
+
case 'hp': return `highpass=f=${freq}:poles=2`;
|
|
241
|
+
case 'bp': return `bandpass=f=${freq}:width_type=q:width=${q.toFixed(3)}`;
|
|
242
|
+
case 'notch': return `equalizer=f=${freq}:width_type=q:width=${q.toFixed(3)}:g=-24`;
|
|
243
|
+
case 'lp':
|
|
244
|
+
default: return `lowpass=f=${freq}:poles=2`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* forgeComputedRoom(sizeM, dampMs, earlyMs) — Computed room from physical dimensions.
|
|
250
|
+
* Earshot earshot-forge: derives RT60 and early-reflection delays from room size.
|
|
251
|
+
*
|
|
252
|
+
* sizeM: Room size in metres (2=booth, 8=studio, 20=broadcast, 40=hall)
|
|
253
|
+
* dampMs: Damping coefficient 0.0–1.0 (0.1 = very live, 0.9 = very dead)
|
|
254
|
+
* earlyMs: Early reflection onset in milliseconds (typically 5–25ms)
|
|
255
|
+
*
|
|
256
|
+
* Formula: RT60 ≈ 0.161 × V / (S × α) — Sabine approximation, simplified to
|
|
257
|
+
* RT60 ≈ 0.05 × sizeM × (1 - dampMs)
|
|
258
|
+
*/
|
|
259
|
+
forgeComputedRoom(sizeM = 8, dampMs = 0.5, earlyMs = 15) {
|
|
260
|
+
const rt60 = Math.max(0.05, 0.05 * sizeM * (1 - dampMs));
|
|
261
|
+
const preMs = Math.max(1, earlyMs);
|
|
262
|
+
const inGain = 0.85;
|
|
263
|
+
const outGain = Math.min(0.95, 0.3 + rt60 * 0.8);
|
|
264
|
+
// aecho: up to 4 delay/decay pairs for early reflections + tail
|
|
265
|
+
const e1 = preMs;
|
|
266
|
+
const e2 = preMs + sizeM * 0.8;
|
|
267
|
+
const e3 = preMs + sizeM * 2.0;
|
|
268
|
+
const d1 = Math.max(0.05, dampMs * 0.4);
|
|
269
|
+
const d2 = Math.max(0.02, dampMs * 0.25);
|
|
270
|
+
const d3 = Math.max(0.01, dampMs * 0.15);
|
|
271
|
+
return (
|
|
272
|
+
`aecho=in_gain=${inGain.toFixed(2)}:out_gain=${outGain.toFixed(2)}:` +
|
|
273
|
+
`delays=${e1.toFixed(0)}|${e2.toFixed(0)}|${e3.toFixed(0)}:` +
|
|
274
|
+
`decays=${d1.toFixed(3)}|${d2.toFixed(3)}|${d3.toFixed(3)}`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* forgeKitSoundBank(material, role) — Map earshot kit to SFX_FREQ_MAP key.
|
|
280
|
+
* Earshot kit sound bank: physical material + role → procedural SFX name.
|
|
281
|
+
*
|
|
282
|
+
* material: 'air' | 'wood' | 'glass' | 'sub' | 'bubble'
|
|
283
|
+
* role: 'entrance' | 'exit' | 'beat' | 'accent' | 'ambient'
|
|
284
|
+
*/
|
|
285
|
+
forgeKitSoundBank(material = 'air', role = 'entrance') {
|
|
286
|
+
const kitMap = {
|
|
287
|
+
air: { entrance: 'whoosh', exit: 'swish', beat: 'whoosh', accent: 'riser', ambient: 'transition' },
|
|
288
|
+
wood: { entrance: 'impact', exit: 'impact', beat: 'punch', accent: 'deep_boom', ambient: 'tick' },
|
|
289
|
+
glass: { entrance: 'chime', exit: 'chime', beat: 'click', accent: 'sparkle', ambient: 'chime' },
|
|
290
|
+
sub: { entrance: 'deep_boom', exit: 'impact', beat: 'deep_boom', accent: 'punch', ambient: 'transition' },
|
|
291
|
+
bubble: { entrance: 'pop', exit: 'pop', beat: 'click', accent: 'chime', ambient: 'tick' },
|
|
292
|
+
};
|
|
293
|
+
return (kitMap[material] || kitMap.air)[role] || 'pop';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_buildVoiceChainFilter(addReverb = false, roomProfile = null, noiseGate = false) {
|
|
297
|
+
const chain = [];
|
|
298
|
+
// Noise gate — removes room tone / breath between words
|
|
299
|
+
if (noiseGate) {
|
|
300
|
+
chain.push('agate=threshold=0.0003:range=0.15:attack=5:release=200');
|
|
301
|
+
}
|
|
302
|
+
chain.push(
|
|
303
|
+
'highpass=f=80',
|
|
304
|
+
'equalizer=f=300:width_type=o:width=1.2:g=-1.5', // mud / boxiness cut
|
|
305
|
+
'equalizer=f=5000:width_type=o:width=2:g=2.0', // presence boost
|
|
306
|
+
'equalizer=f=12000:width_type=s:width=1:g=1.5', // air shelf
|
|
307
|
+
'acompressor=ratio=3:threshold=-20dB:attack=5:release=50:makeup=2',
|
|
308
|
+
'equalizer=f=6500:width_type=h:width=1500:g=-3.0', // de-esser
|
|
309
|
+
);
|
|
310
|
+
// Room acoustics — resolve named profile, computed room (earshot forgeComputedRoom),
|
|
311
|
+
// or fall back to legacy addReverb bool
|
|
312
|
+
const profile = roomProfile
|
|
313
|
+
? (ROOM_PROFILES[roomProfile] || null)
|
|
314
|
+
: (addReverb ? ROOM_PROFILES.cinematic : null);
|
|
315
|
+
if (profile) {
|
|
316
|
+
chain.push(
|
|
317
|
+
`aecho=in_gain=${profile.inGain}:out_gain=${profile.outGain}:delays=${profile.delays}:decays=${profile.decays}`
|
|
318
|
+
);
|
|
319
|
+
} else if (roomProfile && roomProfile.startsWith('computed:')) {
|
|
320
|
+
// earshot forgeComputedRoom — format: "computed:sizeM:damp:earlyMs"
|
|
321
|
+
const [, sizeM = '8', damp = '0.5', earlyMs = '15'] = roomProfile.split(':');
|
|
322
|
+
chain.push(this.forgeComputedRoom(
|
|
323
|
+
parseFloat(sizeM), parseFloat(damp), parseFloat(earlyMs)
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
return chain.join(',');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Two-pass EBU R128 loudnorm for accurate linear normalization
|
|
331
|
+
*/
|
|
332
|
+
_twoPassNorm(inPath, outPath, lufs = -14, tp = -1.0) {
|
|
333
|
+
const pass1 = spawnSync('ffmpeg', [
|
|
334
|
+
'-nostdin', '-i', inPath,
|
|
335
|
+
'-af', `loudnorm=I=${lufs}:TP=${tp}:print_format=json`,
|
|
336
|
+
'-f', 'null', '-',
|
|
337
|
+
], { encoding: 'utf8', timeout: 120000 });
|
|
338
|
+
|
|
339
|
+
const combined = (pass1.stderr || '') + (pass1.stdout || '');
|
|
340
|
+
const match = combined.match(/\{[\s\S]*?"input_i"[\s\S]*?\}/);
|
|
341
|
+
let filterStr = `loudnorm=I=${lufs}:TP=${tp}`;
|
|
342
|
+
if (match) {
|
|
343
|
+
try {
|
|
344
|
+
const ln = JSON.parse(match[0]);
|
|
345
|
+
filterStr = (
|
|
346
|
+
`loudnorm=I=${lufs}:TP=${tp}:` +
|
|
347
|
+
`measured_I=${ln.input_i}:measured_LRA=${ln.input_lra}:` +
|
|
348
|
+
`measured_TP=${ln.input_tp}:measured_thresh=${ln.input_thresh}:` +
|
|
349
|
+
`offset=${ln.target_offset}:linear=true`
|
|
350
|
+
);
|
|
351
|
+
} catch (_) {}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const pass2 = spawnSync('ffmpeg', [
|
|
71
355
|
'-nostdin', '-y',
|
|
72
|
-
'-i',
|
|
73
|
-
'-
|
|
74
|
-
'-filter_complex', filterComplex,
|
|
75
|
-
'-map', '[out]',
|
|
356
|
+
'-i', inPath,
|
|
357
|
+
'-af', filterStr,
|
|
76
358
|
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k',
|
|
77
|
-
|
|
359
|
+
outPath,
|
|
78
360
|
], { timeout: 120000, encoding: 'utf8' });
|
|
79
361
|
|
|
80
|
-
if (
|
|
81
|
-
throw new Error(`
|
|
362
|
+
if (pass2.status !== 0) {
|
|
363
|
+
throw new Error(`Two-pass loudnorm failed: ${(pass2.stderr || '').slice(-300)}`);
|
|
82
364
|
}
|
|
83
|
-
return outputPath;
|
|
84
365
|
}
|
|
85
366
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
367
|
+
/**
|
|
368
|
+
* Synthesize a single SFX tone and place it at adjustedTime on a silence-padded track.
|
|
369
|
+
* Returns local file path to the generated SFX track.
|
|
370
|
+
* 10+ year sound designer procedural synthesis + earshot 40ms perceptual lead contract.
|
|
371
|
+
*/
|
|
372
|
+
_synthesizeSfxCue({ sfxName, cueTime, cueVol = 1.0, totalDuration, tmpDir }) {
|
|
373
|
+
const sfx = SFX_FREQ_MAP[sfxName] || SFX_FREQ_MAP.pop;
|
|
374
|
+
const adjustedTime = Math.max(0, cueTime - PERCEPTUAL_LEAD_S);
|
|
375
|
+
const adelayMs = Math.round(adjustedTime * 1000);
|
|
376
|
+
const volume = sfx.vol * cueVol;
|
|
377
|
+
const sfxPath = path.join(tmpDir, `sfx_${sfxName}_${adelayMs}.m4a`);
|
|
378
|
+
const padDuration = Math.max(sfx.dur + adjustedTime + 0.5, 1, totalDuration || 1);
|
|
379
|
+
|
|
380
|
+
// Check if local recorded sample exists
|
|
381
|
+
const localSample = path.resolve(__dirname, `../../assets/sfx/${sfxName}.wav`);
|
|
382
|
+
let ffmpegInputs = [];
|
|
383
|
+
let filterExpr = '';
|
|
384
|
+
|
|
385
|
+
if (fs.existsSync(localSample)) {
|
|
386
|
+
ffmpegInputs = [
|
|
387
|
+
'-f', 'lavfi', '-i', `anullsrc=channel_layout=stereo:sample_rate=48000:duration=${padDuration}`,
|
|
388
|
+
'-i', localSample,
|
|
389
|
+
];
|
|
390
|
+
filterExpr = `[1:a]volume=${volume},adelay=${adelayMs}|${adelayMs}[sfx];[0:a][sfx]amix=inputs=2:duration=first[out]`;
|
|
391
|
+
} else {
|
|
392
|
+
// Professional Procedural Synthesis (harmonic oscillators + shaped noise bursts)
|
|
393
|
+
let lavfiSource = '';
|
|
394
|
+
switch (sfxName) {
|
|
395
|
+
case 'whoosh':
|
|
396
|
+
lavfiSource = 'anoisesrc=d=0.4:c=pink:r=48000,bandpass=f=400:w=250,afade=t=in:ss=0:d=0.18,afade=t=out:st=0.20:d=0.20';
|
|
397
|
+
break;
|
|
398
|
+
case 'swish':
|
|
399
|
+
lavfiSource = 'anoisesrc=d=0.25:c=pink:r=48000,bandpass=f=600:w=300,afade=t=in:ss=0:d=0.10,afade=t=out:st=0.12:d=0.13';
|
|
400
|
+
break;
|
|
401
|
+
case 'impact':
|
|
402
|
+
lavfiSource = "aevalsrc='(0.65*sin(2*PI*(120-150*t)*t) + 0.3*sin(2*PI*55*t))*exp(-8*t)':d=0.5:s=48000";
|
|
403
|
+
break;
|
|
404
|
+
case 'deep_boom':
|
|
405
|
+
lavfiSource = "aevalsrc='sin(2*PI*38*t)*exp(-2.2*t)':d=1.2:s=48000";
|
|
406
|
+
break;
|
|
407
|
+
case 'punch':
|
|
408
|
+
lavfiSource = "aevalsrc='(0.7*sin(2*PI*55*t) + 0.35*sin(2*PI*140*t))*exp(-12*t)':d=0.25:s=48000";
|
|
409
|
+
break;
|
|
410
|
+
case 'pop':
|
|
411
|
+
lavfiSource = "aevalsrc='sin(2*PI*(900-5000*t)*t)*exp(-25*t)':d=0.10:s=48000";
|
|
412
|
+
break;
|
|
413
|
+
case 'click':
|
|
414
|
+
lavfiSource = 'anoisesrc=d=0.03:c=white:r=48000,highpass=f=2500,afade=t=out:st=0.005:d=0.025';
|
|
415
|
+
break;
|
|
416
|
+
case 'tick':
|
|
417
|
+
lavfiSource = 'anoisesrc=d=0.025:c=white:r=48000,bandpass=f=3200:w=600,afade=t=out:st=0.005:d=0.020';
|
|
418
|
+
break;
|
|
419
|
+
case 'riser':
|
|
420
|
+
lavfiSource = "aevalsrc='sin(2*PI*(80+140*t*t)*t)*pow(t/2.0,1.5)':d=2.0:s=48000";
|
|
421
|
+
break;
|
|
422
|
+
case 'chime':
|
|
423
|
+
lavfiSource = "aevalsrc='(0.6*sin(2*PI*1200*t)*exp(-6*t) + 0.4*sin(2*PI*2400*t)*exp(-9*t))':d=0.4:s=48000";
|
|
424
|
+
break;
|
|
425
|
+
case 'sparkle':
|
|
426
|
+
lavfiSource = "aevalsrc='(0.4*sin(2*PI*3200*t)*exp(-9*t) + 0.35*sin(2*PI*4800*t)*exp(-12*t) + 0.25*sin(2*PI*6400*t)*exp(-16*t))':d=0.3:s=48000";
|
|
427
|
+
break;
|
|
428
|
+
case 'buzz':
|
|
429
|
+
lavfiSource = "aevalsrc='(0.5*sin(2*PI*85*t) + 0.3*sin(2*PI*170*t) + 0.2*sin(2*PI*255*t))*exp(-7*t)':d=0.25:s=48000";
|
|
430
|
+
break;
|
|
431
|
+
case 'transition':
|
|
432
|
+
lavfiSource = "aevalsrc='(0.35*sin(2*PI*261.63*t) + 0.35*sin(2*PI*329.63*t) + 0.3*sin(2*PI*392*t))*sin(PI*t/0.6)':d=0.6:s=48000";
|
|
433
|
+
break;
|
|
434
|
+
default:
|
|
435
|
+
lavfiSource = `sine=f=${sfx.freq}:d=${sfx.dur}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
ffmpegInputs = [
|
|
439
|
+
'-f', 'lavfi', '-i', `anullsrc=channel_layout=stereo:sample_rate=48000:duration=${padDuration}`,
|
|
440
|
+
'-f', 'lavfi', '-i', lavfiSource,
|
|
441
|
+
];
|
|
442
|
+
filterExpr = `[1:a]volume=${volume},adelay=${adelayMs}|${adelayMs}[sfx];[0:a][sfx]amix=inputs=2:duration=first[out]`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const r = spawnSync('ffmpeg', [
|
|
446
|
+
'-nostdin', '-y',
|
|
447
|
+
...ffmpegInputs,
|
|
448
|
+
'-filter_complex', filterExpr,
|
|
449
|
+
'-map', '[out]',
|
|
450
|
+
'-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-ac', '2',
|
|
451
|
+
'-t', String(padDuration),
|
|
452
|
+
sfxPath,
|
|
453
|
+
], { encoding: 'utf8', timeout: 60000 });
|
|
454
|
+
|
|
455
|
+
if (r.status !== 0) {
|
|
456
|
+
throw new Error(`SFX synthesis failed for ${sfxName}@${cueTime}s: ${(r.stderr || '').slice(-200)}`);
|
|
457
|
+
}
|
|
458
|
+
return sfxPath;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
_sidechainMixPro({
|
|
462
|
+
voicePath,
|
|
463
|
+
musicPath,
|
|
464
|
+
outputPath,
|
|
465
|
+
musicVolume = 0.12,
|
|
466
|
+
duckLevel = -18,
|
|
467
|
+
addReverb = false,
|
|
468
|
+
roomProfile = null,
|
|
469
|
+
noiseGate = false,
|
|
470
|
+
stereoWiden = true,
|
|
471
|
+
sfxCues = [],
|
|
472
|
+
platform = null,
|
|
473
|
+
targetLufs = -14,
|
|
474
|
+
}) {
|
|
475
|
+
const tmpDir = path.join(os.tmpdir(), `ff_mix_${Date.now()}`);
|
|
476
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
// Step 1: Apply voice chain (noise gate + EQ + mud cut + compressor + de-esser + room)
|
|
480
|
+
const voiceChainPath = path.join(tmpDir, 'voice_chain.m4a');
|
|
481
|
+
const voiceFilter = this._buildVoiceChainFilter(addReverb, roomProfile, noiseGate);
|
|
482
|
+
const vc = spawnSync('ffmpeg', [
|
|
483
|
+
'-nostdin', '-y', '-i', voicePath,
|
|
484
|
+
'-af', voiceFilter,
|
|
485
|
+
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
486
|
+
voiceChainPath,
|
|
487
|
+
], { timeout: 120000, encoding: 'utf8' });
|
|
488
|
+
if (vc.status !== 0) throw new Error(`Voice chain failed: ${(vc.stderr || '').slice(-300)}`);
|
|
489
|
+
|
|
490
|
+
// Step 2: Synthesize SFX cues
|
|
491
|
+
const durProbe = spawnSync('ffprobe', [
|
|
492
|
+
'-v', 'error', '-show_entries', 'format=duration',
|
|
493
|
+
'-of', 'default=nk=1:nw=1', voiceChainPath,
|
|
494
|
+
], { encoding: 'utf8', timeout: 10000 });
|
|
495
|
+
const totalDur = parseFloat((durProbe.stdout || '0').trim()) || 60;
|
|
496
|
+
|
|
497
|
+
let sfxMixPath = null;
|
|
498
|
+
if (Array.isArray(sfxCues) && sfxCues.length > 0) {
|
|
499
|
+
const sfxPaths = sfxCues.map(cue => this._synthesizeSfxCue({
|
|
500
|
+
sfxName: (cue.name || cue.sfxName || 'pop').toLowerCase(),
|
|
501
|
+
cueTime: Number(cue.time || cue.t || 0),
|
|
502
|
+
cueVol: Number(cue.volume || cue.vol || 1.0),
|
|
503
|
+
totalDuration: totalDur,
|
|
504
|
+
tmpDir,
|
|
505
|
+
}));
|
|
506
|
+
|
|
507
|
+
if (sfxPaths.length === 1) {
|
|
508
|
+
sfxMixPath = sfxPaths[0];
|
|
509
|
+
} else {
|
|
510
|
+
sfxMixPath = path.join(tmpDir, 'sfx_combined.m4a');
|
|
511
|
+
const sfxInputs = sfxPaths.flatMap(p => ['-i', p]);
|
|
512
|
+
const sfxFilter = sfxPaths.map((_, i) => `[${i}:a]`).join('') +
|
|
513
|
+
`amix=inputs=${sfxPaths.length}:duration=longest:normalize=0[sfxout]`;
|
|
514
|
+
const sfxR = spawnSync('ffmpeg', [
|
|
515
|
+
'-nostdin', '-y', ...sfxInputs,
|
|
516
|
+
'-filter_complex', sfxFilter,
|
|
517
|
+
'-map', '[sfxout]',
|
|
518
|
+
'-c:a', 'aac', '-b:a', '128k', '-ar', '48000', '-ac', '2',
|
|
519
|
+
sfxMixPath,
|
|
520
|
+
], { encoding: 'utf8', timeout: 120000 });
|
|
521
|
+
if (sfxR.status !== 0) throw new Error(`SFX combine failed: ${(sfxR.stderr || '').slice(-200)}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Step 3: Music processing + sidechain ducking (with acoustic pocket carving & stereo widening)
|
|
526
|
+
const musicVol = Number.isFinite(musicVolume) ? musicVolume : 0.12;
|
|
527
|
+
const pocketFilter = 'equalizer=f=2500:width_type=o:width=1.5:g=-3.5'; // carve acoustic pocket for vocal
|
|
528
|
+
const widenFilter = stereoWiden ? ',extrastereo=m=1.35' : '';
|
|
529
|
+
const musicFilter = `aformat=sample_rates=48000:channel_layouts=stereo,highpass=f=80,lowpass=f=16000,${pocketFilter},volume=${musicVol}${widenFilter}`;
|
|
530
|
+
|
|
531
|
+
const preDuckPath = path.join(tmpDir, 'pre_norm.m4a');
|
|
532
|
+
const extraInputs = sfxMixPath ? ['-i', sfxMixPath] : [];
|
|
533
|
+
let filterComplex, mapExpr;
|
|
534
|
+
|
|
535
|
+
// Sidechain compress: threshold=0.003 (~-50dBFS), ratio=20, attack=0.15s, release=0.8s, makeup=1.5
|
|
536
|
+
if (sfxMixPath) {
|
|
537
|
+
filterComplex = [
|
|
538
|
+
`[1:a]${musicFilter}[music_proc]`,
|
|
539
|
+
`[0:a][music_proc]sidechaincompress=threshold=0.003:ratio=20:attack=0.15:release=0.8:makeup=1.5[ducked]`,
|
|
540
|
+
`[ducked][2:a]amix=inputs=2:duration=first:normalize=0[out]`,
|
|
541
|
+
].join(';');
|
|
542
|
+
mapExpr = '[out]';
|
|
543
|
+
} else {
|
|
544
|
+
filterComplex = [
|
|
545
|
+
`[1:a]${musicFilter}[music_proc]`,
|
|
546
|
+
`[0:a][music_proc]sidechaincompress=threshold=0.003:ratio=20:attack=0.15:release=0.8:makeup=1.5[out]`,
|
|
547
|
+
].join(';');
|
|
548
|
+
mapExpr = '[out]';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const mixResult = spawnSync('ffmpeg', [
|
|
552
|
+
'-nostdin', '-y',
|
|
553
|
+
'-i', voiceChainPath,
|
|
554
|
+
'-stream_loop', '-1', '-i', musicPath,
|
|
555
|
+
...extraInputs,
|
|
556
|
+
'-filter_complex', filterComplex,
|
|
557
|
+
'-map', mapExpr,
|
|
558
|
+
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k',
|
|
559
|
+
'-t', String(totalDur),
|
|
560
|
+
preDuckPath,
|
|
561
|
+
], { timeout: 300000, encoding: 'utf8' });
|
|
562
|
+
if (mixResult.status !== 0) throw new Error(`Sidechain mix failed: ${(mixResult.stderr || '').slice(-300)}`);
|
|
563
|
+
|
|
564
|
+
// Step 4: Two-pass loudnorm master bus at resolved target
|
|
565
|
+
const platformTarget = platform ? (PLATFORM_LUFS[platform] || null) : null;
|
|
566
|
+
const resolvedLufs = platformTarget ? platformTarget.lufs : (targetLufs || -14);
|
|
567
|
+
const resolvedTp = platformTarget ? platformTarget.tp : -1.0;
|
|
568
|
+
|
|
569
|
+
this._twoPassNorm(preDuckPath, outputPath, resolvedLufs, resolvedTp);
|
|
570
|
+
return outputPath;
|
|
571
|
+
|
|
572
|
+
} finally {
|
|
573
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
_videoWithDuckingPro({
|
|
578
|
+
videoPath,
|
|
579
|
+
musicPath,
|
|
580
|
+
outputPath,
|
|
581
|
+
musicVolume = 0.12,
|
|
582
|
+
duckLevel = -18,
|
|
583
|
+
addReverb = false,
|
|
584
|
+
roomProfile = null,
|
|
585
|
+
noiseGate = false,
|
|
586
|
+
stereoWiden = true,
|
|
587
|
+
sfxCues = [],
|
|
588
|
+
platform = null,
|
|
589
|
+
targetLufs = -14,
|
|
590
|
+
}) {
|
|
90
591
|
const videoDur = this._getVideoDuration(videoPath);
|
|
592
|
+
const musicVol = Number.isFinite(musicVolume) ? musicVolume : 0.12;
|
|
593
|
+
const pocketFilter = 'equalizer=f=2500:width_type=o:width=1.5:g=-3.5';
|
|
594
|
+
const widenFilter = stereoWiden ? ',extrastereo=m=1.35' : '';
|
|
595
|
+
const musicFilter = `aformat=sample_rates=48000:channel_layouts=stereo,highpass=f=80,lowpass=f=16000,${pocketFilter},volume=${musicVol}${widenFilter}`;
|
|
596
|
+
|
|
597
|
+
const voiceChainFilter = this._buildVoiceChainFilter(addReverb, roomProfile, noiseGate);
|
|
598
|
+
const platformTarget = platform ? (PLATFORM_LUFS[platform] || null) : null;
|
|
599
|
+
const resolvedLufs = platformTarget ? platformTarget.lufs : (targetLufs || -14);
|
|
600
|
+
const resolvedTp = platformTarget ? platformTarget.tp : -1.0;
|
|
91
601
|
|
|
92
602
|
const filterComplex = [
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
603
|
+
`[0:a]${voiceChainFilter}[voice_proc]`,
|
|
604
|
+
`[1:a]${musicFilter}[music_proc]`,
|
|
605
|
+
`[voice_proc][music_proc]sidechaincompress=threshold=0.003:ratio=20:attack=0.15:release=0.8:makeup=1.5[mixed]`,
|
|
606
|
+
`[mixed]loudnorm=I=${resolvedLufs}:TP=${resolvedTp}:LRA=11[audio_out]`,
|
|
97
607
|
].join(';');
|
|
98
608
|
|
|
99
609
|
const result = spawnSync('ffmpeg', [
|
|
100
610
|
'-nostdin', '-y',
|
|
101
|
-
'-i', videoPath,
|
|
102
|
-
'-stream_loop', '-1',
|
|
103
|
-
'-i', musicPath, // input 1: music (loop to fill)
|
|
611
|
+
'-i', videoPath,
|
|
612
|
+
'-stream_loop', '-1', '-i', musicPath,
|
|
104
613
|
'-filter_complex', filterComplex,
|
|
105
614
|
'-map', '0:v',
|
|
106
615
|
'-map', '[audio_out]',
|
|
107
|
-
'-c:v', 'copy',
|
|
616
|
+
'-c:v', 'copy',
|
|
108
617
|
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2',
|
|
109
|
-
// Use explicit -t instead of -shortest to prevent hang/trim on subtitle streams.
|
|
110
|
-
// videoDur measured by ffprobe; fallback to omitting -t if measurement failed.
|
|
111
618
|
...(videoDur > 0 ? ['-t', String(videoDur)] : []),
|
|
112
619
|
'-movflags', '+faststart',
|
|
113
620
|
outputPath,
|
|
114
621
|
], { timeout: 300000, encoding: 'utf8' });
|
|
115
622
|
|
|
116
623
|
if (result.status !== 0) {
|
|
117
|
-
throw new Error(`Video ducking failed: ${(result.stderr || '').slice(-300)}`);
|
|
624
|
+
throw new Error(`Video ducking (pro) failed: ${(result.stderr || '').slice(-300)}`);
|
|
118
625
|
}
|
|
119
626
|
return outputPath;
|
|
120
627
|
}
|
|
@@ -133,25 +640,34 @@ class AudioMixer {
|
|
|
133
640
|
} catch { return 0; }
|
|
134
641
|
}
|
|
135
642
|
|
|
136
|
-
|
|
643
|
+
/**
|
|
644
|
+
* Normalize voice only (no music), applying full voice chain.
|
|
645
|
+
*/
|
|
646
|
+
_normaliseVoiceWithChain(voicePath, outputPath, { addReverb = false, roomProfile = null, noiseGate = false, platform = null, targetLufs = -14 } = {}) {
|
|
647
|
+
const platformTarget = platform ? (PLATFORM_LUFS[platform] || null) : null;
|
|
648
|
+
const resolvedLufs = platformTarget ? platformTarget.lufs : (targetLufs || -14);
|
|
649
|
+
const resolvedTp = platformTarget ? platformTarget.tp : -1.0;
|
|
650
|
+
const voiceFilter = this._buildVoiceChainFilter(addReverb, roomProfile, noiseGate) + `,loudnorm=I=${resolvedLufs}:TP=${resolvedTp}:LRA=11`;
|
|
137
651
|
const result = spawnSync('ffmpeg', [
|
|
138
652
|
'-nostdin', '-y',
|
|
139
653
|
'-i', voicePath,
|
|
140
|
-
'-af',
|
|
654
|
+
'-af', voiceFilter,
|
|
141
655
|
'-ar', '48000', '-ac', '2', '-c:a', 'aac', '-b:a', '192k',
|
|
142
656
|
outputPath,
|
|
143
657
|
], { timeout: 60000, encoding: 'utf8' });
|
|
144
|
-
if (result.status !== 0) throw new Error(`Voice normalise failed: ${(result.stderr || '').slice(-200)}`);
|
|
658
|
+
if (result.status !== 0) throw new Error(`Voice chain normalise failed: ${(result.stderr || '').slice(-200)}`);
|
|
145
659
|
return outputPath;
|
|
146
660
|
}
|
|
147
661
|
|
|
148
|
-
_normaliseVideo(videoPath, outputPath) {
|
|
149
|
-
|
|
662
|
+
_normaliseVideo(videoPath, outputPath, { platform = null, targetLufs = -14 } = {}) {
|
|
663
|
+
const platformTarget = platform ? (PLATFORM_LUFS[platform] || null) : null;
|
|
664
|
+
const resolvedLufs = platformTarget ? platformTarget.lufs : (targetLufs || -14);
|
|
665
|
+
const resolvedTp = platformTarget ? platformTarget.tp : -1.0;
|
|
150
666
|
const result = spawnSync('ffmpeg', [
|
|
151
667
|
'-nostdin', '-y',
|
|
152
668
|
'-i', videoPath,
|
|
153
669
|
'-map', '0:v', '-map', '0:a',
|
|
154
|
-
'-af',
|
|
670
|
+
'-af', `loudnorm=I=${resolvedLufs}:TP=${resolvedTp}:LRA=11`,
|
|
155
671
|
'-c:v', 'copy',
|
|
156
672
|
'-c:a', 'aac', '-b:a', '192k',
|
|
157
673
|
'-movflags', '+faststart',
|
|
@@ -182,6 +698,21 @@ class AudioMixer {
|
|
|
182
698
|
return pauses;
|
|
183
699
|
} catch { return []; }
|
|
184
700
|
}
|
|
701
|
+
|
|
702
|
+
// ── Backward-compatible alias for older callers ──────────────────────────
|
|
703
|
+
mixWithDuckingLegacy({ voicePath, musicPath = null, outputPath }) {
|
|
704
|
+
return this.mixWithDucking({ voicePath, musicPath, outputPath });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
mixVideoLegacy({ videoPath, musicPath = null, outputPath }) {
|
|
708
|
+
return this.mixVideo({ videoPath, musicPath, outputPath });
|
|
709
|
+
}
|
|
185
710
|
}
|
|
186
711
|
|
|
187
|
-
module.exports = { AudioMixer };
|
|
712
|
+
module.exports = { AudioMixer, SFX_FREQ_MAP, ROOM_PROFILES, PLATFORM_LUFS, PERCEPTUAL_LEAD_S, detectNoiseFloor };
|
|
713
|
+
// Earshot forge DSP primitives are instance methods on AudioMixer:
|
|
714
|
+
// mixer.forgeModalResonator(freq, q, gainDb) → ffmpeg EQ filter string
|
|
715
|
+
// mixer.forgeSVFilter(type, freq, q) → ffmpeg filter string
|
|
716
|
+
// mixer.forgeComputedRoom(sizeM, damp, earlyMs) → ffmpeg aecho filter string (Sabine RT60)
|
|
717
|
+
// mixer.forgeKitSoundBank(material, role) → SFX_FREQ_MAP key
|
|
718
|
+
// room param 'computed:sizeM:damp:earlyMs' is supported by mix_audio_pro and _buildVoiceChainFilter
|