First commit

This commit is contained in:
2026-07-18 15:09:05 +07:00
parent 0e8693882b
commit a34b01a035
29 changed files with 7400 additions and 0 deletions
View File
View File
View File
+174
View File
@@ -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
}
+78
View File
@@ -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"
}
+19
View File
@@ -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
+16
View File
@@ -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()
View File
+239
View File
@@ -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}
+270
View File
@@ -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)
}
+169
View File
@@ -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
View File
@@ -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)
View File
View File
View File
+305
View File
@@ -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