LLMVideoPipeline/pipeline/music_gen.py

184 lines
7.7 KiB
Python
Raw Normal View History

2026-08-16 09:39:36 -06:00
"""
Background Music Generation & Audio Synthesis Module.
Generates dynamic, mood-matched cinematic background music scores (ambient pads,
harmonic minor chord progressions, subtle sub-bass, atmospheric texture) for the exact
video length, perfectly structured for dynamic audio ducking under speech.
"""
import os
import math
import wave
import struct
import random
import logging
import numpy as np
from typing import Optional
logger = logging.getLogger("YTFactory.Music")
# Musical note frequencies (Hz)
NOTES = {
"C2": 65.41, "D2": 73.42, "E2": 82.41, "F2": 87.31, "G2": 98.00, "A2": 110.00, "B2": 123.47,
"C3": 130.81, "D3": 146.83, "E3": 164.81, "F3": 174.61, "G3": 196.00, "A3": 220.00, "B3": 246.94,
"C4": 261.63, "D4": 293.66, "E4": 329.63, "F4": 349.23, "G4": 392.00, "A4": 440.00, "B4": 493.88,
"C5": 523.25, "D5": 587.33, "E5": 659.25, "F5": 698.46, "G5": 783.99, "A5": 880.00, "B5": 987.77,
}
# Cinematic minor chord progressions (Root, Minor 3rd, 5th, Octave)
PROGRESSIONS = {
"d_minor": [
[NOTES["D3"], NOTES["F3"], NOTES["A3"], NOTES["D4"]],
[NOTES["B2"], NOTES["D3"], NOTES["F3"], NOTES["B3"]],
[NOTES["G2"], NOTES["B2"], NOTES["D3"], NOTES["G3"]],
[NOTES["A2"], NOTES["C3"], NOTES["E3"], NOTES["A3"]],
],
"a_minor": [
[NOTES["A2"], NOTES["C3"], NOTES["E3"], NOTES["A3"]],
[NOTES["F2"], NOTES["A2"], NOTES["C3"], NOTES["F3"]],
[NOTES["D2"], NOTES["F2"], NOTES["A2"], NOTES["D3"]],
[NOTES["E2"], NOTES["G2"], NOTES["B2"], NOTES["E3"]],
],
"c_minor": [
[NOTES["C3"], NOTES["D3"] * 1.06, NOTES["G3"], NOTES["C4"]],
[NOTES["A2"] * 1.06, NOTES["C3"], NOTES["E3"] * 1.06, NOTES["A3"] * 1.06],
[NOTES["F2"], NOTES["A2"] * 1.06, NOTES["C3"], NOTES["F3"]],
[NOTES["G2"], NOTES["B2"], NOTES["D3"], NOTES["G3"]],
]
}
class MusicGen:
def __init__(self, sample_rate: int = 44100):
self.sample_rate = sample_rate
def _generate_pad_chord(self, freqs: list, duration_sec: float) -> np.ndarray:
"""Synthesize a lush, detuned ambient pad chord with subtle LFO filtering."""
num_samples = int(self.sample_rate * duration_sec)
t = np.linspace(0, duration_sec, num_samples, endpoint=False)
chord_signal = np.zeros(num_samples, dtype=np.float32)
# LFO for slow ambient movement (0.2 Hz)
lfo = 0.5 + 0.5 * np.sin(2 * np.pi * 0.2 * t)
for freq in freqs:
# 3 detuned oscillators per note for stereo-like warmth
osc1 = np.sin(2 * np.pi * freq * t)
osc2 = 0.5 * np.sin(2 * np.pi * (freq * 1.003) * t)
osc3 = 0.5 * np.sin(2 * np.pi * (freq * 0.997) * t)
# Gentle soft harmonic
osc4 = 0.15 * np.sin(2 * np.pi * (freq * 2.0) * t)
note_signal = (osc1 + osc2 + osc3 + osc4) / 2.15
chord_signal += note_signal
# Normalize chord
chord_signal = (chord_signal / max(1, len(freqs))) * lfo
# Envelope: 1.5s attack, 1.5s release
attack_samples = int(self.sample_rate * min(1.5, duration_sec * 0.25))
release_samples = int(self.sample_rate * min(1.5, duration_sec * 0.25))
env = np.ones(num_samples, dtype=np.float32)
if attack_samples > 0:
env[:attack_samples] = np.linspace(0, 1, attack_samples)
if release_samples > 0:
env[-release_samples:] = np.linspace(1, 0, release_samples)
return chord_signal * env
def _generate_sub_bass(self, root_freq: float, duration_sec: float) -> np.ndarray:
"""Warm deep sub-bass oscillator (sine + soft 2nd harmonic)."""
num_samples = int(self.sample_rate * duration_sec)
t = np.linspace(0, duration_sec, num_samples, endpoint=False)
sub = np.sin(2 * np.pi * (root_freq / 2.0) * t)
sub_2 = 0.25 * np.sin(2 * np.pi * root_freq * t)
return (sub + sub_2) * 0.4
def _generate_texture(self, duration_sec: float) -> np.ndarray:
"""Gentle tape/atmosphere background texture."""
num_samples = int(self.sample_rate * duration_sec)
# Pinkish filtered noise
noise = np.random.normal(0, 0.02, num_samples).astype(np.float32)
# Simple moving average lowpass filter
kernel_size = 50
kernel = np.ones(kernel_size) / kernel_size
filtered_noise = np.convolve(noise, kernel, mode='same')
return filtered_noise
def generate(self, duration_sec: float, output_wav: str, mood: str = "cinematic") -> str:
"""
Synthesize a complete cinematic background music track tailored to the exact duration.
"""
logger.info(f"[MUSIC] Synthesizing {duration_sec:.1f}s cinematic soundtrack ({mood})...")
os.makedirs(os.path.dirname(os.path.abspath(output_wav)), exist_ok=True)
chord_prog = PROGRESSIONS["d_minor"] if mood != "space" else PROGRESSIONS["a_minor"]
chord_duration = 6.0 # 6 seconds per chord change
total_samples = int(self.sample_rate * duration_sec)
master_audio_left = np.zeros(total_samples, dtype=np.float32)
master_audio_right = np.zeros(total_samples, dtype=np.float32)
# Generate sequential chords
cur_sample = 0
chord_idx = 0
while cur_sample < total_samples:
remaining_samples = total_samples - cur_sample
this_dur = min(chord_duration, remaining_samples / self.sample_rate)
if this_dur <= 0.1:
break
chord = chord_prog[chord_idx % len(chord_prog)]
pad = self._generate_pad_chord(chord, this_dur)
bass = self._generate_sub_bass(chord[0], this_dur)
seg_samples = len(pad)
# Stereo spread: subtle phase offset
master_audio_left[cur_sample:cur_sample + seg_samples] += (pad * 0.7 + bass * 0.5)
master_audio_right[cur_sample:cur_sample + seg_samples] += (pad * 0.72 + bass * 0.48)
cur_sample += seg_samples
chord_idx += 1
# Add atmospheric texture layer
texture = self._generate_texture(duration_sec)
master_audio_left += texture[:total_samples]
master_audio_right += texture[:total_samples]
# Master Fade-in and Fade-out
fade_in_samples = int(self.sample_rate * 2.0)
fade_out_samples = int(self.sample_rate * 3.0)
if fade_in_samples < total_samples:
fade_in = np.linspace(0, 1, fade_in_samples)
master_audio_left[:fade_in_samples] *= fade_in
master_audio_right[:fade_in_samples] *= fade_in
if fade_out_samples < total_samples:
fade_out = np.linspace(1, 0, fade_out_samples)
master_audio_left[-fade_out_samples:] *= fade_out
master_audio_right[-fade_out_samples:] *= fade_out
# Normalize to -18 dB peak so it is ready for background ducking
peak = max(np.max(np.abs(master_audio_left)), np.max(np.abs(master_audio_right)), 1e-6)
target_peak = 0.35 # ~ -18 dBFS
master_audio_left = (master_audio_left / peak) * target_peak
master_audio_right = (master_audio_right / peak) * target_peak
# Convert to 16-bit PCM stereo
left_int16 = (master_audio_left * 32767).astype(np.int16)
right_int16 = (master_audio_right * 32767).astype(np.int16)
stereo = np.empty((total_samples, 2), dtype=np.int16)
stereo[:, 0] = left_int16
stereo[:, 1] = right_int16
with wave.open(output_wav, 'wb') as wf:
wf.setnchannels(2)
wf.setsampwidth(2)
wf.setframerate(self.sample_rate)
wf.writeframes(stereo.tobytes())
logger.info(f"[MUSIC] Soundtrack generated successfully: {output_wav}")
return output_wav