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>
This commit is contained in:
260
audio-studio-api/app/services/audio_mixer.py
Normal file
260
audio-studio-api/app/services/audio_mixer.py
Normal file
@ -0,0 +1,260 @@
|
||||
# 오디오 믹서 서비스
|
||||
# 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()
|
||||
Reference in New Issue
Block a user