FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel
This commit is contained in:
+112
-10
@@ -1,7 +1,16 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
||||
headers: Dict[str, str] = {}
|
||||
body: Dict[str, Any] = {}
|
||||
|
||||
import json
|
||||
|
||||
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
]
|
||||
# Private ranges: only reachable when the target host is one the user has
|
||||
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||
_PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # ULA
|
||||
]
|
||||
|
||||
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||
|
||||
|
||||
def _configured_ai_hosts(user_id: str) -> set:
|
||||
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||
hosts = set()
|
||||
configs = _load_ai_configs()
|
||||
providers = configs.get(user_id) or _get_default_providers()
|
||||
for p in providers:
|
||||
base = (p.get("api_base_url") or "").strip()
|
||||
if not base:
|
||||
continue
|
||||
try:
|
||||
host = urlparse(base).hostname
|
||||
if host:
|
||||
hosts.add(host.lower())
|
||||
except Exception:
|
||||
continue
|
||||
return hosts
|
||||
|
||||
|
||||
async def _resolve_host_ips(hostname: str):
|
||||
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||
ips = []
|
||||
for info in infos:
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(info[4][0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ips
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _validate_target_url(url: str, user_id: str):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||
|
||||
allowed_hosts = _configured_ai_hosts(user_id)
|
||||
|
||||
# Hostname-level fast path for loopback hosts
|
||||
if hostname in _LOOPBACK_HOSTS:
|
||||
if hostname in allowed_hosts:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
# Try direct IP parse (hostname may itself be an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
ips = [ip]
|
||||
except ValueError:
|
||||
ips = await _resolve_host_ips(hostname)
|
||||
|
||||
if not ips:
|
||||
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||
|
||||
for ip in ips:
|
||||
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||
if hostname in allowed_hosts:
|
||||
continue
|
||||
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
|
||||
@router.post("/proxy")
|
||||
async def proxy_llm(req: ProxyRequest):
|
||||
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||
await _validate_target_url(req.url, current_user["user_id"])
|
||||
# Never forward the app's own auth token upstream.
|
||||
headers = {
|
||||
k: v for k, v in req.headers.items()
|
||||
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
req.url,
|
||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
||||
json=req.body
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||
raw = resp.text
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||
raise HTTPException(status_code=502, detail=msg)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from app.config import settings
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
from app.api.v1.projects import get_optional_user
|
||||
from app.models.user import get_db_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
def _safe_file_id(file_id: str) -> str:
|
||||
"""Strip any path components from a client-supplied file id."""
|
||||
if not file_id:
|
||||
return ""
|
||||
return os.path.basename(file_id.replace("\\", "/"))
|
||||
|
||||
def _resolve_storage_path(file_id: str) -> str:
|
||||
"""Return the existing file path (processed first, then uploads) for a
|
||||
sanitized file id, or '' when not found."""
|
||||
fid = _safe_file_id(file_id)
|
||||
if not fid:
|
||||
return ""
|
||||
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||
p = os.path.join(d, fid)
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
file_id: str
|
||||
cut_start_ms: Optional[float] = None
|
||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
ext = os.path.splitext(file.filename or "")[1]
|
||||
if not ext:
|
||||
ext = ".wav"
|
||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||
# WAV into RAM and bounds disk usage).
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
size = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||
f.close()
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||
f.write(chunk)
|
||||
|
||||
# Save original filename as sidecar metadata
|
||||
import json
|
||||
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
||||
|
||||
@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):
|
||||
if not _resolve_storage_path(req.file_id):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import edit_audio_task
|
||||
task = edit_audio_task.delay(req.dict())
|
||||
task = edit_audio_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id
|
||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
||||
|
||||
@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")
|
||||
path = _resolve_storage_path(file_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||
|
||||
@router.get("/waveform/{file_id}")
|
||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
||||
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:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_peak_waveform
|
||||
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
||||
"""
|
||||
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:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_rms_waveform
|
||||
@@ -159,26 +175,20 @@ 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:
|
||||
file_path = _resolve_storage_path(req.file_id)
|
||||
if not file_path:
|
||||
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,
|
||||
file_id=_safe_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
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/export")
|
||||
@@ -186,19 +196,13 @@ 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:
|
||||
source_path = _resolve_storage_path(req.file_id)
|
||||
if not source_path:
|
||||
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,
|
||||
file_id=_safe_file_id(req.file_id),
|
||||
format=req.format,
|
||||
sample_rate=req.sample_rate,
|
||||
bit_depth=req.bit_depth
|
||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/ai-scan")
|
||||
async def ai_scan_audio(req: AIScanRequest):
|
||||
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
"""
|
||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||
"""
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
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
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
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
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.python_tools_engine import PythonToolsEngine
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
|
||||
+79
-14
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
import threading
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from app.models.user import get_db_connection
|
||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
||||
from app.core.auth import (
|
||||
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||
COOKIE_NAME, X_AUTH_HEADER,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||
_LOGIN_LOCK = threading.Lock()
|
||||
MAX_LOGIN_ATTEMPTS = 10
|
||||
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||
LOGIN_BLOCK_SEC = 900
|
||||
|
||||
def _check_login_ratelimit(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||
_LOGIN_FAILURES[ip] = stamps
|
||||
|
||||
def _record_login_failure(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||
stamps.append(now)
|
||||
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||
|
||||
def _record_login_success(ip: str):
|
||||
with _LOGIN_LOCK:
|
||||
_LOGIN_FAILURES.pop(ip, None)
|
||||
|
||||
def _set_auth_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
COOKIE_NAME, token,
|
||||
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||
# path="/" (default); secure flag set by proxy when behind TLS
|
||||
)
|
||||
|
||||
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||
token = None
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.split(" ")[1]
|
||||
elif x_auth_token:
|
||||
token = x_auth_token
|
||||
elif request.cookies.get(COOKIE_NAME):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||
token = authorization.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
||||
)
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
async def login(req: LoginRequest, request: Request):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_login_ratelimit(client_ip)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
||||
conn.close()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
if not verify_password(password, user["hashed_password"]):
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||
|
||||
return {
|
||||
_record_login_success(client_ip)
|
||||
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
||||
"role": user["role"],
|
||||
"must_change_password": bool(user["must_change_password"])
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
def _validate_password_strength(password: str):
|
||||
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||
lowered = password.lower()
|
||||
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest):
|
||||
async def register(req: RegisterRequest, request: Request):
|
||||
username = req.username.strip()
|
||||
email = req.email.strip()
|
||||
password = req.password.strip()
|
||||
_validate_password_strength(password)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
||||
conn.close()
|
||||
|
||||
token = create_token(user_id, username, "standard", False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
||||
"role": "standard",
|
||||
"must_change_password": False
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
||||
conn.close()
|
||||
|
||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"message": "Đổi mật khẩu thành công!",
|
||||
"access_token": new_token
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, new_token)
|
||||
return resp
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||
|
||||
+6
-4
@@ -2,9 +2,11 @@ import os
|
||||
import platform
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MEDIA_EXTS = {
|
||||
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
|
||||
|
||||
|
||||
@router.get("/computer")
|
||||
async def list_computer_roots():
|
||||
async def list_computer_roots(current_user: dict = Depends(get_current_user)):
|
||||
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
|
||||
system = platform.system()
|
||||
roots = []
|
||||
@@ -97,7 +99,7 @@ async def list_computer_roots():
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse_directory(path: str = Query(...)):
|
||||
async def browse_directory(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def serve_local_file(path: str = Query(...)):
|
||||
async def serve_local_file(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isfile(resolved):
|
||||
|
||||
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
||||
|
||||
# Gửi task xuống Celery Worker
|
||||
from app.tasks.worker import mix_multitrack_task
|
||||
task = mix_multitrack_task.delay(req.dict())
|
||||
task = mix_multitrack_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
|
||||
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())
|
||||
task = process_multitrack_session_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
|
||||
+21
-4
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
|
||||
from app.core.soundfont_inspector import SoundFontInspector
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -79,11 +79,23 @@ async def upload_soundfont(
|
||||
background_tasks: BackgroundTasks = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
||||
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if not PluginManager.validate_sf2_header(contents):
|
||||
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||
# 500MB; reading the whole body into RAM would OOM the server).
|
||||
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
||||
contents = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
contents.extend(chunk)
|
||||
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
||||
|
||||
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
@@ -178,8 +190,13 @@ async def render_project(
|
||||
req: RenderRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
engine = PythonRenderEngine()
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
|
||||
# Prevent path traversal: strip any directory components and force .wav.
|
||||
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
||||
if not safe_name.lower().endswith(".wav"):
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
|
||||
+21
-4
@@ -283,13 +283,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
exists = cursor.fetchone()
|
||||
if not exists:
|
||||
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||
|
||||
|
||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||
|
||||
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||
# update bypassed the quota entirely).
|
||||
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||
quota_row = cursor.fetchone()
|
||||
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||
used_row = cursor.fetchone()
|
||||
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||
max_bytes = storage_limit_mb * 1024 * 1024
|
||||
if used_bytes + new_size_bytes > max_bytes:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("""
|
||||
|
||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
configs = _load_ai_configs()
|
||||
configs[uid] = [p.dict() for p in req.providers]
|
||||
configs[uid] = [p.model_dump() for p in req.providers]
|
||||
_save_all(ai_configs=configs)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
||||
from app.models.user import get_db_connection
|
||||
from app.config import settings
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
||||
COOKIE_NAME = "sf_token"
|
||||
X_AUTH_HEADER = "X-Auth-Token"
|
||||
|
||||
def _load_or_create_secret_key() -> str:
|
||||
"""Persistent random SECRET_KEY.
|
||||
|
||||
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
|
||||
first run). Never falls back to a hardcoded value: a known secret lets
|
||||
anyone forge admin tokens.
|
||||
"""
|
||||
env_key = os.getenv("SECRET_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
|
||||
try:
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
if os.path.exists(key_file):
|
||||
with open(key_file, "r") as f:
|
||||
key = f.read().strip()
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
key = secrets.token_hex(32)
|
||||
with open(key_file, "w") as f:
|
||||
f.write(key)
|
||||
return key
|
||||
except Exception:
|
||||
# Last resort: ephemeral random key (all tokens invalid on restart).
|
||||
return secrets.token_hex(32)
|
||||
|
||||
SECRET_KEY = _load_or_create_secret_key()
|
||||
|
||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||
"""
|
||||
|
||||
+43
-21
@@ -1,4 +1,4 @@
|
||||
import os, logging
|
||||
import os, logging, math
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import scipy.signal as signal
|
||||
@@ -87,12 +87,18 @@ class PythonRenderEngine:
|
||||
return url_or_id
|
||||
return url_or_id
|
||||
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||
tracks = session.get("tracks", [])
|
||||
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||
|
||||
_channel_counter = 0
|
||||
|
||||
for track in session.get("tracks", []):
|
||||
for track in tracks:
|
||||
if solo_ids and track.get("id") not in solo_ids:
|
||||
continue
|
||||
track_type = track.get("type", "AUDIO")
|
||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
@@ -123,8 +129,17 @@ class PythonRenderEngine:
|
||||
try:
|
||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||
if sr != self.sample_rate:
|
||||
# Resampling fallback if simple, otherwise skip
|
||||
pass
|
||||
# Proper resampling: previously a silent no-op that
|
||||
# played 48kHz audio at the wrong speed/pitch.
|
||||
from scipy.signal import resample_poly
|
||||
g = math.gcd(sr, self.sample_rate)
|
||||
audio_data = resample_poly(
|
||||
audio_data,
|
||||
up=self.sample_rate // g,
|
||||
down=sr // g,
|
||||
axis=-1,
|
||||
)
|
||||
sr = self.sample_rate
|
||||
|
||||
# Handle channel mapping (Mono/Stereo)
|
||||
if len(audio_data.shape) == 1:
|
||||
@@ -148,7 +163,7 @@ class PythonRenderEngine:
|
||||
if actual_len > 0:
|
||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
||||
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||
|
||||
elif item_type == "MIDI_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
@@ -284,21 +299,27 @@ class PythonRenderEngine:
|
||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
||||
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||
|
||||
elif item_type == "SECTION_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
sec_id = source_data.get("referenced_section_id", "")
|
||||
if sec_id and sec_id in section_store:
|
||||
# Render nested section recursively
|
||||
sec_container = section_store[sec_id]
|
||||
sec_buffer = self.render_session_container(
|
||||
session=sec_container,
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
)
|
||||
# Render nested section recursively, cached per section id
|
||||
# so repeated section instances don't re-render every time.
|
||||
cache = _cache if _cache is not None else {}
|
||||
if sec_id in cache:
|
||||
sec_buffer = cache[sec_id]
|
||||
else:
|
||||
sec_buffer = self.render_session_container(
|
||||
session=section_store[sec_id],
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples,
|
||||
_cache=cache,
|
||||
)
|
||||
cache[sec_id] = sec_buffer
|
||||
|
||||
# Apply non-destructive crop/slicing on section buffer
|
||||
if offset_sample < total_samples:
|
||||
@@ -327,7 +348,7 @@ class PythonRenderEngine:
|
||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||
else:
|
||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||
try:
|
||||
@@ -341,14 +362,14 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = track_buffer[ch, indices]
|
||||
track_buffer = dry + wet * 0.5
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
if HAS_PEDALBOARD:
|
||||
try:
|
||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||
else:
|
||||
# Fallback reverb using exponentially decaying noise room impulse response
|
||||
try:
|
||||
@@ -368,7 +389,7 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = conv
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
# Process track volume
|
||||
if HAS_PEDALBOARD:
|
||||
@@ -410,7 +431,8 @@ class PythonRenderEngine:
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
total_samples=total_samples,
|
||||
_cache={},
|
||||
)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
|
||||
@@ -280,31 +280,37 @@ class SoundFontConverter:
|
||||
@staticmethod
|
||||
def _sf3_plays_audio(path: str) -> bool:
|
||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||
against shipping malformed SF3 files that silently play nothing)."""
|
||||
against shipping malformed SF3 files that silently play nothing).
|
||||
|
||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||
high-level Synth() class does not exist in this binding, so it is never
|
||||
used here.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
import fluidsynth
|
||||
import fluidsynth as _fs
|
||||
import numpy as np
|
||||
fl = fluidsynth.Synth()
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fl = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
h = fl.sfload(path)
|
||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||
if h < 0:
|
||||
return False
|
||||
fl.program_select(0, h, 0, 0)
|
||||
fl.noteon(0, 60, 100)
|
||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||
frames = 8820 # 0.2s
|
||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||
fluidsynth._fl.fluid_synth_write_float(
|
||||
fl.synth, frames, buf.ctypes.data, 0, 1,
|
||||
_fs.fluid_synth_write_float(
|
||||
_fl, frames, buf.ctypes.data, 0, 1,
|
||||
buf.ctypes.data + frames * 4, 0, 1
|
||||
)
|
||||
fl.noteoff(0, 60)
|
||||
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||
return rms > 1e-4
|
||||
finally:
|
||||
try:
|
||||
fl.delete()
|
||||
_fs.delete_fluid_synth(_fl)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
+59
-39
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
import numpy as np
|
||||
import functools
|
||||
from ctypes import c_int, c_char_p, c_void_p
|
||||
from ctypes import c_char_p
|
||||
|
||||
def midi_note_to_freq(note_number: int) -> float:
|
||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
|
||||
return _PLUGIN_MANAGER_INSTANCE
|
||||
|
||||
def load_soundfont_cached(path: str):
|
||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
||||
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||
|
||||
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||
do not exist in this binding, so they are never used here.
|
||||
"""
|
||||
global _FLUID_CACHE
|
||||
if not HAS_PYFLUIDSYNTH:
|
||||
return None
|
||||
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
|
||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||
return fl
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
||||
font_id = fl.sfload(path)
|
||||
fl.program_select(0, font_id, 0, 0)
|
||||
import fluidsynth as _fs
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||
fl = _fs.new_fluid_synth(_settings)
|
||||
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||
if font_id < 0:
|
||||
_fs.delete_fluid_synth(fl)
|
||||
return None
|
||||
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||
_FLUID_CACHE[path] = (fl, 1)
|
||||
return fl
|
||||
except Exception:
|
||||
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
|
||||
fl, ref = _FLUID_CACHE[path]
|
||||
if ref <= 1:
|
||||
try:
|
||||
fl.delete()
|
||||
import fluidsynth as _fs
|
||||
_fs.delete_fluid_synth(fl)
|
||||
except Exception:
|
||||
pass
|
||||
del _FLUID_CACHE[path]
|
||||
@@ -245,38 +256,47 @@ class PluginManager:
|
||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||
path = os.path.join(d, f)
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.Synth()
|
||||
fid = fl.sfload(path)
|
||||
if fid < 0:
|
||||
fl.delete()
|
||||
continue
|
||||
presets = []
|
||||
_fl = fluidsynth._fl
|
||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
||||
_fl.fluid_preset_get_name.restype = c_char_p
|
||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
||||
if sfont_ptr:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
name_val = c_char_p(name_ptr).value
|
||||
if name_val:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": name_val.decode("utf-8", errors="replace")
|
||||
})
|
||||
fl.delete()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
import fluidsynth as _fs
|
||||
# Low-level CFFI API (same as render_engine); never use
|
||||
# the high-level Synth() class that this binding lacks.
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_synth = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||
if fid < 0:
|
||||
continue
|
||||
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||
presets = []
|
||||
if sfont:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
try:
|
||||
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
if hasattr(_fs, "ffi"):
|
||||
raw = _fs.ffi.string(name_ptr)
|
||||
else:
|
||||
raw = c_char_p(name_ptr).value
|
||||
if raw:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": raw.decode("utf-8", errors="replace")
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
finally:
|
||||
try:
|
||||
_fs.delete_fluid_synth(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
import traceback; traceback.print_exc()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
||||
|
||||
+24
-22
@@ -1,8 +1,11 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
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
|
||||
@@ -22,16 +25,32 @@ from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine")
|
||||
_SF_SCANNER_STOP = None
|
||||
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
seed_admin()
|
||||
scanner = SoundFontAutoScanner()
|
||||
global _SF_SCANNER_STOP
|
||||
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||
yield
|
||||
# Shutdown
|
||||
if _SF_SCANNER_STOP is not None:
|
||||
_SF_SCANNER_STOP.set()
|
||||
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -55,22 +74,6 @@ app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@app.on_event("startup")
|
||||
async def startup_seed_admin():
|
||||
seed_admin()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_convert_soundfonts():
|
||||
# SF2 -> SF3 conversion is disabled: the client FluidSynth WASM cannot decode
|
||||
# Ogg Vorbis (SF3) samples, so converted SF3s would play silence. The download
|
||||
# endpoint serves SF2 when available and converts SF3 -> SF2 on demand instead.
|
||||
pass
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_sf_scanner():
|
||||
scanner = SoundFontAutoScanner()
|
||||
scanner.start_background(interval=30)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
@@ -80,12 +83,11 @@ async def get_index():
|
||||
with open(index_path, "r", encoding="utf-8") as file:
|
||||
return HTMLResponse(content=file.read(), status_code=200)
|
||||
|
||||
|
||||
@app.get("/favicon.svg")
|
||||
async def get_favicon():
|
||||
import os
|
||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||
if os.path.exists(favicon_path):
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||
return HTMLResponse(content="", status_code=404)
|
||||
|
||||
|
||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.config import settings
|
||||
|
||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||
# dev database is never touched by the test suite.
|
||||
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
|
||||
def get_db_connection():
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||
# cleanup consistent when users are deleted.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
@@ -89,6 +95,16 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
|
||||
# Placeholder user for anonymous autosave: projects are saved with
|
||||
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||
if not cursor.fetchone():
|
||||
import secrets as _secrets
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||
""", (_secrets.token_hex(32), time.time()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
+87
-8
@@ -62,7 +62,37 @@ const assignTrackMidiChannel = (track, tracks) => {
|
||||
// Storage for server-side file IDs mapped to track IDs
|
||||
let serverFileIdMap = {};
|
||||
let audioCtx;
|
||||
let masterBus = null; // { input, compressor, analyser, output, masteringActive }
|
||||
let masterBus = null; // { input, compressor, analyser, output, masteringActive, dryInput, dryOutput }
|
||||
|
||||
// Per-track mastering-bypass state (trackId -> bool), kept in sync with the
|
||||
// tracks state so ANY audio path can route without holding the track object.
|
||||
const trackMasteringBypassMap = {};
|
||||
|
||||
// Build the dual routing for one track: routeGain -> mastering chain (normal),
|
||||
// dryGain -> dry bus (bypass). Gains start at complementary 1/0 values.
|
||||
function createMasteringRoute(ctx, track, bus) {
|
||||
const bypass = !!(track && track.masteringBypass);
|
||||
const routeGain = ctx.createGain();
|
||||
const dryGain = ctx.createGain();
|
||||
const masterDest = bus ? bus.input : ctx.destination;
|
||||
const dryDest = (bus && bus.dryInput) ? bus.dryInput : ctx.destination;
|
||||
routeGain.gain.value = bypass ? 0 : 1;
|
||||
dryGain.gain.value = bypass ? 1 : 0;
|
||||
routeGain.connect(masterDest);
|
||||
dryGain.connect(dryDest);
|
||||
return { routeGain, dryGain };
|
||||
}
|
||||
|
||||
// Live-toggle a route with a short crossfade (click-free).
|
||||
function setMasteringRoute(route, bypass) {
|
||||
if (!route || !audioCtx) return;
|
||||
const t = audioCtx.currentTime;
|
||||
const on = !!bypass;
|
||||
route.routeGain.gain.cancelScheduledValues(t);
|
||||
route.dryGain.gain.cancelScheduledValues(t);
|
||||
route.routeGain.gain.setTargetAtTime(on ? 0 : 1, t, 0.02);
|
||||
route.dryGain.gain.setTargetAtTime(on ? 1 : 0, t, 0.02);
|
||||
}
|
||||
|
||||
function makeDistortionCurve(k) {
|
||||
const n_samples = 44100;
|
||||
@@ -288,6 +318,15 @@ function initMasterBus(ctx) {
|
||||
const output = ctx.createGain();
|
||||
output.gain.value = 1.0;
|
||||
|
||||
// Per-track mastering-bypass dry bus: tracks with bypass ON feed into
|
||||
// dryInput -> dryOutput -> output, skipping the mastering modules
|
||||
// (EQ / Imager / Maximizer) while still passing the master volume fader
|
||||
// and the master output metering.
|
||||
const dryInput = ctx.createGain();
|
||||
const dryOutput = ctx.createGain();
|
||||
dryInput.connect(dryOutput);
|
||||
dryOutput.connect(output);
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
|
||||
@@ -307,6 +346,8 @@ function initMasterBus(ctx) {
|
||||
analyser,
|
||||
output,
|
||||
masteringActive: false,
|
||||
dryInput,
|
||||
dryOutput,
|
||||
|
||||
// Analysers for metering
|
||||
inputAnalyser,
|
||||
@@ -754,6 +795,7 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
const dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB';
|
||||
const isMuted = track.muted;
|
||||
const isSoloed = track.solo;
|
||||
const isBypassed = track.masteringBypass;
|
||||
const vol = track.volumeDb != null ? track.volumeDb : 0;
|
||||
var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100));
|
||||
var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e';
|
||||
@@ -776,7 +818,17 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
||||
title: "Solo",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "S")),
|
||||
}, "S"), React.createElement("button", {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
const next = !track.masteringBypass;
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { masteringBypass: next });
|
||||
// Live audio re-route (applies immediately to playing tracks).
|
||||
if (window.__setTrackMasteringBypass) window.__setTrackMasteringBypass(track.id, next);
|
||||
},
|
||||
title: "Bypass Mastering: bật thì track KHÔNG qua EQ/Imager/Maximizer ở Main out",
|
||||
className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isBypassed ? 'bg-sky-400 text-black border-sky-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')
|
||||
}, "B")),
|
||||
React.createElement("div", {
|
||||
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
|
||||
}, React.createElement("div", {
|
||||
@@ -7986,6 +8038,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
pan: t.pan || 0.0,
|
||||
mute: t.muted || false,
|
||||
solo: t.solo || false,
|
||||
mastering_bypass: t.masteringBypass || false,
|
||||
instrument_id: t.instrumentId || null,
|
||||
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
|
||||
instrument_name: t.instrumentName || null,
|
||||
@@ -8061,6 +8114,7 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
|
||||
pan: t.pan || 0.0,
|
||||
muted: t.mute || false,
|
||||
solo: t.solo || false,
|
||||
masteringBypass: t.mastering_bypass || false,
|
||||
color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'),
|
||||
startTime: t.start_time || 0,
|
||||
height: t.height || 140,
|
||||
@@ -11720,6 +11774,20 @@ const App = () => {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Live per-track mastering bypass: updates the routing map + re-routes any
|
||||
// active track node immediately (called from MixerStrip's B button).
|
||||
window.__setTrackMasteringBypass = function(trackId, bypass) {
|
||||
trackMasteringBypassMap[trackId] = !!bypass;
|
||||
const node = activeTrackNodesRef.current[trackId];
|
||||
if (node && node.route) setMasteringRoute(node.route, !!bypass);
|
||||
};
|
||||
|
||||
// Keep the routing map in sync with the tracks state (loads, undo, AI ops…).
|
||||
useEffect(() => {
|
||||
(tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; });
|
||||
(sessionTabs || []).forEach(st => (st.tracks || []).forEach(t => { trackMasteringBypassMap[t.id] = !!t.masteringBypass; }));
|
||||
}, [tracks, sessionTabs]);
|
||||
window.__toggleMediaExplorerRef = function() {
|
||||
setShowMediaExplorer(function(p) {
|
||||
const next = !p;
|
||||
@@ -12413,6 +12481,10 @@ const App = () => {
|
||||
showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info');
|
||||
} catch(e) {
|
||||
console.warn('restoreLastSessionProject failed:', e);
|
||||
// Stale session id (project deleted / DB reset): clear it so the error
|
||||
// does not repeat on every page load.
|
||||
localStorage.removeItem('sonic_project_id');
|
||||
localStorage.removeItem('sonic_project_name');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15270,7 +15342,10 @@ const App = () => {
|
||||
source.connect(volumeGainNode);
|
||||
volumeGainNode.connect(pannerNode);
|
||||
pannerNode.connect(fadeGainNode);
|
||||
fadeGainNode.connect(masterBus ? masterBus.input : context.destination);
|
||||
// Route through mastering chain unless this track has mastering bypass ON.
|
||||
const route = createMasteringRoute(context, { masteringBypass: !!trackMasteringBypassMap[st.trackId] }, masterBus);
|
||||
fadeGainNode.connect(route.routeGain);
|
||||
fadeGainNode.connect(route.dryGain);
|
||||
source.start(context.currentTime, offsetBuffer);
|
||||
activeSourcesRef.current = [source];
|
||||
activeTrackNodesRef.current[st.trackId] = {
|
||||
@@ -15548,13 +15623,15 @@ const App = () => {
|
||||
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
||||
// Ensure master bus is initialized for MAIN OUT routing
|
||||
if (!masterBus) initMasterBus(context);
|
||||
// Route through master bus if available, else direct to destination
|
||||
const dest = masterBus ? masterBus.input : context.destination;
|
||||
|
||||
const analyserNode = context.createAnalyser();
|
||||
analyserNode.fftSize = 256;
|
||||
pannerNode.connect(analyserNode);
|
||||
analyserNode.connect(dest);
|
||||
// Dual mastering route: routeGain -> mastering chain (normal), dryGain ->
|
||||
// dry bus (bypass). Live-toggled via setMasteringRoute(node.route, ...).
|
||||
const route = createMasteringRoute(context, track, masterBus);
|
||||
analyserNode.connect(route.routeGain);
|
||||
analyserNode.connect(route.dryGain);
|
||||
|
||||
let fxStopFn;
|
||||
if (track.fxType === 'chorus') {
|
||||
@@ -15570,7 +15647,7 @@ const App = () => {
|
||||
} else {
|
||||
gainNode.connect(pannerNode);
|
||||
}
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode };
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
||||
activeTrackNodesRef.current[track.id] = node;
|
||||
}
|
||||
return node.gainNode;
|
||||
@@ -18137,7 +18214,7 @@ const App = () => {
|
||||
id: 'midi_track_' + now + '_' + idx,
|
||||
name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')),
|
||||
buffer: null, startTime: 0, volumeDb: 0, pan: 0,
|
||||
muted: false, solo: false, color: colors[idx % colors.length],
|
||||
muted: false, solo: false, masteringBypass: false, color: colors[idx % colors.length],
|
||||
markers: [], serverFileId: null, clips: [], sections: [],
|
||||
midiItems: [midiItem],
|
||||
isArmed: false, monitoringEnabled: true,
|
||||
@@ -18199,6 +18276,7 @@ const App = () => {
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
masteringBypass: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
@@ -18386,6 +18464,7 @@ const App = () => {
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
masteringBypass: false,
|
||||
color: selectColor,
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -275,7 +275,10 @@ ${rules.join('\n')}` },
|
||||
} else {
|
||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||
},
|
||||
body: JSON.stringify({ url, headers, body })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||
@@ -24,7 +24,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608031430" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608031800" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
try:
|
||||
import fluidsynth
|
||||
print("fluidsynth import successful.")
|
||||
|
||||
fl = fluidsynth.Synth()
|
||||
# Try to load a pre-existing system SF3
|
||||
sf3_path = "/opt/daw_engine/soundfonts/Equinox_Grand_Pianos.sf3"
|
||||
print(f"Checking if {sf3_path} exists: {os.path.exists(sf3_path)}")
|
||||
if os.path.exists(sf3_path):
|
||||
h = fl.sfload(sf3_path)
|
||||
print(f"Loaded {sf3_path}, handle: {h}")
|
||||
else:
|
||||
print("Equinox_Grand_Pianos.sf3 not found.")
|
||||
except Exception as e:
|
||||
print(f"Failed: {e}")
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# Ensure app is in path
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("test_sf_convert")
|
||||
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
|
||||
def test():
|
||||
sf2_dir = "/app/app/storage/soundfonts"
|
||||
sf2_files = [os.path.join(sf2_dir, f) for f in os.listdir(sf2_dir) if f.endswith(".sf2") and "_decomp" not in f]
|
||||
if not sf2_files:
|
||||
logger.error("No SF2 files found in /app/app/storage/soundfonts")
|
||||
return
|
||||
|
||||
sf2_path = sf2_files[0]
|
||||
logger.info(f"Testing with SF2 file: {sf2_path}")
|
||||
|
||||
converter = SoundFontConverter()
|
||||
|
||||
# Check ffmpeg encoder support
|
||||
has_ogg = converter._check_ffmpeg_ogg()
|
||||
logger.info(f"ffmpeg with libvorbis available: {has_ogg}")
|
||||
|
||||
# Convert SF2 -> SF3
|
||||
sf3_path = sf2_path.replace(".sf2", ".sf3")
|
||||
if os.path.exists(sf3_path):
|
||||
os.remove(sf3_path)
|
||||
|
||||
logger.info("Converting SF2 -> SF3...")
|
||||
result_path = converter.convert_sf2_to_sf3(sf2_path)
|
||||
logger.info(f"Result path from convert_sf2_to_sf3: {result_path}")
|
||||
|
||||
if result_path.endswith(".sf3"):
|
||||
logger.info(f"SF3 file exists: {os.path.exists(sf3_path)}")
|
||||
if os.path.exists(sf3_path):
|
||||
logger.info(f"SF3 size: {os.path.getsize(sf3_path)} bytes")
|
||||
# Verify if it plays audio
|
||||
plays = converter._sf3_plays_audio(sf3_path)
|
||||
logger.info(f"SF3 plays audio (pyfluidsynth verify): {plays}")
|
||||
|
||||
# Now test decompression back to SF2
|
||||
decomp_sf2 = sf3_path.replace(".sf3", "_decomp.sf2")
|
||||
if os.path.exists(decomp_sf2):
|
||||
os.remove(decomp_sf2)
|
||||
|
||||
logger.info("Decompressing SF3 -> SF2...")
|
||||
try:
|
||||
decomp_result = converter.sf3_to_sf2(sf3_path, decomp_sf2)
|
||||
logger.info(f"Decompress result path: {decomp_result}")
|
||||
if os.path.exists(decomp_sf2):
|
||||
logger.info(f"Decompressed SF2 size: {os.path.getsize(decomp_sf2)} bytes")
|
||||
# Check if it plays
|
||||
decomp_plays = converter._sf3_plays_audio(decomp_sf2)
|
||||
logger.info(f"Decompressed SF2 plays audio: {decomp_plays}")
|
||||
except Exception as e:
|
||||
logger.error(f"Decompression failed: {e}", exc_info=True)
|
||||
else:
|
||||
logger.warning("Conversion did not produce an SF3 path.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test()
|
||||
Reference in New Issue
Block a user