Files
drama-studio/audio-studio-api/app/services/audio_mixer.py
jungwoo choi cc547372c0 feat: Drama Studio 프로젝트 초기 구조 설정
- FastAPI 백엔드 (audio-studio-api)
- Next.js 프론트엔드 (audio-studio-ui)
- Qwen3-TTS 엔진 (audio-studio-tts)
- MusicGen 서비스 (audio-studio-musicgen)
- Docker Compose 개발/운영 환경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 11:39:38 +09:00

261 lines
8.1 KiB
Python

# 오디오 믹서 서비스
# 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()