# 오디오 믹서 서비스 # pydub를 사용한 오디오 합성/믹싱 import os import tempfile from typing import Optional from pydub import AudioSegment from pydub.effects import normalize from app.models.drama import TimelineItem class AudioMixer: """ 오디오 믹서 기능: - 여러 오디오 트랙 합성 - 볼륨 조절 - 페이드 인/아웃 - 타임라인 기반 믹싱 """ def __init__(self, sample_rate: int = 44100): self.sample_rate = sample_rate def load_audio(self, file_path: str) -> AudioSegment: """오디오 파일 로드""" return AudioSegment.from_file(file_path) def adjust_volume(self, audio: AudioSegment, volume: float) -> AudioSegment: """볼륨 조절 (0.0 ~ 2.0, 1.0 = 원본)""" if volume == 1.0: return audio # dB 변환: 0.5 = -6dB, 2.0 = +6dB db_change = 20 * (volume ** 0.5 - 1) if volume > 0 else -120 return audio + db_change def apply_fade( self, audio: AudioSegment, fade_in_ms: int = 0, fade_out_ms: int = 0 ) -> AudioSegment: """페이드 인/아웃 적용""" if fade_in_ms > 0: audio = audio.fade_in(fade_in_ms) if fade_out_ms > 0: audio = audio.fade_out(fade_out_ms) return audio def concatenate(self, segments: list[AudioSegment]) -> AudioSegment: """오디오 세그먼트 연결""" if not segments: return AudioSegment.silent(duration=0) result = segments[0] for segment in segments[1:]: result += segment return result def overlay( self, base: AudioSegment, overlay_audio: AudioSegment, position_ms: int = 0 ) -> AudioSegment: """오디오 오버레이 (배경음악 위에 보이스 등)""" return base.overlay(overlay_audio, position=position_ms) def create_silence(self, duration_ms: int) -> AudioSegment: """무음 생성""" return AudioSegment.silent(duration=duration_ms) def mix_timeline( self, timeline: list[TimelineItem], audio_files: dict[str, str] # audio_path -> 실제 파일 경로 ) -> AudioSegment: """ 타임라인 기반 믹싱 Args: timeline: 타임라인 아이템 리스트 audio_files: 오디오 경로 매핑 Returns: 믹싱된 오디오 """ if not timeline: return AudioSegment.silent(duration=1000) # 전체 길이 계산 total_duration_ms = max( int((item.start_time + item.duration) * 1000) for item in timeline ) # 트랙별 분리 (voice, music, sfx) voice_track = AudioSegment.silent(duration=total_duration_ms) music_track = AudioSegment.silent(duration=total_duration_ms) sfx_track = AudioSegment.silent(duration=total_duration_ms) for item in timeline: if not item.audio_path or item.audio_path not in audio_files: continue file_path = audio_files[item.audio_path] if not os.path.exists(file_path): continue # 오디오 로드 및 처리 audio = self.load_audio(file_path) # 볼륨 조절 audio = self.adjust_volume(audio, item.volume) # 페이드 적용 fade_in_ms = int(item.fade_in * 1000) fade_out_ms = int(item.fade_out * 1000) audio = self.apply_fade(audio, fade_in_ms, fade_out_ms) # 위치 계산 position_ms = int(item.start_time * 1000) # 트랙에 오버레이 if item.type == "voice": voice_track = voice_track.overlay(audio, position=position_ms) elif item.type == "music": music_track = music_track.overlay(audio, position=position_ms) elif item.type == "sfx": sfx_track = sfx_track.overlay(audio, position=position_ms) # 트랙 믹싱 (music -> sfx -> voice 순서로 레이어링) mixed = music_track.overlay(sfx_track).overlay(voice_track) return mixed def auto_duck( self, music: AudioSegment, voice: AudioSegment, duck_amount_db: float = -10, attack_ms: int = 100, release_ms: int = 300 ) -> AudioSegment: """ Auto-ducking: 보이스가 나올 때 음악 볼륨 자동 감소 간단한 구현 - 보이스가 있는 구간에서 음악 볼륨 낮춤 """ # 보이스 길이에 맞춰 음악 조절 if len(music) < len(voice): music = music + AudioSegment.silent(duration=len(voice) - len(music)) # 보이스의 무음/유음 구간 감지 (간단한 RMS 기반) chunk_ms = 50 ducked_music = AudioSegment.silent(duration=0) for i in range(0, len(voice), chunk_ms): voice_chunk = voice[i:i + chunk_ms] music_chunk = music[i:i + chunk_ms] # 보이스 RMS가 임계값 이상이면 ducking if voice_chunk.rms > 100: # 임계값 조정 가능 music_chunk = music_chunk + duck_amount_db ducked_music += music_chunk return ducked_music def export( self, audio: AudioSegment, output_path: str, format: str = "wav", normalize_audio: bool = True ) -> str: """ 오디오 내보내기 Args: audio: 오디오 세그먼트 output_path: 출력 파일 경로 format: 출력 포맷 (wav, mp3) normalize_audio: 노멀라이즈 여부 Returns: 저장된 파일 경로 """ if normalize_audio: audio = normalize(audio) # 포맷별 설정 export_params = {} if format == "mp3": export_params = {"format": "mp3", "bitrate": "192k"} else: export_params = {"format": "wav"} audio.export(output_path, **export_params) return output_path def create_with_background( self, voice_segments: list[tuple[AudioSegment, float]], # (audio, start_time) background_music: Optional[AudioSegment] = None, music_volume: float = 0.3, gap_between_lines_ms: int = 500 ) -> AudioSegment: """ 보이스 + 배경음악 간단 합성 Args: voice_segments: (오디오, 시작시간) 튜플 리스트 background_music: 배경음악 (없으면 무음) music_volume: 배경음악 볼륨 gap_between_lines_ms: 대사 간 간격 Returns: 합성된 오디오 """ if not voice_segments: return AudioSegment.silent(duration=1000) # 전체 보이스 트랙 생성 voice_track = AudioSegment.silent(duration=0) for audio, start_time in voice_segments: # 시작 위치까지 무음 추가 current_pos = len(voice_track) target_pos = int(start_time * 1000) if target_pos > current_pos: voice_track += AudioSegment.silent(duration=target_pos - current_pos) voice_track += audio voice_track += AudioSegment.silent(duration=gap_between_lines_ms) total_duration = len(voice_track) # 배경음악 처리 if background_music: # 음악 길이 조정 if len(background_music) < total_duration: # 루프 loops_needed = (total_duration // len(background_music)) + 1 background_music = background_music * loops_needed background_music = background_music[:total_duration] # 볼륨 조절 background_music = self.adjust_volume(background_music, music_volume) # Auto-ducking 적용 background_music = self.auto_duck(background_music, voice_track) # 믹싱 return background_music.overlay(voice_track) else: return voice_track # 싱글톤 인스턴스 audio_mixer = AudioMixer()