271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
import os
|
|
import tempfile
|
|
import numpy as np
|
|
import soundfile as sf
|
|
from pydub import AudioSegment
|
|
from app.core.dsp_utils import find_nearest_zero_crossing_file, apply_micro_fade
|
|
|
|
def edit_audio_file(config: dict, input_path: str, output_path: str):
|
|
"""
|
|
Applies editing commands on the audio file based on config:
|
|
- cut_start_ms, cut_end_ms (with optional zero_crossing_align)
|
|
- loop_count
|
|
- fade_in_ms, fade_out_ms
|
|
- volume_change_db
|
|
"""
|
|
# Load original audio
|
|
audio = AudioSegment.from_file(input_path)
|
|
|
|
# Calculate start and end positions in ms
|
|
cut_start_ms = config.get("cut_start_ms")
|
|
cut_end_ms = config.get("cut_end_ms")
|
|
|
|
total_len = len(audio)
|
|
|
|
start_ms = float(cut_start_ms) if cut_start_ms is not None else 0.0
|
|
end_ms = float(cut_end_ms) if cut_end_ms is not None else float(total_len)
|
|
|
|
# Bound start and end
|
|
start_ms = max(0.0, min(start_ms, float(total_len)))
|
|
end_ms = max(start_ms, min(end_ms, float(total_len)))
|
|
|
|
# Align to Zero-crossing if requested
|
|
if config.get("zero_crossing_align", True):
|
|
# Align start
|
|
if cut_start_ms is not None:
|
|
start_sec = start_ms / 1000.0
|
|
aligned_start_sec = find_nearest_zero_crossing_file(input_path, start_sec)
|
|
start_ms = aligned_start_sec * 1000.0
|
|
|
|
# Align end
|
|
if cut_end_ms is not None:
|
|
end_sec = end_ms / 1000.0
|
|
aligned_end_sec = find_nearest_zero_crossing_file(input_path, end_sec)
|
|
end_ms = aligned_end_sec * 1000.0
|
|
|
|
# Crop the segment
|
|
segment = audio[start_ms:end_ms]
|
|
|
|
# Apply micro-fade (50ms) to ensure smooth boundaries
|
|
segment = apply_micro_fade(segment, fade_duration_ms=50)
|
|
|
|
# Loop the segment
|
|
loop_count = int(config.get("loop_count", 1))
|
|
if loop_count > 1:
|
|
segment = segment * loop_count
|
|
|
|
# Apply volume change
|
|
volume_change_db = float(config.get("volume_change_db", 0.0))
|
|
if volume_change_db != 0.0:
|
|
segment = segment + volume_change_db
|
|
|
|
# Apply custom fades
|
|
fade_in_ms = float(config.get("fade_in_ms", 0.0))
|
|
if fade_in_ms > 0:
|
|
segment = segment.fade_in(int(fade_in_ms))
|
|
|
|
# Apply fade out
|
|
fade_out_ms = float(config.get("fade_out_ms", 0.0))
|
|
if fade_out_ms > 0:
|
|
segment = segment.fade_out(int(fade_out_ms))
|
|
|
|
# Ensure parent output directory exists
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
|
|
# Export as WAV
|
|
segment.export(output_path, format="wav")
|
|
|
|
return {
|
|
"success": True,
|
|
"output_path": output_path,
|
|
"duration_ms": len(segment),
|
|
"aligned_start_ms": round(start_ms, 2),
|
|
"aligned_end_ms": round(end_ms, 2)
|
|
}
|
|
|
|
def cut_and_loop_segment(
|
|
file_path: str,
|
|
start_sec: float,
|
|
end_sec: float,
|
|
loop_count: int = 1,
|
|
fade_in_ms: int = 50,
|
|
fade_out_ms: int = 50,
|
|
volume_db_change: float = 0.0
|
|
) -> AudioSegment:
|
|
"""
|
|
Cắt đoạn âm thanh, áp dụng gain, fade-in/out và lặp lại đoạn đó.
|
|
pydub sử dụng mili-giây (ms) làm đơn vị chuẩn.
|
|
|
|
Args:
|
|
file_path: Đường dẫn tệp âm thanh
|
|
start_sec: Thời điểm bắt đầu (giây)
|
|
end_sec: Thời điểm kết thúc (giây)
|
|
loop_count: Số lần lặp
|
|
fade_in_ms: Thời gian fade-in (ms)
|
|
fade_out_ms: Thời gian fade-out (ms)
|
|
volume_db_change: Thay đổi âm lượng (dB)
|
|
|
|
Returns:
|
|
AudioSegment: Đoạn âm thanh đã xử lý
|
|
"""
|
|
# Tải tệp tin âm thanh gốc
|
|
sound = AudioSegment.from_file(file_path)
|
|
|
|
# Chuyển đổi giây sang mili-giây
|
|
start_ms = int(start_sec * 1000)
|
|
end_ms = int(end_sec * 1000)
|
|
|
|
# Trích xuất phân đoạn (Slicing)
|
|
clip = sound[start_ms:end_ms]
|
|
|
|
# Thay đổi âm lượng nếu có yêu cầu
|
|
if volume_db_change != 0.0:
|
|
clip = clip + volume_db_change
|
|
|
|
# Áp dụng hiệu ứng mờ đầu và mờ cuối (Fading)
|
|
if fade_in_ms > 0:
|
|
clip = clip.fade_in(fade_in_ms)
|
|
if fade_out_ms > 0:
|
|
clip = clip.fade_out(fade_out_ms)
|
|
|
|
# Tạo chuỗi lặp (Looping)
|
|
looped_clip = clip * loop_count
|
|
|
|
return looped_clip
|
|
|
|
def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int = 44100, bit_depth: int = 16):
|
|
"""
|
|
Hòa âm đa kênh thống nhất (Multitrack Mixdown).
|
|
|
|
Args:
|
|
tracks_meta: Danh sách cấu hình của từng track:
|
|
[{"file_path": "...", "volume": 0.8, "muted": False}, ...]
|
|
output_path: Đường dẫn file xuất
|
|
sample_rate: Tần số lấy mẫu (Hz)
|
|
bit_depth: Độ sâu bit (8, 16, hoặc 24)
|
|
|
|
Returns:
|
|
dict: Thông tin kết quả
|
|
"""
|
|
master_mix = None
|
|
|
|
for track in tracks_meta:
|
|
if track.get("muted", False):
|
|
continue
|
|
|
|
# Đọc tệp âm thanh của track
|
|
sound = AudioSegment.from_file(track["file_path"])
|
|
|
|
# Áp dụng Gain (chuyển đổi từ tỷ lệ 0.0 -> 1.0 sang decibels dB)
|
|
gain_raw = track.get("volume", 1.0)
|
|
if gain_raw <= 0.001:
|
|
continue # Xem như tắt tiếng hoàn toàn
|
|
gain_db = 20 * np.log10(gain_raw)
|
|
sound = sound + gain_db
|
|
|
|
# Gộp vào Master Mix
|
|
if master_mix is None:
|
|
master_mix = sound
|
|
else:
|
|
# overlay tự động đồng bộ thời điểm bắt đầu tại mốc 0ms
|
|
master_mix = master_mix.overlay(sound, position=0)
|
|
|
|
if master_mix is not None:
|
|
# Đảm bảo thư mục output tồn tại
|
|
out_dir = os.path.dirname(output_path)
|
|
if out_dir:
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
# Xác định format từ đuôi file
|
|
output_ext = os.path.splitext(output_path)[1].lower().lstrip(".")
|
|
|
|
if output_ext in ("mp3", "ogg"):
|
|
# Xuất trực tiếp qua pydub/FFmpeg cho MP3, OGG
|
|
master_mix.export(output_path, format=output_ext)
|
|
else:
|
|
# Cho WAV: sử dụng soundfile để kiểm soát bit-depth chính xác
|
|
# Dùng tempfile an toàn thay vì đường dẫn tương đối
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
temp_wav = tmp.name
|
|
|
|
try:
|
|
master_mix.export(temp_wav, format="wav")
|
|
y, sr_read = sf.read(temp_wav)
|
|
|
|
# Xác định subtype mã hóa bit-depth
|
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
|
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
|
|
|
# Ghi tệp WAV chất lượng cao
|
|
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
|
finally:
|
|
# Dọn dẹp tệp tạm (luôn thực hiện)
|
|
if os.path.exists(temp_wav):
|
|
os.remove(temp_wav)
|
|
|
|
return {
|
|
"success": True,
|
|
"output_path": output_path,
|
|
"duration_ms": len(master_mix),
|
|
"format": output_ext if output_ext else "wav",
|
|
"tracks_processed": len([t for t in tracks_meta if not t.get("muted", False)])
|
|
}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"error": "No active tracks to mix"
|
|
}
|
|
|
|
|
|
def export_audio(input_path: str, output_path: str, format: str = "wav",
|
|
sample_rate: int = 44100, bit_depth: int = 16):
|
|
"""
|
|
Xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
|
|
|
Args:
|
|
input_path: Đường dẫn file nguồn
|
|
output_path: Đường dẫn file đích
|
|
format: Định dạng xuất (wav, mp3, ogg)
|
|
sample_rate: Tần số lấy mẫu
|
|
bit_depth: Độ sâu bit (chỉ cho WAV)
|
|
|
|
Returns:
|
|
dict: Thông tin kết quả
|
|
"""
|
|
# Guard: prevent overwriting source file
|
|
if os.path.abspath(input_path) == os.path.abspath(output_path):
|
|
raise ValueError("output_path must not be the same as input_path")
|
|
|
|
sound = AudioSegment.from_file(input_path)
|
|
|
|
out_dir = os.path.dirname(output_path)
|
|
if out_dir:
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
if format in ("mp3", "ogg"):
|
|
# Xuất qua pydub/FFmpeg
|
|
sound.export(output_path, format=format)
|
|
else:
|
|
# WAV: dùng soundfile cho bit-depth chính xác
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
|
temp_wav = tmp.name
|
|
|
|
try:
|
|
sound.export(temp_wav, format="wav")
|
|
y, sr_read = sf.read(temp_wav)
|
|
|
|
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
|
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
|
|
|
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
|
finally:
|
|
if os.path.exists(temp_wav):
|
|
os.remove(temp_wav)
|
|
|
|
return {
|
|
"success": True,
|
|
"output_path": output_path,
|
|
"format": format,
|
|
"duration_ms": len(sound)
|
|
}
|