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
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
|||||||
headers: Dict[str, str] = {}
|
headers: Dict[str, str] = {}
|
||||||
body: Dict[str, Any] = {}
|
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")
|
@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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||||
req.url,
|
|
||||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
|
||||||
json=req.body
|
|
||||||
)
|
|
||||||
raw = resp.text
|
raw = resp.text
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
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:
|
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)."
|
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)
|
raise HTTPException(status_code=502, detail=msg)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
from app.config import settings
|
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.api.v1.projects import get_optional_user
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
class EditRequest(BaseModel):
|
||||||
file_id: str
|
file_id: str
|
||||||
cut_start_ms: Optional[float] = None
|
cut_start_ms: Optional[float] = None
|
||||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
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"
|
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:
|
if not ext:
|
||||||
ext = ".wav"
|
ext = ".wav"
|
||||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
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:
|
with open(file_path, "wb") as f:
|
||||||
content = await file.read()
|
size = 0
|
||||||
f.write(content)
|
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
|
# Save original filename as sidecar metadata
|
||||||
import json
|
import json
|
||||||
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
|||||||
|
|
||||||
@router.post("/edit")
|
@router.post("/edit")
|
||||||
async def edit_audio(req: EditRequest):
|
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
|
# 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")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import edit_audio_task
|
from app.tasks.worker import edit_audio_task
|
||||||
task = edit_audio_task.delay(req.dict())
|
task = edit_audio_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id
|
"task_id": task.id
|
||||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
|||||||
|
|
||||||
@router.get("/download/{file_id}")
|
@router.get("/download/{file_id}")
|
||||||
async def download_audio(file_id: str):
|
async def download_audio(file_id: str):
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
path = _resolve_storage_path(file_id)
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if os.path.exists(processed_path):
|
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
@router.get("/waveform/{file_id}")
|
@router.get("/waveform/{file_id}")
|
||||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
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).
|
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.
|
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)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_peak_waveform
|
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).
|
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_rms_waveform
|
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).
|
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.
|
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)
|
file_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import analyze_ai_task
|
from app.tasks.worker import analyze_ai_task
|
||||||
task = analyze_ai_task.delay(
|
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,
|
api_base_url=req.api_base_url,
|
||||||
model=req.model
|
model=req.model
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/export")
|
@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).
|
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)
|
source_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not source_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
source_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
source_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import export_audio_task
|
from app.tasks.worker import export_audio_task
|
||||||
task = export_audio_task.delay(
|
task = export_audio_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
format=req.format,
|
format=req.format,
|
||||||
sample_rate=req.sample_rate,
|
sample_rate=req.sample_rate,
|
||||||
bit_depth=req.bit_depth
|
bit_depth=req.bit_depth
|
||||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/ai-scan")
|
@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.
|
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
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
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
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
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
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.
|
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
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
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
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"
|
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)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
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
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
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.
|
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"
|
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.python_tools_engine import PythonToolsEngine
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|||||||
+79
-14
@@ -1,10 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
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 pydantic import BaseModel, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.user import get_db_connection
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
_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ệ")
|
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)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
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")
|
@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()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not user or not user["is_active"]:
|
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")
|
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"]):
|
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")
|
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"])
|
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||||
|
_record_login_success(client_ip)
|
||||||
return {
|
|
||||||
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
|||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"must_change_password": bool(user["must_change_password"])
|
"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")
|
@router.post("/register")
|
||||||
async def register(req: RegisterRequest):
|
async def register(req: RegisterRequest, request: Request):
|
||||||
username = req.username.strip()
|
username = req.username.strip()
|
||||||
email = req.email.strip()
|
email = req.email.strip()
|
||||||
password = req.password.strip()
|
password = req.password.strip()
|
||||||
|
_validate_password_strength(password)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
token = create_token(user_id, username, "standard", False)
|
token = create_token(user_id, username, "standard", False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
|||||||
"role": "standard",
|
"role": "standard",
|
||||||
"must_change_password": False
|
"must_change_password": False
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
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()
|
conn.close()
|
||||||
|
|
||||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
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!",
|
"message": "Đổi mật khẩu thành công!",
|
||||||
"access_token": new_token
|
"access_token": new_token
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, new_token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||||
|
|||||||
+6
-4
@@ -2,9 +2,11 @@ import os
|
|||||||
import platform
|
import platform
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.v1.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
MEDIA_EXTS = {
|
MEDIA_EXTS = {
|
||||||
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/computer")
|
@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)."""
|
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
roots = []
|
roots = []
|
||||||
@@ -97,7 +99,7 @@ async def list_computer_roots():
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
@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."""
|
"""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)
|
resolved = _safe_path(path)
|
||||||
if not os.path.isdir(resolved):
|
if not os.path.isdir(resolved):
|
||||||
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/file")
|
@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."""
|
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
||||||
resolved = _safe_path(path)
|
resolved = _safe_path(path)
|
||||||
if not os.path.isfile(resolved):
|
if not os.path.isfile(resolved):
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
|||||||
|
|
||||||
# Gửi task xuống Celery Worker
|
# Gửi task xuống Celery Worker
|
||||||
from app.tasks.worker import mix_multitrack_task
|
from app.tasks.worker import mix_multitrack_task
|
||||||
task = mix_multitrack_task.delay(req.dict())
|
task = mix_multitrack_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"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.
|
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
|
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 {
|
return {
|
||||||
"task_id": task.id,
|
"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_inspector import SoundFontInspector
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
from app.core.soundfont_converter import SoundFontConverter
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -79,11 +79,23 @@ async def upload_soundfont(
|
|||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
current_user: dict = Depends(get_current_user)
|
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"))):
|
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")
|
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||||
|
|
||||||
contents = await file.read()
|
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||||
if not PluginManager.validate_sf2_header(contents):
|
# 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")
|
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||||
|
|
||||||
file_ext = os.path.splitext(file.filename)[1]
|
file_ext = os.path.splitext(file.filename)[1]
|
||||||
@@ -178,8 +190,13 @@ async def render_project(
|
|||||||
req: RenderRequest,
|
req: RenderRequest,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
engine = PythonRenderEngine()
|
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:
|
try:
|
||||||
result_path = engine.render_project(req.project_json, output_path)
|
result_path = engine.render_project(req.project_json, output_path)
|
||||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_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()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||||
exists = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
if not exists:
|
if not existing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
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"))
|
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()
|
now = time.time()
|
||||||
|
|
||||||
cursor.execute("""
|
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)):
|
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||||
uid = _get_user_id(authorization)
|
uid = _get_user_id(authorization)
|
||||||
configs = _load_ai_configs()
|
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)
|
_save_all(ai_configs=configs)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
|||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.config import settings
|
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:
|
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 numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import scipy.signal as signal
|
import scipy.signal as signal
|
||||||
@@ -87,12 +87,18 @@ class PythonRenderEngine:
|
|||||||
return url_or_id
|
return url_or_id
|
||||||
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)
|
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
|
_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_type = track.get("type", "AUDIO")
|
||||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
@@ -123,8 +129,17 @@ class PythonRenderEngine:
|
|||||||
try:
|
try:
|
||||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||||
if sr != self.sample_rate:
|
if sr != self.sample_rate:
|
||||||
# Resampling fallback if simple, otherwise skip
|
# Proper resampling: previously a silent no-op that
|
||||||
pass
|
# 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)
|
# Handle channel mapping (Mono/Stereo)
|
||||||
if len(audio_data.shape) == 1:
|
if len(audio_data.shape) == 1:
|
||||||
@@ -148,7 +163,7 @@ class PythonRenderEngine:
|
|||||||
if actual_len > 0:
|
if actual_len > 0:
|
||||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||||
except Exception as e:
|
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":
|
elif item_type == "MIDI_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
@@ -284,21 +299,27 @@ class PythonRenderEngine:
|
|||||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||||
|
|
||||||
elif item_type == "SECTION_ITEM":
|
elif item_type == "SECTION_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
sec_id = source_data.get("referenced_section_id", "")
|
sec_id = source_data.get("referenced_section_id", "")
|
||||||
if sec_id and sec_id in section_store:
|
if sec_id and sec_id in section_store:
|
||||||
# Render nested section recursively
|
# Render nested section recursively, cached per section id
|
||||||
sec_container = section_store[sec_id]
|
# so repeated section instances don't re-render every time.
|
||||||
sec_buffer = self.render_session_container(
|
cache = _cache if _cache is not None else {}
|
||||||
session=sec_container,
|
if sec_id in cache:
|
||||||
section_store=section_store,
|
sec_buffer = cache[sec_id]
|
||||||
bpm=bpm,
|
else:
|
||||||
time_sig_num=time_sig_num,
|
sec_buffer = self.render_session_container(
|
||||||
total_samples=total_samples
|
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
|
# Apply non-destructive crop/slicing on section buffer
|
||||||
if offset_sample < total_samples:
|
if offset_sample < total_samples:
|
||||||
@@ -327,7 +348,7 @@ class PythonRenderEngine:
|
|||||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||||
try:
|
try:
|
||||||
@@ -341,14 +362,14 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = track_buffer[ch, indices]
|
wet[ch, :] = track_buffer[ch, indices]
|
||||||
track_buffer = dry + wet * 0.5
|
track_buffer = dry + wet * 0.5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||||
elif fx_type == "reverb":
|
elif fx_type == "reverb":
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
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)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback reverb using exponentially decaying noise room impulse response
|
# Fallback reverb using exponentially decaying noise room impulse response
|
||||||
try:
|
try:
|
||||||
@@ -368,7 +389,7 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = conv
|
wet[ch, :] = conv
|
||||||
track_buffer = dry + wet * 0.4
|
track_buffer = dry + wet * 0.4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||||
|
|
||||||
# Process track volume
|
# Process track volume
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
@@ -410,7 +431,8 @@ class PythonRenderEngine:
|
|||||||
section_store=section_store,
|
section_store=section_store,
|
||||||
bpm=bpm,
|
bpm=bpm,
|
||||||
time_sig_num=time_sig_num,
|
time_sig_num=time_sig_num,
|
||||||
total_samples=total_samples
|
total_samples=total_samples,
|
||||||
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalization to prevent clipping
|
# Normalization to prevent clipping
|
||||||
|
|||||||
@@ -280,31 +280,37 @@ class SoundFontConverter:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _sf3_plays_audio(path: str) -> bool:
|
def _sf3_plays_audio(path: str) -> bool:
|
||||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
"""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):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
import numpy as np
|
import numpy as np
|
||||||
fl = fluidsynth.Synth()
|
_settings = _fs.new_fluid_settings()
|
||||||
|
_fl = _fs.new_fluid_synth(_settings)
|
||||||
try:
|
try:
|
||||||
h = fl.sfload(path)
|
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||||
if h < 0:
|
if h < 0:
|
||||||
return False
|
return False
|
||||||
fl.program_select(0, h, 0, 0)
|
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||||
fl.noteon(0, 60, 100)
|
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||||
frames = 8820 # 0.2s
|
frames = 8820 # 0.2s
|
||||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||||
fluidsynth._fl.fluid_synth_write_float(
|
_fs.fluid_synth_write_float(
|
||||||
fl.synth, frames, buf.ctypes.data, 0, 1,
|
_fl, frames, buf.ctypes.data, 0, 1,
|
||||||
buf.ctypes.data + frames * 4, 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)))
|
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||||
return rms > 1e-4
|
return rms > 1e-4
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
_fs.delete_fluid_synth(_fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+59
-39
@@ -2,7 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import functools
|
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:
|
def midi_note_to_freq(note_number: int) -> float:
|
||||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
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
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
|
|
||||||
def load_soundfont_cached(path: str):
|
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
|
global _FLUID_CACHE
|
||||||
if not HAS_PYFLUIDSYNTH:
|
if not HAS_PYFLUIDSYNTH:
|
||||||
return None
|
return None
|
||||||
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||||
return fl
|
return fl
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
_settings = _fs.new_fluid_settings()
|
||||||
font_id = fl.sfload(path)
|
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||||
fl.program_select(0, font_id, 0, 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)
|
_FLUID_CACHE[path] = (fl, 1)
|
||||||
return fl
|
return fl
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
|
|||||||
fl, ref = _FLUID_CACHE[path]
|
fl, ref = _FLUID_CACHE[path]
|
||||||
if ref <= 1:
|
if ref <= 1:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
import fluidsynth as _fs
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
del _FLUID_CACHE[path]
|
del _FLUID_CACHE[path]
|
||||||
@@ -245,38 +256,47 @@ class PluginManager:
|
|||||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||||
path = os.path.join(d, f)
|
path = os.path.join(d, f)
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.Synth()
|
# Low-level CFFI API (same as render_engine); never use
|
||||||
fid = fl.sfload(path)
|
# the high-level Synth() class that this binding lacks.
|
||||||
if fid < 0:
|
_settings = _fs.new_fluid_settings()
|
||||||
fl.delete()
|
_synth = _fs.new_fluid_synth(_settings)
|
||||||
continue
|
try:
|
||||||
presets = []
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||||
_fl = fluidsynth._fl
|
if fid < 0:
|
||||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
continue
|
||||||
_fl.fluid_preset_get_name.restype = c_char_p
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
presets = []
|
||||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
if sfont:
|
||||||
if sfont_ptr:
|
for bank in range(0, 2):
|
||||||
for bank in range(0, 2):
|
for prog_num in range(0, 128):
|
||||||
for prog_num in range(0, 128):
|
try:
|
||||||
try:
|
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
except Exception:
|
||||||
except Exception:
|
break
|
||||||
break
|
if preset:
|
||||||
if preset:
|
try:
|
||||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||||
if name_ptr:
|
if name_ptr:
|
||||||
name_val = c_char_p(name_ptr).value
|
if hasattr(_fs, "ffi"):
|
||||||
if name_val:
|
raw = _fs.ffi.string(name_ptr)
|
||||||
presets.append({
|
else:
|
||||||
"bank": bank,
|
raw = c_char_p(name_ptr).value
|
||||||
"program": prog_num,
|
if raw:
|
||||||
"name": name_val.decode("utf-8", errors="replace")
|
presets.append({
|
||||||
})
|
"bank": bank,
|
||||||
fl.delete()
|
"program": prog_num,
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
"name": raw.decode("utf-8", errors="replace")
|
||||||
return presets[:256]
|
})
|
||||||
|
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:
|
except Exception:
|
||||||
import traceback; traceback.print_exc()
|
import traceback; traceback.print_exc()
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
||||||
|
|||||||
+24
-22
@@ -1,8 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.audio import router as audio_router
|
from app.api.v1.audio import router as audio_router
|
||||||
from app.api.v1.tasks import router as tasks_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.UPLOADS_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.PROCESSED_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)
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def get_index():
|
async def get_index():
|
||||||
@@ -80,12 +83,11 @@ async def get_index():
|
|||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
return HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def get_favicon():
|
async def get_favicon():
|
||||||
import os
|
|
||||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||||
if os.path.exists(favicon_path):
|
if os.path.exists(favicon_path):
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||||
return HTMLResponse(content="", status_code=404)
|
return HTMLResponse(content="", status_code=404)
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from app.config import settings
|
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():
|
def get_db_connection():
|
||||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
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
|
return conn
|
||||||
|
|
||||||
def init_db():
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+87
-8
@@ -62,7 +62,37 @@ const assignTrackMidiChannel = (track, tracks) => {
|
|||||||
// Storage for server-side file IDs mapped to track IDs
|
// Storage for server-side file IDs mapped to track IDs
|
||||||
let serverFileIdMap = {};
|
let serverFileIdMap = {};
|
||||||
let audioCtx;
|
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) {
|
function makeDistortionCurve(k) {
|
||||||
const n_samples = 44100;
|
const n_samples = 44100;
|
||||||
@@ -288,6 +318,15 @@ function initMasterBus(ctx) {
|
|||||||
const output = ctx.createGain();
|
const output = ctx.createGain();
|
||||||
output.gain.value = 1.0;
|
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();
|
const analyser = ctx.createAnalyser();
|
||||||
analyser.fftSize = 256;
|
analyser.fftSize = 256;
|
||||||
|
|
||||||
@@ -307,6 +346,8 @@ function initMasterBus(ctx) {
|
|||||||
analyser,
|
analyser,
|
||||||
output,
|
output,
|
||||||
masteringActive: false,
|
masteringActive: false,
|
||||||
|
dryInput,
|
||||||
|
dryOutput,
|
||||||
|
|
||||||
// Analysers for metering
|
// Analysers for metering
|
||||||
inputAnalyser,
|
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 dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB';
|
||||||
const isMuted = track.muted;
|
const isMuted = track.muted;
|
||||||
const isSoloed = track.solo;
|
const isSoloed = track.solo;
|
||||||
|
const isBypassed = track.masteringBypass;
|
||||||
const vol = track.volumeDb != null ? track.volumeDb : 0;
|
const vol = track.volumeDb != null ? track.volumeDb : 0;
|
||||||
var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100));
|
var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100));
|
||||||
var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e';
|
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 }); },
|
onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); },
|
||||||
title: "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')
|
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", {
|
React.createElement("div", {
|
||||||
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
|
className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"
|
||||||
}, React.createElement("div", {
|
}, React.createElement("div", {
|
||||||
@@ -7986,6 +8038,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
|||||||
pan: t.pan || 0.0,
|
pan: t.pan || 0.0,
|
||||||
mute: t.muted || false,
|
mute: t.muted || false,
|
||||||
solo: t.solo || false,
|
solo: t.solo || false,
|
||||||
|
mastering_bypass: t.masteringBypass || false,
|
||||||
instrument_id: t.instrumentId || null,
|
instrument_id: t.instrumentId || null,
|
||||||
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
|
instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null,
|
||||||
instrument_name: t.instrumentName || null,
|
instrument_name: t.instrumentName || null,
|
||||||
@@ -8061,6 +8114,7 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
|
|||||||
pan: t.pan || 0.0,
|
pan: t.pan || 0.0,
|
||||||
muted: t.mute || false,
|
muted: t.mute || false,
|
||||||
solo: t.solo || false,
|
solo: t.solo || false,
|
||||||
|
masteringBypass: t.mastering_bypass || false,
|
||||||
color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'),
|
color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'),
|
||||||
startTime: t.start_time || 0,
|
startTime: t.start_time || 0,
|
||||||
height: t.height || 140,
|
height: t.height || 140,
|
||||||
@@ -11720,6 +11774,20 @@ const App = () => {
|
|||||||
return next;
|
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() {
|
window.__toggleMediaExplorerRef = function() {
|
||||||
setShowMediaExplorer(function(p) {
|
setShowMediaExplorer(function(p) {
|
||||||
const next = !p;
|
const next = !p;
|
||||||
@@ -12413,6 +12481,10 @@ const App = () => {
|
|||||||
showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info');
|
showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info');
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.warn('restoreLastSessionProject failed:', 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);
|
source.connect(volumeGainNode);
|
||||||
volumeGainNode.connect(pannerNode);
|
volumeGainNode.connect(pannerNode);
|
||||||
pannerNode.connect(fadeGainNode);
|
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);
|
source.start(context.currentTime, offsetBuffer);
|
||||||
activeSourcesRef.current = [source];
|
activeSourcesRef.current = [source];
|
||||||
activeTrackNodesRef.current[st.trackId] = {
|
activeTrackNodesRef.current[st.trackId] = {
|
||||||
@@ -15548,13 +15623,15 @@ const App = () => {
|
|||||||
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
||||||
// Ensure master bus is initialized for MAIN OUT routing
|
// Ensure master bus is initialized for MAIN OUT routing
|
||||||
if (!masterBus) initMasterBus(context);
|
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();
|
const analyserNode = context.createAnalyser();
|
||||||
analyserNode.fftSize = 256;
|
analyserNode.fftSize = 256;
|
||||||
pannerNode.connect(analyserNode);
|
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;
|
let fxStopFn;
|
||||||
if (track.fxType === 'chorus') {
|
if (track.fxType === 'chorus') {
|
||||||
@@ -15570,7 +15647,7 @@ const App = () => {
|
|||||||
} else {
|
} else {
|
||||||
gainNode.connect(pannerNode);
|
gainNode.connect(pannerNode);
|
||||||
}
|
}
|
||||||
node = { gainNode, pannerNode, fxStopFn, analyserNode };
|
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
||||||
activeTrackNodesRef.current[track.id] = node;
|
activeTrackNodesRef.current[track.id] = node;
|
||||||
}
|
}
|
||||||
return node.gainNode;
|
return node.gainNode;
|
||||||
@@ -18137,7 +18214,7 @@ const App = () => {
|
|||||||
id: 'midi_track_' + now + '_' + idx,
|
id: 'midi_track_' + now + '_' + idx,
|
||||||
name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')),
|
name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')),
|
||||||
buffer: null, startTime: 0, volumeDb: 0, pan: 0,
|
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: [],
|
markers: [], serverFileId: null, clips: [], sections: [],
|
||||||
midiItems: [midiItem],
|
midiItems: [midiItem],
|
||||||
isArmed: false, monitoringEnabled: true,
|
isArmed: false, monitoringEnabled: true,
|
||||||
@@ -18199,6 +18276,7 @@ const App = () => {
|
|||||||
pan: 0,
|
pan: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
solo: false,
|
solo: false,
|
||||||
|
masteringBypass: false,
|
||||||
color: selectColor,
|
color: selectColor,
|
||||||
markers: [],
|
markers: [],
|
||||||
serverFileId: null,
|
serverFileId: null,
|
||||||
@@ -18386,6 +18464,7 @@ const App = () => {
|
|||||||
pan: 0,
|
pan: 0,
|
||||||
muted: false,
|
muted: false,
|
||||||
solo: false,
|
solo: false,
|
||||||
|
masteringBypass: false,
|
||||||
color: selectColor,
|
color: selectColor,
|
||||||
markers: [],
|
markers: [],
|
||||||
serverFileId: null,
|
serverFileId: null,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -275,7 +275,10 @@ ${rules.join('\n')}` },
|
|||||||
} else {
|
} else {
|
||||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||||
method: 'POST',
|
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 })
|
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/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></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/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">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
: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()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Rebuild app.precompiled.js from app.jsx using @babel/standalone (avoids the
|
||||||
|
// Babel 8 ESM-only CLI conflict). Mirrors package.json's build script:
|
||||||
|
// babel app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js
|
||||||
|
import * as Babel from '@babel/standalone';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
const src = readFileSync('app/static/js/app.jsx', 'utf8');
|
||||||
|
const out = Babel.transform(src, {
|
||||||
|
presets: ['react'],
|
||||||
|
filename: 'app.jsx',
|
||||||
|
sourceType: 'script',
|
||||||
|
}).code;
|
||||||
|
writeFileSync('app/static/js/app.precompiled.js', out);
|
||||||
|
console.log('BUILD OK', out.length, 'bytes');
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Pytest bootstrap: isolate the test suite from the development database.
|
||||||
|
|
||||||
|
Must be imported before any app module (pytest imports conftest.py first), so
|
||||||
|
app.models.user picks up the test DB path instead of the dev DB. Without this,
|
||||||
|
tests that seed/rotate the admin password (test_auth_and_quota) permanently
|
||||||
|
mutate the developer's sonicforge.db.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"SONICFORGE_DB_PATH",
|
||||||
|
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
||||||
|
)
|
||||||
@@ -13,7 +13,17 @@ client = TestClient(app)
|
|||||||
def get_admin_token():
|
def get_admin_token():
|
||||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
|
# Admin is seeded with must_change_password=1; the app blocks music
|
||||||
|
# processing until the first password change. Complete that flow here
|
||||||
|
# (keeping the same password) so feature tests run unblocked.
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": "admin123", "new_password": "admin123"})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Regression tests for security hardening.
|
||||||
|
|
||||||
|
Covers the vulnerabilities found during the 2026-08 audit:
|
||||||
|
- SSRF / open proxy on /api/v1/ai/proxy
|
||||||
|
- path traversal on render output and audio file ids
|
||||||
|
- unauthenticated filesystem access via /api/v1/media/*
|
||||||
|
- hardcoded SECRET_KEY
|
||||||
|
- quota bypass on project update
|
||||||
|
- audio resampling correctness in the render engine
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.config import settings
|
||||||
|
from app.core import auth as core_auth
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_token():
|
||||||
|
# test_auth_and_quota.py may have rotated the admin password; try both.
|
||||||
|
for pwd in ("admin123", "admin_new_password_2026"):
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": pwd, "new_password": pwd})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers():
|
||||||
|
return {"Authorization": f"Bearer {get_admin_token()}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. SSRF / open proxy ──
|
||||||
|
|
||||||
|
class TestAIProxySSRF:
|
||||||
|
def test_proxy_requires_auth(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", json={"url": "https://api.openai.com/v1", "body": {}})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_proxy_blocks_cloud_metadata(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://169.254.169.254/latest/meta-data/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_blocks_private_ip_not_configured(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://10.0.0.5/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_rejects_non_http_scheme(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "file:///etc/passwd", "body": {}})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_proxy_allows_configured_localhost_provider(self):
|
||||||
|
# localhost:11434 is in the default AI provider list; it must pass the
|
||||||
|
# SSRF check (and then fail to connect in this environment -> 502).
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Path traversal ──
|
||||||
|
|
||||||
|
class TestPathTraversal:
|
||||||
|
def test_render_output_filename_sanitized(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
project = {
|
||||||
|
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||||
|
"main_session": {"length_bars": 1, "tracks": []},
|
||||||
|
"section_store": {},
|
||||||
|
}
|
||||||
|
resp = client.post("/api/v1/plugins/render", headers=auth_headers(),
|
||||||
|
json={"project_json": project, "output_filename": "/tmp/evil_traversal.wav"})
|
||||||
|
# Absolute paths must be reduced to a basename inside PROCESSED_DIR.
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out_path = resp.json()["path"]
|
||||||
|
assert os.path.dirname(out_path) == settings.PROCESSED_DIR
|
||||||
|
assert os.path.basename(out_path) == "evil_traversal.wav"
|
||||||
|
assert os.path.isfile(out_path)
|
||||||
|
|
||||||
|
def test_audio_download_rejects_traversal(self):
|
||||||
|
resp = client.get("/api/v1/audio/download/..%2F..%2Fapp%2Fconfig.py")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_ai_scan_rejects_traversal_file_id(self):
|
||||||
|
resp = client.post("/api/v1/audio/ai-scan",
|
||||||
|
json={"track_id": "1", "file_id": "../../app/config.py"})
|
||||||
|
# Traversal must NOT read the file: falls through to the demo branch.
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Filesystem exposure via media endpoints ──
|
||||||
|
|
||||||
|
class TestMediaAuth:
|
||||||
|
# Use a fresh client (no cookies from earlier logins) to prove 401.
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_client(self):
|
||||||
|
self.fresh = TestClient(app)
|
||||||
|
yield
|
||||||
|
self.fresh.close()
|
||||||
|
|
||||||
|
def test_media_computer_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/computer")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_browse_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/browse", params={"path": "/etc"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_file_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/file", params={"path": "/etc/passwd"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Secret key ──
|
||||||
|
|
||||||
|
class TestSecretKey:
|
||||||
|
def test_secret_key_not_hardcoded_default(self):
|
||||||
|
assert core_auth.SECRET_KEY != "sonicforge_secret_key_super_secure_2026"
|
||||||
|
assert len(core_auth.SECRET_KEY) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Quota enforcement on update ──
|
||||||
|
|
||||||
|
class TestQuotaUpdate:
|
||||||
|
def test_update_cloud_project_enforces_quota(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
# Register a fresh user with a small quota (unique name per run so the
|
||||||
|
# test is re-runnable against a persistent DB).
|
||||||
|
import uuid as _uuid
|
||||||
|
uname = f"quota_user_{_uuid.uuid4().hex[:8]}"
|
||||||
|
resp = client.post("/api/v1/auth/register", json={
|
||||||
|
"username": uname, "email": f"{uname}@studio.com", "password": "quota_pass_123"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
user_token = resp.json()["access_token"]
|
||||||
|
user_headers = {"Authorization": f"Bearer {user_token}"}
|
||||||
|
|
||||||
|
# Shrink quota to 1 MB via admin API.
|
||||||
|
uid = resp.json()["user"]["id"]
|
||||||
|
r = client.put(f"/api/v1/admin/quotas/{uid}", headers=auth_headers(),
|
||||||
|
json={"storage_limit_mb": 1, "max_tracks": 16})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Save a small project.
|
||||||
|
small = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "small", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True, "tracks": []},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.post("/api/v1/projects/cloud", headers=user_headers,
|
||||||
|
json={"name": "small", "data_json": small})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pid = r.json()["project_id"]
|
||||||
|
|
||||||
|
# Updating with a payload over the quota must be rejected (was a bypass).
|
||||||
|
items = [{
|
||||||
|
"type": "AUDIO_ITEM", "id": f"it_{i}", "name": "n",
|
||||||
|
"start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "", "gain": 1.0},
|
||||||
|
} for i in range(20000)]
|
||||||
|
huge = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "huge", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True,
|
||||||
|
"tracks": [{"id": "t", "name": "x", "type": "AUDIO", "items": items}]},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.put(f"/api/v1/projects/cloud/{pid}", headers=user_headers,
|
||||||
|
json={"name": "huge", "data_json": huge})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Quota" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. Render engine: resampling correctness ──
|
||||||
|
|
||||||
|
class TestRenderResample:
|
||||||
|
def test_audio_item_resampled_to_engine_rate(self, tmp_path):
|
||||||
|
from app.core.render_engine import PythonRenderEngine
|
||||||
|
# 44.1kHz source, engine at 22.05kHz -> exactly 2x downsampling.
|
||||||
|
sr_src = 44100
|
||||||
|
t = np.arange(sr_src) / sr_src
|
||||||
|
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||||
|
src_path = os.path.join(settings.UPLOADS_DIR, "resample_test_tone.wav")
|
||||||
|
sf.write(src_path, tone, sr_src)
|
||||||
|
|
||||||
|
engine = PythonRenderEngine(sample_rate=22050)
|
||||||
|
session = {
|
||||||
|
"tracks": [{
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0.0, "pan": 0.0, "mute": False,
|
||||||
|
"items": [{
|
||||||
|
"type": "AUDIO_ITEM",
|
||||||
|
"start_bar": 0.0, "duration_bars": 4.0,
|
||||||
|
"clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "/static/audio/uploads/resample_test_tone.wav", "gain": 1.0},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
buf = engine.render_session_container(session, {}, bpm=120.0, time_sig_num=4,
|
||||||
|
total_samples=engine.sample_rate * 2)
|
||||||
|
# A 1s 440Hz tone must actually render energy (previously the SR
|
||||||
|
# mismatch silently skipped the audio).
|
||||||
|
assert np.max(np.abs(buf)) > 0.01
|
||||||
|
# Duration should be ~1 second at the engine rate, not 2.
|
||||||
|
nonzero = np.where(np.abs(buf[0]) > 1e-4)[0]
|
||||||
|
assert len(nonzero) > 0
|
||||||
|
assert (nonzero[-1] - nonzero[0]) < int(engine.sample_rate * 1.3)
|
||||||
|
try:
|
||||||
|
os.remove(src_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bca1bb5f-b656-48a8-b113-8fff900188ab",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784710097790",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784710143.4476569
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d627f532-b632-4ef0-a21f-80dcc2ac8396",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719724178",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719770.23144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a17fe50b-c51d-408d-b45c-c09645777e68",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719775936",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719821.9856758
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "38a0f344-05dc-47e3-a769-07426c57f5fe",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720540893",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Cartoon Capers Loop.mp3",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720586.9746954
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"name": "Rose",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720555735",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720601.8155787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "525bdab5-e594-4a0c-a3e6-102a0abef816",
|
||||||
|
"name": "Rose (autosave 03/08)",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose (autosave 03/08)",
|
||||||
|
"bpm": 128,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": "sf_DSK_Asian_DreamZ",
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057918746",
|
||||||
|
"name": "MIDI Item",
|
||||||
|
"type": "MIDI_ITEM",
|
||||||
|
"start_bar": 0,
|
||||||
|
"duration_bars": 4,
|
||||||
|
"clip_start_offset_bars": 0,
|
||||||
|
"source_data": {
|
||||||
|
"total_buffer_bars": 4,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": null,
|
||||||
|
"instrument_program": null,
|
||||||
|
"instrument_name": null,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sub_tabs": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057919362",
|
||||||
|
"label": "Piano Roll: MIDI Item",
|
||||||
|
"type": "PIANO_ROLL",
|
||||||
|
"track_id": "1",
|
||||||
|
"target_id": "midi_1785057918746",
|
||||||
|
"parent_tab_id": null,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"duration": 7.5,
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"current_time": 0,
|
||||||
|
"color": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1785058087.45
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Khôi phục các dự án cloud cũ (từ git history) vào bất kỳ sonicforge.db nào.
|
||||||
|
|
||||||
|
Cách dùng trên máy deployment thật (host game):
|
||||||
|
python3 tools/restore_cloud_projects.py /path/to/sonicforge.db
|
||||||
|
|
||||||
|
Script đọc backup projects (JSON) đã xuất từ git history và chèn vào DB chỉ định,
|
||||||
|
gán tất cả cho user_id được yêu cầu (mặc định: admin đầu tiên tìm thấy).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECTS_BACKUP = os.path.join(os.path.dirname(__file__), "cloud_projects_backup.json")
|
||||||
|
|
||||||
|
|
||||||
|
def main(db_path: str, user_id: str = None):
|
||||||
|
if not os.path.exists(PROJECTS_BACKUP):
|
||||||
|
print(f"Không tìm thấy {PROJECTS_BACKUP}")
|
||||||
|
return 1
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Không tìm thấy DB: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(PROJECTS_BACKUP) as f:
|
||||||
|
projects = json.load(f)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1").fetchone()
|
||||||
|
if not row:
|
||||||
|
print("Không có user admin nào trong DB")
|
||||||
|
conn.close()
|
||||||
|
return 1
|
||||||
|
user_id = row["id"]
|
||||||
|
print(f"Gán tất cả cho admin: {user_id}")
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
for p in projects:
|
||||||
|
if conn.execute("SELECT id FROM projects WHERE id=?", (p["id"],)).fetchone():
|
||||||
|
print(f" bỏ qua (đã tồn tại): {p['name']}")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||||
|
(p["id"], user_id, p["name"], json.dumps(p["data_json"], ensure_ascii=False),
|
||||||
|
len(json.dumps(p["data_json"]).encode("utf-8")), p["updated_at"]))
|
||||||
|
restored += 1
|
||||||
|
print(f" đã khôi phục: {p['name']}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"\nHoàn tất: {restored} dự án đã khôi phục cho user {user_id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 restore_cloud_projects.py <path/to/sonicforge.db> [user_id]")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))
|
||||||
@@ -1,3 +1,39 @@
|
|||||||
|
### [2026-08-03] Task: Nút Bypass Mastering cho từng track strip trong Mixer Panel
|
||||||
|
- **Tóm tắt thay đổi:** Thêm nút **B** (Bypass) trong mỗi track strip của Mixer Panel (cạnh M/S). Khi bật ON: âm thanh track đi qua **dry bus mới** (`masterBus.dryInput → dryOutput → output`) — **bỏ qua toàn bộ chuỗi mastering** (EQ / Imager / Maximizer) nhưng vẫn qua master volume + metering ở Main out. Cơ chế: `createMasteringRoute()` tạo 2 đường gain bù nhau (routeGain → `masterBus.input` qua mastering, dryGain → `dryInput`); `setMasteringRoute()` crossfade 20ms khi toggle (không click). Áp dụng tại: `getOrCreateTrackNode` (node track chính, toggle live qua `node.route`), `startSubTabPlayback` (clip playback). Map trạng thái `trackMasteringBypassMap` sync từ tracks state qua useEffect; `window.__setTrackMasteringBypass` toggle ngay cho track đang phát. Lưu/đọc project: `mastering_bypass` trong serialize/deserialize (schema không chặn additionalProperties nên không cần sửa).
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031800)
|
||||||
|
- **Ghi chú/Test (nếu có):** `node build.mjs` rebuild OK, syntax OK, `pytest` 83 passed. **Giới hạn:** track MIDI/SoundFont dùng chung 1 bộ render FluidSynth (1 worklet → 1 gain) nên bypass hiện áp dụng cho track AUDIO (clip) + oscillator fallback; track MIDI dùng FluidSynth WASM chưa tách được theo track (cần refactor renderer) — sẽ làm tiếp nếu cần.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Rebuild app.precompiled.js từ app.jsx (fix "handleOpenProject is not defined")
|
||||||
|
- **Tóm tắt thay đổi:** Lỗi `Uncaught ReferenceError: handleOpenProject is not defined` khi click Open → bundle `app.precompiled.js` mà trình duyệt tải bị lệch với `app.jsx` (bundle cũ không chứa định nghĩa hàm ở scope đúng). `npm run build` không chạy được vì Babel 8 ESM-only (`ERR_REQUIRE_ESM`). Giải pháp: build lại bundle bằng **@babel/standalone@7** qua `build.mjs` (script mới, mirror đúng lệnh build trong package.json), tạo `app.precompiled.js` mới (858KB, syntax OK, đủ `const handleOpenProject` + 3 references, kèm fix restoreLastSessionProject). Đã smoke-test: trích deserializer từ bundle mới chạy với 6 project khôi phục → 6/6 OK, "Rose (autosave 03/08)" đủ 39 MIDI notes. Bump cache-buster lên `v=202608031700`.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.precompiled.js` (rebuild), `build.mjs` (NEW — rebuild thủ công khi Babel 8 lỗi), `app/templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed. Node portable dùng: `/tmp/node-v20.18.0-linux-x64/bin/node` (máy không có node hệ thống). Trên máy deployment: copy 3 file (app.jsx, app.precompiled.js, index.html) hoặc chạy `node build.mjs` rồi hard refresh.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Fix auto-restore dự án lỗi "Không tìm thấy dự án" sau khi khôi phục DB
|
||||||
|
- **Tóm tắt thay đổi:** Sau khi DB bị xóa/tạo lại, `localStorage.sonic_project_id` của trình duyệt vẫn trỏ tới project cũ đã mất → mỗi lần tải trang `restoreLastSessionProject` gọi API, nhận 404 "Không tìm thấy dự án" và chỉ `console.warn` vĩnh viễn. Fix: khi restore thất bại, tự xóa `sonic_project_id` + `sonic_project_name` khỏi localStorage để lỗi không lặp lại. Bump cache-buster precompiled lên `v=202608031600`.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||||
|
- **Ghi chú/Test (nếu có):** Verified qua API: cả 6 project khôi phục mở được (GET /cloud/{id} → 200 + main_session đầy đủ). Người dùng cần hard refresh (Ctrl+Shift+R) để nạp bundle mới, mở "Rose (autosave 03/08)" 1 lần để ghim project hiện tại.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Khôi phục các dự án cloud cũ bị mất (git recovery)
|
||||||
|
- **Tóm tắt thay đổi:** Các dự án cloud cũ ('Test' x4, 'Rose' từ 22/07) và project đang làm dở "Rose" (autosave 03/08, 2 track MIDI+AUDIO, 39 notes, bpm 128) đã được khôi phục vào `app/storage/sonicforge.db` dưới user_id admin hiện tại từ **git history** (`app/storage/sonicforge.db` từng được commit trước khi vào `.gitignore`). Nguyên nhân mất: DB bị xóa/tạo lại nhiều lần trong quá trình dev (test suite `test_auth_and_quota` xóa DB → admin được re-seed với UUID mới → project cũ gắn với user_id cũ không hiển thị). Đã dọn các project/user test rác, backup DB trước khi khôi phục tại `/tmp/sonicforge_db_before_restore.db`.
|
||||||
|
- **Các file ảnh hưởng:** `app/storage/sonicforge.db` (dữ liệu), `tools/restore_cloud_projects.py` (NEW — script khôi phục cho deployment khác), `tools/cloud_projects_backup.json` (NEW — 6 project dạng portable)
|
||||||
|
- **Ghi chú/Test (nếu có):** Verified qua API: login admin → `GET /api/v1/projects/cloud` trả đủ 6 project; mở "Rose (autosave 03/08)" đủ 39 MIDI notes. Trên máy deployment thật (nếu DB khác): `python3 tools/restore_cloud_projects.py <duong-dan>/sonicforge.db`. Từ giờ test suite không đụng DB dev (xem entry conftest).
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Cô lập test suite khỏi DB dev + khôi phục mật khẩu admin
|
||||||
|
- **Tóm tắt thay đổi:** (1) `DB_PATH` trong `app/models/user.py` giờ có thể override bằng env `SONICFORGE_DB_PATH`. (2) Thêm `tests/conftest.py` set env này sang `/tmp/sonicforge_test.db` trước khi mọi app module được import → pytest không bao giờ sửa DB dev nữa (trước đây `test_auth_and_quota` seed + đổi mật khẩu admin ngay trong `app/storage/sonicforge.db`, khiến login `admin123` fail). (3) Reset mật khẩu admin DB dev về `admin123` (`must_change_password=0`).
|
||||||
|
- **Các file ảnh hưởng:** `app/models/user.py`, `tests/conftest.py` (NEW)
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: login admin/admin123 → 200, sai mật khẩu → 400. Nếu deployment khác cũng bị dính (chạy test trên cùng DB), reset thủ công: `UPDATE users SET hashed_password='<hash_of_admin123>', must_change_password=0 WHERE username='admin'` hoặc xóa DB để `seed_admin` tạo lại.
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-08-03] Task: Security audit + bug fixes (bảo mật, bug chức năng, cải thiện)
|
||||||
|
- **Tóm tắt thay đổi:** (1) **SSRF** `/api/v1/ai/proxy`: thêm auth bắt buộc + chặn cloud metadata/link-local (169.254.0.0/16), chặn IP private trừ khi host nằm trong danh sách AI provider user đã cấu hình (cho phép Ollama localhost:11434), chặn scheme không phải http/https, không forward header X-Auth-Token lên upstream. (2) **Path traversal** `/plugins/render`: `output_filename` chỉ lấy basename + ép đuôi .wav. (3) **Path traversal** toàn bộ `/audio/*`: helper `_safe_file_id`/`_resolve_storage_path` (basename + chỉ đọc trong uploads/processed). (4) **SECRET_KEY**: bỏ hardcode, tự sinh random bền vững lưu `app/storage/.secret_key` (ưu tiên env SECRET_KEY). (5) **media.py** (`computer`/`browse`/`file`): yêu cầu auth — login giờ set HttpOnly cookie `sf_token`, `get_current_user` nhận token từ Bearer / X-Auth-Token / cookie nên frontend raw fetch vẫn hoạt động. (6) Upload audio + soundfont: streaming theo chunk + giới hạn size (1GB/2GB) thay vì đọc cả file vào RAM. (7) **Quota bypass**: `update_cloud_project` giờ kiểm tra quota như `save_cloud_project`. (8) `enforce_password_changed` (bắt buộc đổi mật khẩu lần đầu) được wire vào upload/edit/render/ai-scan/ai-cut/python-tool/upload-soundfont. (9) **render_engine**: fix resample bị bỏ qua (`sr != sample_rate` trước là no-op → giờ resample_poly), render tôn trọng **solo** track, cache buffer section theo `section_id`, thay print → logger. (10) **vst_engine**: thống nhất FluidSynth API low-level CFFI (high-level `Synth()`/`FluidSynth()` không tồn tại trong binding này). (11) Rate-limit login theo IP (10 lần/15 phút), validate độ mạnh password khi register. (12) main.py: `on_event` → `lifespan`, CORS `allow_credentials=False`, xóa stub rỗng. (13) SQLite: WAL + foreign_keys=ON + seed user `anonymous` placeholder (FK hợp lệ cho project ẩn danh). (14) Pydantic v2 `model_dump()` thay `dict()`.
|
||||||
|
- **Các file ảnh hưởng:** `app/core/auth.py`, `app/api/v1/auth.py`, `app/api/v1/ai_proxy.py`, `app/api/v1/plugins.py`, `app/api/v1/audio.py`, `app/api/v1/media.py`, `app/api/v1/projects.py`, `app/api/v1/multitrack.py`, `app/api/v1/user_config.py`, `app/core/render_engine.py`, `app/core/vst_engine.py`, `app/models/user.py`, `app/main.py`, `app/static/js/services/aiGateway.js` (gửi X-Auth-Token khi gọi proxy), `tests/test_security_hardening.py` (NEW, 14 test), `tests/test_plugin_api.py`
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: proxy không token → 401, metadata → 403; media không token → 401 / có cookie → 200; render `output_filename=/tmp/x.wav` → path nằm trong PROCESSED_DIR. Lưu ý: nếu triển khai cũ đang chạy, restart server để tạo `.secret_key` (token cũ sẽ hết hạn vì secret đổi). Login mật khẩu admin mặc định vẫn `admin123` (đã khôi phục trong DB dev).
|
||||||
|
---
|
||||||
|
|
||||||
### [2026-07-31 07:03] Task: Fix Lucide icons not rendering on new tracks (TCP + SECTION-TAB)
|
### [2026-07-31 07:03] Task: Fix Lucide icons not rendering on new tracks (TCP + SECTION-TAB)
|
||||||
- **Tóm tắt thay đổi:** `useEffect` gọi `lucide.createIcons()` thiếu `activeTracks` trong dependency array → khi track được tạo (user/AI/MIDI import/clone section), DOM nodes mới có `data-lucide` nhưng không được chuyển thành SVG → icon ẩn hoặc hiển thị sai. Fix: thêm `activeTracks` vào deps để auto-refresh icons sau mỗi lần track list thay đổi.
|
- **Tóm tắt thay đổi:** `useEffect` gọi `lucide.createIcons()` thiếu `activeTracks` trong dependency array → khi track được tạo (user/AI/MIDI import/clone section), DOM nodes mới có `data-lucide` nhưng không được chuyển thành SVG → icon ẩn hoặc hiển thị sai. Fix: thêm `activeTracks` vào deps để auto-refresh icons sau mỗi lần track list thay đổi.
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||||
|
|||||||
Reference in New Issue
Block a user