First commit
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import os
|
||||
import uuid
|
||||
import asyncio
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
file_id: str
|
||||
cut_start_ms: Optional[float] = None
|
||||
cut_end_ms: Optional[float] = None
|
||||
zero_crossing_align: bool = True
|
||||
loop_count: int = 1
|
||||
fade_in_ms: float = 0.0
|
||||
fade_out_ms: float = 0.0
|
||||
volume_change_db: float = 0.0
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
file_id: str
|
||||
format: str = "wav"
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16
|
||||
|
||||
class AIAnalysisRequest(BaseModel):
|
||||
file_id: str
|
||||
api_base_url: Optional[str] = None
|
||||
model: str = "deepseek-chat"
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_audio(file: UploadFile = File(...)):
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
if not ext:
|
||||
ext = ".wav"
|
||||
file_id = f"{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
# Trigger celery task
|
||||
from app.tasks.worker import analyze_audio_task
|
||||
task = analyze_audio_task.delay(file_id)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"filename": file.filename,
|
||||
"analysis_task_id": task.id
|
||||
}
|
||||
|
||||
@router.post("/edit")
|
||||
async def edit_audio(req: EditRequest):
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import edit_audio_task
|
||||
task = edit_audio_task.delay(req.dict())
|
||||
|
||||
return {
|
||||
"task_id": task.id
|
||||
}
|
||||
|
||||
@router.get("/download/{file_id}")
|
||||
async def download_audio(file_id: str):
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
||||
elif os.path.exists(upload_path):
|
||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
@router.get("/waveform/{file_id}")
|
||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||
"""
|
||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_peak_waveform
|
||||
return await asyncio.to_thread(generate_peak_waveform, file_path, num_peaks)
|
||||
|
||||
@router.get("/waveform-rms/{file_id}")
|
||||
async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge=50, le=4000)):
|
||||
"""
|
||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_rms_waveform
|
||||
return await asyncio.to_thread(generate_rms_waveform, file_path, num_points)
|
||||
|
||||
@router.post("/analyze-ai")
|
||||
async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
||||
"""
|
||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import analyze_ai_task
|
||||
task = analyze_ai_task.delay(
|
||||
file_id=req.file_id,
|
||||
api_base_url=req.api_base_url,
|
||||
model=req.model
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
}
|
||||
|
||||
@router.post("/export")
|
||||
async def export_audio(req: ExportRequest):
|
||||
"""
|
||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
source_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
source_path = upload_path
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import export_audio_task
|
||||
task = export_audio_task.delay(
|
||||
file_id=req.file_id,
|
||||
format=req.format,
|
||||
sample_rate=req.sample_rate,
|
||||
bit_depth=req.bit_depth
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class ClipConfig(BaseModel):
|
||||
clip_id: str
|
||||
start_time_seconds: float
|
||||
end_time_seconds: float
|
||||
loop_count: int = 1
|
||||
apply_zero_crossing: bool = True
|
||||
fade_in_ms: int = 150
|
||||
fade_out_ms: int = 150
|
||||
|
||||
class TrackConfig(BaseModel):
|
||||
track_id: str
|
||||
file_id: str
|
||||
volume: float = 1.0
|
||||
muted: bool = False
|
||||
clips: List[ClipConfig] = []
|
||||
|
||||
class ExportSettings(BaseModel):
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16
|
||||
format: str = "wav"
|
||||
|
||||
class MultitrackSessionRequest(BaseModel):
|
||||
session_id: str
|
||||
export_settings: ExportSettings
|
||||
tracks: List[TrackConfig]
|
||||
|
||||
@router.post("/mix")
|
||||
async def mix_multitrack_session(req: MultitrackSessionRequest):
|
||||
"""
|
||||
API endpoint để xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||
Nhận cấu hình JSON từ Client và gửi task xuống Celery Worker.
|
||||
"""
|
||||
# Kiểm tra xem các file nguồn có tồn tại không
|
||||
for track in req.tracks:
|
||||
if track.muted:
|
||||
continue
|
||||
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, track.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, track.file_id)
|
||||
|
||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"File not found for track {track.track_id}: {track.file_id}"
|
||||
)
|
||||
|
||||
# Gửi task xuống Celery Worker
|
||||
from app.tasks.worker import mix_multitrack_task
|
||||
task = mix_multitrack_task.delay(req.dict())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"session_id": req.session_id,
|
||||
"status": "processing"
|
||||
}
|
||||
|
||||
@router.post("/process-session")
|
||||
async def process_session(req: MultitrackSessionRequest):
|
||||
"""
|
||||
API endpoint để xử lý toàn bộ session với nhiều tracks và clips.
|
||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||
"""
|
||||
from app.tasks.worker import process_multitrack_session_task
|
||||
task = process_multitrack_session_task.delay(req.dict())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"session_id": req.session_id,
|
||||
"status": "processing"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter
|
||||
from celery.result import AsyncResult
|
||||
from app.tasks.worker import celery_app
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
res = AsyncResult(task_id, app=celery_app)
|
||||
response_data = {
|
||||
"task_id": task_id,
|
||||
"status": res.status,
|
||||
}
|
||||
if res.ready():
|
||||
if res.successful():
|
||||
response_data["result"] = res.result
|
||||
else:
|
||||
response_data["error"] = str(res.result)
|
||||
return response_data
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
class Settings:
|
||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")
|
||||
|
||||
# __file__ is app/config.py, so dirname(__file__) = app/, dirname(app/) = project root
|
||||
APP_DIR: str = os.path.dirname(os.path.abspath(__file__))
|
||||
BASE_DIR: str = os.path.dirname(APP_DIR)
|
||||
TEMPLATES_DIR: str = os.path.join(APP_DIR, "templates")
|
||||
STORAGE_DIR: str = os.path.join(APP_DIR, "storage")
|
||||
UPLOADS_DIR: str = os.path.join(STORAGE_DIR, "uploads")
|
||||
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,239 @@
|
||||
import os
|
||||
import json
|
||||
import librosa
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def analyze_audio(file_path: str) -> dict:
|
||||
"""
|
||||
Phân tích âm thanh: BPM, beat tracking, ước lượng bars.
|
||||
"""
|
||||
# Load audio
|
||||
y, sr = librosa.load(file_path, sr=None)
|
||||
|
||||
# Track beats
|
||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
||||
|
||||
# Handle tempo which might be scalar or numpy array in different librosa versions
|
||||
if isinstance(tempo, np.ndarray):
|
||||
if tempo.size > 0:
|
||||
bpm = float(tempo[0])
|
||||
else:
|
||||
bpm = 120.0
|
||||
else:
|
||||
bpm = float(tempo)
|
||||
|
||||
# Convert frames to time (seconds)
|
||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
||||
|
||||
# Estimate bars (assume 4/4 time signature - grouping every 4 beats)
|
||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||
|
||||
return {
|
||||
"bpm": round(bpm, 2),
|
||||
"beats": [round(t, 4) for t in beat_times],
|
||||
"bars": [round(t, 4) for t in bar_times],
|
||||
"duration": round(float(len(y)) / sr, 4)
|
||||
}
|
||||
|
||||
|
||||
def analyze_audio_advanced(file_path: str) -> dict:
|
||||
"""
|
||||
Phân tích âm thanh nâng cao: BPM, beats, bars, spectral features.
|
||||
Sử dụng librosa để trích xuất đặc trưng âm học chi tiết.
|
||||
"""
|
||||
y, sr = librosa.load(file_path, sr=None)
|
||||
duration = float(len(y)) / sr
|
||||
|
||||
# Beat tracking
|
||||
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
|
||||
|
||||
if isinstance(tempo, np.ndarray):
|
||||
bpm = float(tempo[0]) if tempo.size > 0 else 120.0
|
||||
else:
|
||||
bpm = float(tempo)
|
||||
|
||||
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
|
||||
bar_times = [beat_times[i] for i in range(0, len(beat_times), 4)]
|
||||
|
||||
# Spectral centroid (brightness)
|
||||
spectral_centroids = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
||||
avg_brightness = float(np.mean(spectral_centroids))
|
||||
|
||||
# RMS energy
|
||||
rms = librosa.feature.rms(y=y)[0]
|
||||
avg_energy = float(np.mean(rms))
|
||||
|
||||
# Zero crossing rate
|
||||
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
||||
avg_zcr = float(np.mean(zcr))
|
||||
|
||||
return {
|
||||
"bpm": round(bpm, 2),
|
||||
"beats": [round(t, 4) for t in beat_times],
|
||||
"bars": [round(t, 4) for t in bar_times],
|
||||
"duration": round(duration, 4),
|
||||
"spectral_centroid_avg": round(avg_brightness, 2),
|
||||
"rms_energy_avg": round(avg_energy, 6),
|
||||
"zero_crossing_rate_avg": round(avg_zcr, 6),
|
||||
"sample_rate": sr
|
||||
}
|
||||
|
||||
|
||||
def analyze_structure_with_ai(
|
||||
file_path: str,
|
||||
api_base_url: Optional[str] = None,
|
||||
model: str = "deepseek-chat"
|
||||
) -> dict:
|
||||
"""
|
||||
Phân tích cấu trúc khuôn nhạc bằng AI (OpenAI Compatible API).
|
||||
Gọi API DeepSeek/Ollama để phân đoạn bố cục (Intro, Verse, Chorus, Outro).
|
||||
|
||||
Args:
|
||||
file_path: Đường dẫn tệp âm thanh
|
||||
api_base_url: Base URL của API (mặc định dùng env OPENAI_API_BASE)
|
||||
model: Model name (DeepSeek, Ollama, etc.)
|
||||
|
||||
Note:
|
||||
API key is read exclusively from OPENAI_API_KEY env var.
|
||||
|
||||
Returns:
|
||||
dict: Kết quả phân tích cấu trúc
|
||||
"""
|
||||
# Lấy thông tin phân tích cơ bản trước
|
||||
analysis = analyze_audio_advanced(file_path)
|
||||
|
||||
# Cấu hình API - key chỉ đọc từ env var, không bao giờ truyền qua task queue
|
||||
base_url = api_base_url or os.getenv("OPENAI_API_BASE", "http://localhost:11434/v1")
|
||||
key = os.getenv("OPENAI_API_KEY", "ollama")
|
||||
|
||||
# Chuẩn bị prompt phân tích
|
||||
prompt = f"""Analyze the following audio metadata and suggest the musical structure (sections).
|
||||
|
||||
Audio Analysis:
|
||||
- BPM: {analysis['bpm']}
|
||||
- Duration: {analysis['duration']} seconds
|
||||
- Number of beats: {len(analysis['beats'])}
|
||||
- Number of bars: {len(analysis['bars'])}
|
||||
- Average spectral centroid: {analysis['spectral_centroid_avg']} Hz
|
||||
- Average RMS energy: {analysis['rms_energy_avg']}
|
||||
- Bar timestamps (seconds): {json.dumps(analysis['bars'][:20])}
|
||||
|
||||
Based on this data, estimate the song structure by identifying sections like Intro, Verse, Chorus, Bridge, Outro.
|
||||
Respond ONLY with valid JSON in this exact format:
|
||||
{{
|
||||
"sections": [
|
||||
{{"name": "Intro", "start_time": 0.0, "end_time": 8.5}},
|
||||
{{"name": "Verse", "start_time": 8.5, "end_time": 25.0}},
|
||||
{{"name": "Chorus", "start_time": 25.0, "end_time": 40.0}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
response = httpx.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a music analysis AI. Respond only with valid JSON."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.3
|
||||
},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
ai_content = result["choices"][0]["message"]["content"]
|
||||
|
||||
# Parse JSON response
|
||||
try:
|
||||
# Xử lý trường hợp AI trả về text bọc trong ```json ... ```
|
||||
if "```json" in ai_content:
|
||||
ai_content = ai_content.split("```json")[1].split("```")[0]
|
||||
elif "```" in ai_content:
|
||||
ai_content = ai_content.split("```")[1].split("```")[0]
|
||||
|
||||
sections = json.loads(ai_content.strip())
|
||||
except json.JSONDecodeError:
|
||||
sections = {"sections": [], "error": "AI response was not valid JSON"}
|
||||
|
||||
return {
|
||||
**analysis,
|
||||
"ai_structure": sections,
|
||||
"ai_model": model,
|
||||
"ai_status": "success"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**analysis,
|
||||
"ai_structure": {"sections": []},
|
||||
"ai_model": model,
|
||||
"ai_status": f"API error: {response.status_code}"
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
# httpx not available, fall back to basic heuristic
|
||||
return {
|
||||
**analysis,
|
||||
"ai_structure": _estimate_structure_heuristic(analysis),
|
||||
"ai_model": "heuristic",
|
||||
"ai_status": "httpx not installed, using heuristic"
|
||||
}
|
||||
except Exception as e:
|
||||
# API không khả dụng, dùng heuristic
|
||||
return {
|
||||
**analysis,
|
||||
"ai_structure": _estimate_structure_heuristic(analysis),
|
||||
"ai_model": "heuristic",
|
||||
"ai_status": f"AI unavailable ({str(e)}), using heuristic"
|
||||
}
|
||||
|
||||
|
||||
def _estimate_structure_heuristic(analysis: dict) -> dict:
|
||||
"""
|
||||
Ước lượng cấu trúc bài hát bằng heuristic khi AI không khả dụng.
|
||||
Dựa trên số bars và duration để phân đoạn.
|
||||
"""
|
||||
duration = analysis.get("duration", 0)
|
||||
bars = analysis.get("bars", [])
|
||||
|
||||
if not bars or duration < 10:
|
||||
return {"sections": [{"name": "Full", "start_time": 0.0, "end_time": duration}]}
|
||||
|
||||
sections = []
|
||||
num_bars = len(bars)
|
||||
|
||||
if num_bars >= 8:
|
||||
# Intro: ~first 2 bars
|
||||
intro_end = bars[min(2, num_bars - 1)]
|
||||
sections.append({"name": "Intro", "start_time": 0.0, "end_time": round(intro_end, 4)})
|
||||
|
||||
# Main body
|
||||
if num_bars >= 16:
|
||||
verse_end = bars[min(8, num_bars - 1)]
|
||||
sections.append({"name": "Verse", "start_time": round(intro_end, 4), "end_time": round(verse_end, 4)})
|
||||
|
||||
if num_bars >= 24:
|
||||
chorus_end = bars[min(16, num_bars - 1)]
|
||||
sections.append({"name": "Chorus", "start_time": round(verse_end, 4), "end_time": round(chorus_end, 4)})
|
||||
|
||||
# Outro: last bars to end
|
||||
outro_start = bars[min(num_bars - 2, num_bars - 1)]
|
||||
sections.append({"name": "Outro", "start_time": round(outro_start, 4), "end_time": round(duration, 4)})
|
||||
else:
|
||||
sections.append({"name": "Outro", "start_time": round(verse_end, 4), "end_time": round(duration, 4)})
|
||||
else:
|
||||
sections.append({"name": "Main", "start_time": round(intro_end, 4), "end_time": round(duration, 4)})
|
||||
else:
|
||||
sections.append({"name": "Full", "start_time": 0.0, "end_time": round(duration, 4)})
|
||||
|
||||
return {"sections": sections}
|
||||
@@ -0,0 +1,270 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import numpy as np
|
||||
import librosa
|
||||
from pydub import AudioSegment
|
||||
|
||||
def find_zero_crossing(y: np.ndarray, sr: int, target_time: float, window_seconds: float = 0.04) -> float:
|
||||
"""
|
||||
Tìm điểm zero-crossing gần nhất với mốc thời gian đích (giây) để tránh click/pop.
|
||||
|
||||
Args:
|
||||
y: Mảng biên độ âm thanh (1D numpy array, Mono)
|
||||
sr: Tần số lấy mẫu (Sample Rate)
|
||||
target_time: Vị trí mong muốn cắt (giây)
|
||||
window_seconds: Cửa sổ quét (mặc định 40ms)
|
||||
|
||||
Returns:
|
||||
float: Thời gian của điểm zero-crossing gần nhất (giây)
|
||||
"""
|
||||
if target_time is None or target_time < 0:
|
||||
return target_time
|
||||
|
||||
target_sample = int(target_time * sr)
|
||||
window_samples = int(window_seconds * sr)
|
||||
|
||||
# Xác định giới hạn vùng quét an toàn
|
||||
start_idx = max(0, target_sample - window_samples)
|
||||
end_idx = min(len(y) - 2, target_sample + window_samples)
|
||||
|
||||
if start_idx >= end_idx:
|
||||
return target_time
|
||||
|
||||
# Lấy phân khúc sóng âm trong cửa sổ quét
|
||||
y_window = y[start_idx:end_idx]
|
||||
|
||||
# Tìm các điểm đổi dấu: y[i] * y[i+1] <= 0
|
||||
# Sử dụng np.sign và np.diff để tìm điểm đổi dấu nhanh chóng
|
||||
signs = np.sign(y_window)
|
||||
# Bất kỳ vị trí nào diff != 0 nghĩa là có sự đổi dấu (đi qua điểm 0)
|
||||
zero_crossings = np.where(np.diff(signs) != 0)[0]
|
||||
|
||||
if len(zero_crossings) == 0:
|
||||
return target_time # Không tìm thấy, trả về vị trí gốc
|
||||
|
||||
# Chuyển chỉ số vùng quét về chỉ số mảng tuyệt đối
|
||||
absolute_crossings = zero_crossings + start_idx
|
||||
|
||||
# Tìm điểm gần với target_sample nhất
|
||||
distances = np.abs(absolute_crossings - target_sample)
|
||||
closest_sample_idx = absolute_crossings[np.argmin(distances)]
|
||||
|
||||
# Trả về thời gian tương ứng (giây)
|
||||
return float(closest_sample_idx / sr)
|
||||
|
||||
def find_nearest_zero_crossing_file(file_path: str, target_time_sec: float, search_window_sec: float = 0.04) -> float:
|
||||
"""
|
||||
Tìm điểm zero-crossing từ file âm thanh.
|
||||
Wrapper cho hàm find_zero_crossing để tương thích với code cũ.
|
||||
"""
|
||||
try:
|
||||
# Load mono audio for zero crossing analysis
|
||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
||||
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
||||
except Exception as e:
|
||||
print(f"Error finding zero crossing: {e}")
|
||||
return target_time_sec
|
||||
|
||||
def find_nearest_zero_crossing(y: np.ndarray, sr: int, target_time_sec: float, search_window_sec: float = 0.04) -> float:
|
||||
"""
|
||||
Tương thích với code cũ. Gọi đến hàm find_zero_crossing mới.
|
||||
"""
|
||||
return find_zero_crossing(y, sr, target_time_sec, search_window_sec)
|
||||
|
||||
def apply_micro_fade(segment: AudioSegment, fade_duration_ms: int = 50) -> AudioSegment:
|
||||
"""
|
||||
Áp dụng micro-fades (fade-in và fade-out) để triệt tiêu click/pop.
|
||||
"""
|
||||
if len(segment) > fade_duration_ms * 2:
|
||||
return segment.fade_in(fade_duration_ms).fade_out(fade_duration_ms)
|
||||
elif len(segment) > fade_duration_ms:
|
||||
return segment.fade_in(fade_duration_ms // 2).fade_out(fade_duration_ms // 2)
|
||||
return segment
|
||||
|
||||
|
||||
def generate_peak_waveform(file_path: str, num_peaks: int = 800) -> dict:
|
||||
"""
|
||||
Tạo dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||
Dùng để vẽ waveform đồng bộ với Client (thay thế Web Audio API decodeAudioData).
|
||||
|
||||
Args:
|
||||
file_path: Đường dẫn tệp âm thanh
|
||||
num_peaks: Số lượng điểm peak trả về (tương ứng pixel width trên UI)
|
||||
|
||||
Returns:
|
||||
dict: {"peaks": [...], "duration": float, "sample_rate": int}
|
||||
"""
|
||||
# Load mono audio
|
||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
||||
|
||||
total_samples = len(y)
|
||||
duration = float(total_samples) / sr
|
||||
|
||||
if total_samples == 0:
|
||||
return {
|
||||
"peaks": [],
|
||||
"duration": 0.0,
|
||||
"sample_rate": sr
|
||||
}
|
||||
|
||||
# Tính kích thước mỗi chunk
|
||||
samples_per_peak = max(1, total_samples // num_peaks)
|
||||
|
||||
peaks = []
|
||||
for i in range(0, total_samples, samples_per_peak):
|
||||
chunk = y[i:i + samples_per_peak]
|
||||
if len(chunk) > 0:
|
||||
# Peak = giá trị tuyệt đối lớn nhất trong chunk
|
||||
peak_val = float(np.max(np.abs(chunk)))
|
||||
peaks.append(round(peak_val, 6))
|
||||
|
||||
# Giới hạn đúng số lượng peaks yêu cầu
|
||||
if len(peaks) > num_peaks:
|
||||
peaks = peaks[:num_peaks]
|
||||
|
||||
return {
|
||||
"peaks": peaks,
|
||||
"duration": round(duration, 4),
|
||||
"sample_rate": sr
|
||||
}
|
||||
|
||||
|
||||
def generate_rms_waveform(file_path: str, num_points: int = 800) -> dict:
|
||||
"""
|
||||
Tạo dữ liệu RMS waveform (mượt hơn peak waveform).
|
||||
|
||||
Args:
|
||||
file_path: Đường dẫn tệp âm thanh
|
||||
num_points: Số lượng điểm RMS trả về
|
||||
|
||||
Returns:
|
||||
dict: {"rms": [...], "duration": float, "sample_rate": int}
|
||||
"""
|
||||
y, sr = librosa.load(file_path, sr=None, mono=True)
|
||||
|
||||
total_samples = len(y)
|
||||
duration = float(total_samples) / sr
|
||||
|
||||
if total_samples == 0:
|
||||
return {
|
||||
"rms": [],
|
||||
"duration": 0.0,
|
||||
"sample_rate": sr
|
||||
}
|
||||
|
||||
samples_per_point = max(1, total_samples // num_points)
|
||||
|
||||
rms_values = []
|
||||
for i in range(0, total_samples, samples_per_point):
|
||||
chunk = y[i:i + samples_per_point]
|
||||
if len(chunk) > 0:
|
||||
rms_val = float(np.sqrt(np.mean(chunk ** 2)))
|
||||
rms_values.append(round(rms_val, 6))
|
||||
|
||||
if len(rms_values) > num_points:
|
||||
rms_values = rms_values[:num_points]
|
||||
|
||||
return {
|
||||
"rms": rms_values,
|
||||
"duration": round(duration, 4),
|
||||
"sample_rate": sr
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.api.v1.audio import router as audio_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.multitrack import router as multitrack_router
|
||||
|
||||
# Ensure storage directories exist
|
||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount storage directory
|
||||
app.mount("/static/audio", StaticFiles(directory=settings.STORAGE_DIR), name="audio")
|
||||
|
||||
# Include routers
|
||||
app.include_router(audio_router, prefix="/api/v1/audio", tags=["audio"])
|
||||
app.include_router(tasks_router, prefix="/api/v1/audio", tags=["tasks"])
|
||||
app.include_router(multitrack_router, prefix="/api/v1/multitrack", tags=["multitrack"])
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html")
|
||||
if not os.path.exists(index_path):
|
||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||
with open(index_path, "r", encoding="utf-8") as file:
|
||||
return HTMLResponse(content=file.read(), status_code=200)
|
||||
@@ -0,0 +1,305 @@
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import glob
|
||||
import logging
|
||||
from celery import Celery
|
||||
from app.config import settings
|
||||
from app.core.analyzer import analyze_audio, analyze_structure_with_ai
|
||||
from app.core.audio_editor import (
|
||||
edit_audio_file, cut_and_loop_segment, mix_multitrack_session, export_audio
|
||||
)
|
||||
from app.core.dsp_utils import find_nearest_zero_crossing_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
celery_app = Celery(
|
||||
"audio_tasks",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
)
|
||||
|
||||
# ── Lịch trình tự động dọn dẹp file hết hạn (Week 5) ──
|
||||
celery_app.conf.beat_schedule = {
|
||||
"cleanup-expired-files-every-hour": {
|
||||
"task": "app.tasks.worker.cleanup_expired_files_task",
|
||||
"schedule": 3600.0, # Chạy mỗi giờ
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def analyze_audio_task(file_id: str):
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"Upload file not found: {file_id}")
|
||||
return analyze_audio(file_path)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def analyze_ai_task(file_id: str, api_base_url: str = None,
|
||||
model: str = "deepseek-chat"):
|
||||
"""
|
||||
Task phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||
API key is read from OPENAI_API_KEY env var only (never serialized into task queue).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
raise FileNotFoundError(f"File not found: {file_id}")
|
||||
|
||||
return analyze_structure_with_ai(
|
||||
file_path=file_path,
|
||||
api_base_url=api_base_url,
|
||||
model=model
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def edit_audio_task(config: dict):
|
||||
file_id = config.get("file_id")
|
||||
|
||||
input_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
if not os.path.exists(input_path):
|
||||
input_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
if not os.path.exists(input_path):
|
||||
raise FileNotFoundError(f"Source file not found: {file_id}")
|
||||
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
res = edit_audio_file(config, input_path, output_path)
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"success": True,
|
||||
"details": res
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def export_audio_task(file_id: str, format: str = "wav",
|
||||
sample_rate: int = 44100, bit_depth: int = 16):
|
||||
"""
|
||||
Task xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
source_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
source_path = upload_path
|
||||
else:
|
||||
raise FileNotFoundError(f"File not found: {file_id}")
|
||||
|
||||
# Tạo output filename
|
||||
base_name = os.path.splitext(file_id)[0]
|
||||
output_filename = f"{base_name}_exported.{format}"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, output_filename)
|
||||
|
||||
result = export_audio(
|
||||
input_path=source_path,
|
||||
output_path=output_path,
|
||||
format=format,
|
||||
sample_rate=sample_rate,
|
||||
bit_depth=bit_depth
|
||||
)
|
||||
|
||||
result["output_file_id"] = output_filename
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def mix_multitrack_task(session_config: dict):
|
||||
"""
|
||||
Task xử lý hòa âm đa kênh (Multitrack Mixdown).
|
||||
"""
|
||||
session_id = session_config.get("session_id")
|
||||
export_settings = session_config.get("export_settings", {})
|
||||
tracks = session_config.get("tracks", [])
|
||||
|
||||
# Chuẩn bị metadata cho từng track
|
||||
tracks_meta = []
|
||||
for track in tracks:
|
||||
if track.get("muted", False):
|
||||
continue
|
||||
|
||||
file_id = track.get("file_id")
|
||||
|
||||
# Tìm file nguồn
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
raise FileNotFoundError(f"File not found: {file_id}")
|
||||
|
||||
tracks_meta.append({
|
||||
"file_path": file_path,
|
||||
"volume": track.get("volume", 1.0),
|
||||
"muted": False
|
||||
})
|
||||
|
||||
# Tạo tên file output
|
||||
output_format = export_settings.get("format", "wav")
|
||||
output_filename = f"{session_id}_mixed.{output_format}"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, output_filename)
|
||||
|
||||
# Thực hiện mix
|
||||
result = mix_multitrack_session(
|
||||
tracks_meta=tracks_meta,
|
||||
output_path=output_path,
|
||||
sample_rate=export_settings.get("sample_rate", 44100),
|
||||
bit_depth=export_settings.get("bit_depth", 16)
|
||||
)
|
||||
|
||||
result["output_file_id"] = output_filename
|
||||
result["session_id"] = session_id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def process_multitrack_session_task(session_config: dict):
|
||||
"""
|
||||
Task xử lý toàn bộ session với nhiều tracks và clips.
|
||||
Xử lý từng clip (với zero-crossing alignment), sau đó hòa âm tất cả tracks.
|
||||
"""
|
||||
session_id = session_config.get("session_id")
|
||||
export_settings = session_config.get("export_settings", {})
|
||||
tracks = session_config.get("tracks", [])
|
||||
|
||||
processed_tracks = []
|
||||
|
||||
# Xử lý từng track
|
||||
for track in tracks:
|
||||
if track.get("muted", False):
|
||||
continue
|
||||
|
||||
track_id = track.get("track_id")
|
||||
file_id = track.get("file_id")
|
||||
clips = track.get("clips", [])
|
||||
|
||||
# Tìm file nguồn
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
source_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
source_path = upload_path
|
||||
else:
|
||||
raise FileNotFoundError(f"File not found: {file_id}")
|
||||
|
||||
# Xử lý clips nếu có
|
||||
if clips:
|
||||
clip = clips[0]
|
||||
|
||||
start_sec = clip.get("start_time_seconds")
|
||||
end_sec = clip.get("end_time_seconds")
|
||||
|
||||
# Áp dụng zero-crossing alignment nếu được yêu cầu
|
||||
if clip.get("apply_zero_crossing", True):
|
||||
start_sec = find_nearest_zero_crossing_file(source_path, start_sec)
|
||||
end_sec = find_nearest_zero_crossing_file(source_path, end_sec)
|
||||
|
||||
# Sử dụng hàm cut_and_loop_segment
|
||||
processed_segment = cut_and_loop_segment(
|
||||
file_path=source_path,
|
||||
start_sec=start_sec,
|
||||
end_sec=end_sec,
|
||||
loop_count=clip.get("loop_count", 1),
|
||||
fade_in_ms=clip.get("fade_in_ms", 150),
|
||||
fade_out_ms=clip.get("fade_out_ms", 150),
|
||||
volume_db_change=0.0
|
||||
)
|
||||
|
||||
# Lưu segment đã xử lý
|
||||
temp_filename = f"temp_{track_id}_{uuid.uuid4().hex[:8]}.wav"
|
||||
temp_path = os.path.join(settings.PROCESSED_DIR, temp_filename)
|
||||
processed_segment.export(temp_path, format="wav")
|
||||
|
||||
track_file_path = temp_path
|
||||
else:
|
||||
track_file_path = source_path
|
||||
|
||||
processed_tracks.append({
|
||||
"file_path": track_file_path,
|
||||
"volume": track.get("volume", 1.0),
|
||||
"muted": False
|
||||
})
|
||||
|
||||
# Hòa âm tất cả tracks
|
||||
output_format = export_settings.get("format", "wav")
|
||||
output_filename = f"{session_id}_final.{output_format}"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, output_filename)
|
||||
|
||||
result = mix_multitrack_session(
|
||||
tracks_meta=processed_tracks,
|
||||
output_path=output_path,
|
||||
sample_rate=export_settings.get("sample_rate", 44100),
|
||||
bit_depth=export_settings.get("bit_depth", 16)
|
||||
)
|
||||
|
||||
# Dọn dẹp các file tạm
|
||||
for track in processed_tracks:
|
||||
if "temp_" in os.path.basename(track["file_path"]):
|
||||
try:
|
||||
os.remove(track["file_path"])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove temp file %s: %s", track["file_path"], e)
|
||||
|
||||
result["output_file_id"] = output_filename
|
||||
result["session_id"] = session_id
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@celery_app.task
|
||||
def cleanup_expired_files_task(max_age_hours: int = 24):
|
||||
"""
|
||||
Task tự động dọn dẹp các tệp kết xuất hết hạn (Week 5).
|
||||
Xóa file trong thư mục processed cũ hơn max_age_hours giờ.
|
||||
"""
|
||||
now = time.time()
|
||||
max_age_seconds = max_age_hours * 3600
|
||||
|
||||
cleaned_count = 0
|
||||
cleaned_size = 0
|
||||
|
||||
for directory in [settings.PROCESSED_DIR]:
|
||||
if not os.path.exists(directory):
|
||||
continue
|
||||
|
||||
for filepath in glob.glob(os.path.join(directory, "*")):
|
||||
if os.path.isfile(filepath):
|
||||
file_age = now - os.path.getmtime(filepath)
|
||||
if file_age > max_age_seconds:
|
||||
file_size = os.path.getsize(filepath)
|
||||
try:
|
||||
os.remove(filepath)
|
||||
cleaned_count += 1
|
||||
cleaned_size += file_size
|
||||
except Exception as e:
|
||||
logger.warning("Failed to remove expired file %s: %s", filepath, e)
|
||||
|
||||
return {
|
||||
"cleaned_files": cleaned_count,
|
||||
"cleaned_size_mb": round(cleaned_size / (1024 * 1024), 2),
|
||||
"max_age_hours": max_age_hours
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user