FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel

This commit is contained in:
2026-08-03 15:47:10 +07:00
parent 6f55d36085
commit a9da813cb1
28 changed files with 2076 additions and 233 deletions
+112 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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("""
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -1
View File
@@ -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 })
});
}
+1
View File
@@ -0,0 +1 @@
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
+1 -1
View File
@@ -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 {
+20
View File
@@ -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}")
+66
View File
@@ -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()