Compare commits
48 Commits
7325fbfc45
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e3c652551 | |||
| 393df8fd7b | |||
| 688a7a57e2 | |||
| 652e745d0a | |||
| 45ab31d42e | |||
| c3182f6baa | |||
| 1d9b426d15 | |||
| 4f34491f3e | |||
| 9e269d3b79 | |||
| 58c6ec794d | |||
| 173a1f227b | |||
| c1aa1eb4d4 | |||
| 6c16bf8410 | |||
| 4981785913 | |||
| bca661bffd | |||
| 4c820bfa77 | |||
| 8cdf2942b9 | |||
| 23675dab83 | |||
| 71b9231b57 | |||
| 93dc13b17a | |||
| f1e914fa62 | |||
| 4dfab954d8 | |||
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca | |||
| 289746f187 | |||
| b3ced7a7b3 | |||
| 4233c1eeda | |||
| 35f7c3822f | |||
| d37f4e7557 | |||
| 8a9d6b3c27 | |||
| 55d3464b1e | |||
| cfc114b9d7 | |||
| 856a8183b6 | |||
| 0272912cff | |||
| c047934fc4 | |||
| fc663921ff | |||
| fe5e8cb58e | |||
| 01fcf51fea | |||
| 1af2119444 | |||
| e5b4321a55 | |||
| ec5fedec33 | |||
| 8c1a8ead56 | |||
| ed91e4534c | |||
| 8fc1c2641b | |||
| a9da813cb1 | |||
| 6f55d36085 |
+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
|
||||||
|
|||||||
+108
-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)):
|
||||||
@@ -194,3 +259,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
|
|||||||
"max_tracks": row["max_tracks"] or 16
|
"max_tracks": row["max_tracks"] or 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/first-time")
|
||||||
|
async def auth_first_time():
|
||||||
|
# "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH
|
||||||
|
# (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa
|
||||||
|
# gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu:
|
||||||
|
# admin123, nút Điền nhanh).
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, hashed_password, must_change_password FROM users "
|
||||||
|
"WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return {"first_time": True}
|
||||||
|
still_default = False
|
||||||
|
try:
|
||||||
|
still_default = verify_password("admin123", row["hashed_password"])
|
||||||
|
except Exception:
|
||||||
|
still_default = False
|
||||||
|
return {"first_time": bool(row["must_change_password"]) and still_default}
|
||||||
|
except Exception:
|
||||||
|
return {"first_time": True}
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+26
-5
@@ -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]
|
||||||
@@ -139,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
|||||||
@router.get("/soundfonts/download/{sf_id}")
|
@router.get("/soundfonts/download/{sf_id}")
|
||||||
async def download_soundfont_asset(sf_id: str):
|
async def download_soundfont_asset(sf_id: str):
|
||||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||||
|
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||||
|
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||||
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||||
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||||
if not os.path.isdir(base_dir):
|
if not os.path.isdir(base_dir):
|
||||||
continue
|
continue
|
||||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||||
@@ -178,8 +194,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}
|
||||||
|
|||||||
+31
-9
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
return project_data
|
return project_data
|
||||||
|
|
||||||
tracks = project_data.get("tracks", [])
|
tracks = project_data.get("tracks", [])
|
||||||
|
# Legacy format stores item start times in SECONDS; convert using the real
|
||||||
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||||
|
# position for any tempo other than the one where 1 bar = 4s).
|
||||||
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||||
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||||
upgraded_tracks = []
|
upgraded_tracks = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track_id = str(t.get("id", ""))
|
track_id = str(t.get("id", ""))
|
||||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"name": c.get("name", "Audio Clip"),
|
"name": c.get("name", "Audio Clip"),
|
||||||
"type": "AUDIO_ITEM",
|
"type": "AUDIO_ITEM",
|
||||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": 4.0,
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||||
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": m.get("id"),
|
"id": m.get("id"),
|
||||||
"name": m.get("name", "MIDI Item"),
|
"name": m.get("name", "MIDI Item"),
|
||||||
"type": "MIDI_ITEM",
|
"type": "MIDI_ITEM",
|
||||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": m.get("duration", 4.0),
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"total_buffer_bars": m.get("duration", 8.0),
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||||
"notes": m.get("notes", [])
|
"notes": m.get("notes", [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -283,13 +288,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] = []
|
||||||
|
|||||||
+30
-23
@@ -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():
|
||||||
@@ -78,14 +81,18 @@ async def get_index():
|
|||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||||
|
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||||
|
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"id": { "type": "string" },
|
"id": { "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||||
|
"color": { "type": ["string", "null"], "default": null },
|
||||||
"volume_db": { "type": "number", "default": 0.0 },
|
"volume_db": { "type": "number", "default": 0.0 },
|
||||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||||
"mute": { "type": "boolean", "default": false },
|
"mute": { "type": "boolean", "default": false },
|
||||||
|
|||||||
+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()
|
||||||
|
|
||||||
|
|||||||
+5973
-689
File diff suppressed because it is too large
Load Diff
+1091
-144
File diff suppressed because one or more lines are too long
@@ -121,6 +121,57 @@ const AIGateway = (function() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ai_midi_rearrange_specification.md §3 — Function Tool Schema hỗ trợ 2 mode:
|
||||||
|
// SIMILAR_VARIATION (biến tấu cùng độ dài) / EXTEND_CONTINUATION (viết tiếp
|
||||||
|
// các bar sau). AI trả gói dữ liệu có vị trí target trên Timeline.
|
||||||
|
const REARRANGE_EXTEND_TOOL_SPEC = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'rearrange_or_extend_midi_melody',
|
||||||
|
description: 'Analyzes source MIDI melody data and returns either a variation (Variation) or continuation (Extend) based on user instructions.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['SIMILAR_VARIATION', 'EXTEND_CONTINUATION'],
|
||||||
|
description: "Mode: 'SIMILAR_VARIATION' (new arrangement of equal length) or 'EXTEND_CONTINUATION' (writes subsequent bars)."
|
||||||
|
},
|
||||||
|
composition_title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Short title describing the new melody style (e.g., Jazz Swing Variation, Epic Extension Part 2)'
|
||||||
|
},
|
||||||
|
target_start_bar: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Starting bar number for the generated notes on the Timeline'
|
||||||
|
},
|
||||||
|
target_duration_bars: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Total bar duration covered by the generated sequence'
|
||||||
|
},
|
||||||
|
soundfont_id: { type: 'string', default: 'generaluser_gs' },
|
||||||
|
soundfont_bank: { type: 'integer', default: 0 },
|
||||||
|
soundfont_program: { type: 'integer', default: 0 },
|
||||||
|
generated_notes: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of AI-generated MIDI notes.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pitch: { type: 'integer', minimum: 0, maximum: 127 },
|
||||||
|
start_beat: { type: 'number', description: 'Starting beat position relative to beat 0.0 of the generated item' },
|
||||||
|
duration_beats: { type: 'number', minimum: 0.1 },
|
||||||
|
velocity: { type: 'number', minimum: 0.0, maximum: 1.0 }
|
||||||
|
},
|
||||||
|
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['mode', 'composition_title', 'target_start_bar', 'target_duration_bars', 'generated_notes']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const REARRANGE_SCENARIOS = [
|
const REARRANGE_SCENARIOS = [
|
||||||
{
|
{
|
||||||
id: 'arpeggio',
|
id: 'arpeggio',
|
||||||
@@ -275,7 +326,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 })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
|||||||
return {
|
return {
|
||||||
DEFAULT_TOOLS,
|
DEFAULT_TOOLS,
|
||||||
REARRANGE_TOOL_SPEC,
|
REARRANGE_TOOL_SPEC,
|
||||||
|
REARRANGE_EXTEND_TOOL_SPEC,
|
||||||
REARRANGE_SCENARIOS,
|
REARRANGE_SCENARIOS,
|
||||||
detectRearrangeScenario,
|
detectRearrangeScenario,
|
||||||
buildRearrangeMessage,
|
buildRearrangeMessage,
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
let _loadedFonts = {};
|
let _loadedFonts = {};
|
||||||
let _activeOscillators = {};
|
let _activeOscillators = {};
|
||||||
let _gainNode = null;
|
let _gainNode = null;
|
||||||
|
let _pendingOutputDestination = null;
|
||||||
|
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
|
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||||
|
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||||
let _scheduledNotes = [];
|
let _scheduledNotes = [];
|
||||||
let _loadPromises = {};
|
let _loadPromises = {};
|
||||||
let _sfloadSeq = 0;
|
let _sfloadSeq = 0;
|
||||||
@@ -31,7 +35,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return _audioCtx;
|
return _audioCtx;
|
||||||
}
|
}
|
||||||
@@ -40,7 +45,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = ctx.createGain();
|
_gainNode = ctx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -53,7 +59,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = window.__sharedAudioCtx.createGain();
|
_gainNode = window.__sharedAudioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return window.__sharedAudioCtx;
|
return window.__sharedAudioCtx;
|
||||||
};
|
};
|
||||||
@@ -61,6 +68,41 @@
|
|||||||
const SonicSF = {
|
const SonicSF = {
|
||||||
loadedFonts: _loadedFonts,
|
loadedFonts: _loadedFonts,
|
||||||
|
|
||||||
|
// Route the shared FluidSynth output through a per-track node (e.g. the
|
||||||
|
// track's gainNode) so the track's FX chain / fader / pan affect the
|
||||||
|
// soundfont instrument. Pass null to restore the default master-bus route.
|
||||||
|
setOutputDestination: function (node) {
|
||||||
|
try {
|
||||||
|
if (_gainNode) {
|
||||||
|
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||||
|
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||||
|
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||||
|
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||||
|
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||||
|
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||||
|
// ngay sau setOutputDestination lần 2).
|
||||||
|
if (dest === _outputDestination) return;
|
||||||
|
_gainNode.disconnect();
|
||||||
|
_gainNode.connect(dest);
|
||||||
|
// Reconnect VU analyser tap (app.jsx VU tick gắn __vuAnalyser
|
||||||
|
// trên _gainNode): disconnect() không đối số ngắt MỌI kết
|
||||||
|
// nối kể cả analyser → sfAudioPeak = 0 mãi → track VU MIDI
|
||||||
|
// không nhảy khi có âm (user bug 06:35).
|
||||||
|
if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);
|
||||||
|
_outputDestination = dest;
|
||||||
|
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||||
|
} else {
|
||||||
|
_pendingOutputDestination = node || null;
|
||||||
|
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SonicSF] setOutputDestination error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getOutputNode: function () {
|
||||||
|
return _gainNode;
|
||||||
|
},
|
||||||
|
|
||||||
init: async function (audioContext) {
|
init: async function (audioContext) {
|
||||||
if (_initialized && _fluidModule) return;
|
if (_initialized && _fluidModule) return;
|
||||||
if (_initPromise) return _initPromise;
|
if (_initPromise) return _initPromise;
|
||||||
@@ -81,7 +123,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||||
@@ -117,7 +160,7 @@
|
|||||||
// expected notice when a soundfont simply has no
|
// expected notice when a soundfont simply has no
|
||||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||||
// font) — the note is just silent, not an error.
|
// font) — the note is just silent, not an error.
|
||||||
if (msg && msg.indexOf('No preset found on channel') !== -1) return;
|
if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
|
||||||
console.warn('[FluidSynth:err]', msg);
|
console.warn('[FluidSynth:err]', msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -168,17 +211,31 @@
|
|||||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||||
|
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||||
|
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||||
|
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||||
|
// buffer + re-malloc khi đổi.
|
||||||
|
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
spn.onaudioprocess = function (e) {
|
spn.onaudioprocess = function (e) {
|
||||||
var left = e.outputBuffer.getChannelData(0);
|
var left = e.outputBuffer.getChannelData(0);
|
||||||
var right = e.outputBuffer.getChannelData(1);
|
var right = e.outputBuffer.getChannelData(1);
|
||||||
var sz = left.length;
|
var sz = left.length;
|
||||||
try {
|
try {
|
||||||
|
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||||
|
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||||
|
lp = _fluidModule._malloc(sz * 4);
|
||||||
|
rp = _fluidModule._malloc(sz * 4);
|
||||||
|
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
|
}
|
||||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||||
var hf = _fluidModule.HEAPF32;
|
var hf = _fluidModule.HEAPF32;
|
||||||
var lpb = lp >> 2, rpb = rp >> 2;
|
var lpb = lp >> 2, rpb = rp >> 2;
|
||||||
for (var si = 0; si < sz; si++) {
|
for (var si = 0; si < sz; si++) {
|
||||||
left[si] = hf[lpb + si];
|
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||||
right[si] = hf[rpb + si];
|
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||||
|
var L = hf[lpb + si], R = hf[rpb + si];
|
||||||
|
left[si] = isFinite(L) ? L : 0;
|
||||||
|
right[si] = isFinite(R) ? R : 0;
|
||||||
}
|
}
|
||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
};
|
};
|
||||||
@@ -249,6 +306,12 @@
|
|||||||
|
|
||||||
_doLoadSoundFont: async function (sfId) {
|
_doLoadSoundFont: async function (sfId) {
|
||||||
try {
|
try {
|
||||||
|
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
|
||||||
|
// thành rác trong khi channel state vẫn trỏ tới → program_select
|
||||||
|
// bị skip (progAlreadySet) → noteon trên handle đã unload →
|
||||||
|
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
|
||||||
|
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
|
||||||
|
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
|
||||||
var cache = window.SonicSFStorage;
|
var cache = window.SonicSFStorage;
|
||||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||||
if (buf) {
|
if (buf) {
|
||||||
@@ -269,8 +332,15 @@
|
|||||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||||
var resp = await fetch(url);
|
var resp = await fetch(url);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||||
return false;
|
// serve qua /soundfonts/{f} — catalog default-soundfonts).
|
||||||
|
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
|
||||||
|
var resp2 = await fetch(url2);
|
||||||
|
if (!resp2.ok) {
|
||||||
|
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
resp = resp2;
|
||||||
}
|
}
|
||||||
buf = await resp.arrayBuffer();
|
buf = await resp.arrayBuffer();
|
||||||
if (cache) await cache.saveBuffer(sfId, buf);
|
if (cache) await cache.saveBuffer(sfId, buf);
|
||||||
@@ -429,10 +499,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
var parsedPitch = parseInt(note);
|
||||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
|
||||||
)));
|
if (isNaN(rawVel)) rawVel = 100;
|
||||||
|
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
|
||||||
var _origChannel = channel;
|
var _origChannel = channel;
|
||||||
var usedBank = 0, usedProg = 0;
|
var usedBank = 0, usedProg = 0;
|
||||||
if (synthEngine) {
|
if (synthEngine) {
|
||||||
@@ -465,8 +536,10 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var doNote = function () {
|
var doNote = function () {
|
||||||
try {
|
try {
|
||||||
var finalBank = usedBank;
|
var finalBank = parseInt(usedBank);
|
||||||
var finalProg = usedProg;
|
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||||
|
var finalProg = parseInt(usedProg);
|
||||||
|
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||||
var cachedCh = _channels[ch];
|
var cachedCh = _channels[ch];
|
||||||
// The note's own synth engine (track instrument) is
|
// The note's own synth engine (track instrument) is
|
||||||
@@ -475,16 +548,32 @@
|
|||||||
// re-picked instrument plays the wrong soundfont. Without an
|
// re-picked instrument plays the wrong soundfont. Without an
|
||||||
// engine, fall back to the soundfont configured on the channel.
|
// engine, fall back to the soundfont configured on the channel.
|
||||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||||
finalBank = cachedCh.bank;
|
finalBank = parseInt(cachedCh.bank) || 0;
|
||||||
finalProg = cachedCh.program;
|
finalProg = parseInt(cachedCh.program) || 0;
|
||||||
finalSfId = cachedCh.sfId;
|
finalSfId = cachedCh.sfId;
|
||||||
}
|
}
|
||||||
// Ensure the soundfont is actually loaded before the note plays.
|
// Ensure the soundfont is actually loaded before the note plays.
|
||||||
// Quick instrument pick on a track does not pre-load it, so load
|
// Quick instrument pick on a track does not pre-load it, so load
|
||||||
// lazily here and retry the note once the font is ready.
|
// lazily here and retry the note once the font is ready.
|
||||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||||
|
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
|
||||||
|
// — note chạy thẳng fallback để CÓ ÂM.
|
||||||
|
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
|
||||||
|
if (Date.now() - _lastFail < 10000) {
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
||||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||||
if (ok) doNote();
|
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||||
|
if (ok) {
|
||||||
|
doNote();
|
||||||
|
} else {
|
||||||
|
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||||
|
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||||
|
_sfLoadFailAt[finalSfId] = Date.now();
|
||||||
|
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -492,12 +581,43 @@
|
|||||||
// instrument for each item regardless of processing order.
|
// instrument for each item regardless of processing order.
|
||||||
// Skip if the channel already has this exact instrument (avoids
|
// Skip if the channel already has this exact instrument (avoids
|
||||||
// per-note soundfont reloads that cause audible crackle/glitches).
|
// per-note soundfont reloads that cause audible crackle/glitches).
|
||||||
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
|
// ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
|
||||||
|
// không → vẫn program_select lại (tránh dùng handle đã unload).
|
||||||
|
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
|
||||||
|
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
|
||||||
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
if ((synthEngine || program !== undefined) && !progAlreadySet) {
|
||||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||||
|
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||||
|
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||||
|
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||||
|
// định → font không có → cache channel = (128,0)
|
||||||
|
// INVALID → note sau skip re-select (progAlreadySet)
|
||||||
|
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||||
|
if (finalBank === 128) {
|
||||||
|
var _vKey = finalSfId || ('h' + sfHandle);
|
||||||
|
if (_validPercCache[_vKey] === undefined) {
|
||||||
|
var _found = null;
|
||||||
|
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||||
|
var _bk = _b === 0 ? 128 : 0;
|
||||||
|
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||||
|
try {
|
||||||
|
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||||
|
_found = [_bk, _p];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validPercCache[_vKey] = _found;
|
||||||
|
}
|
||||||
|
if (_validPercCache[_vKey]) {
|
||||||
|
finalBank = _validPercCache[_vKey][0];
|
||||||
|
finalProg = _validPercCache[_vKey][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||||
@@ -508,6 +628,7 @@
|
|||||||
_channels[ch].program = finalProg;
|
_channels[ch].program = finalProg;
|
||||||
_channels[ch].sfId = finalSfId;
|
_channels[ch].sfId = finalSfId;
|
||||||
}
|
}
|
||||||
|
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
||||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||||
@@ -538,6 +659,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
|
||||||
|
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
|
||||||
|
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
|
||||||
|
// tự động → ngân vô hạn → VU master nhảy dù không play).
|
||||||
|
panic: function () {
|
||||||
|
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||||
|
_scheduledNotes = [];
|
||||||
|
if (_initialized && _fluidModule) {
|
||||||
|
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
|
||||||
|
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
|
||||||
|
// thể không có trong WASM exports (catch nuốt → notes kẹt).
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
_activeNotes = {};
|
||||||
|
},
|
||||||
|
|
||||||
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||||
var ctx = getCtx();
|
var ctx = getCtx();
|
||||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||||
@@ -554,7 +700,11 @@
|
|||||||
var oscType = 'triangle';
|
var oscType = 'triangle';
|
||||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||||
var prog = program !== undefined ? parseInt(program) : 0;
|
var prog = program !== undefined ? parseInt(program) : 0;
|
||||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||||
|
// truyền — trước đây override program của track bằng cache channel
|
||||||
|
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||||
|
// nhạc cụ của track TRƯỚC).
|
||||||
|
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||||
prog = _channels[channel].program || prog;
|
prog = _channels[channel].program || prog;
|
||||||
}
|
}
|
||||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||||
@@ -572,7 +722,8 @@
|
|||||||
osc.frequency.setValueAtTime(freq, 0);
|
osc.frequency.setValueAtTime(freq, 0);
|
||||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||||
var durSec = durationMs / 1000;
|
var durSec = durationMs / 1000;
|
||||||
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||||
|
if (isNaN(vel)) vel = 0.8;
|
||||||
var targetGain = vel * volFactor;
|
var targetGain = vel * volFactor;
|
||||||
noteGain.gain.setValueAtTime(0, startAt);
|
noteGain.gain.setValueAtTime(0, startAt);
|
||||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||||
@@ -593,6 +744,15 @@
|
|||||||
|
|
||||||
stopAll: function () {
|
stopAll: function () {
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
|
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
||||||
|
// phòng all_notes_off không có trong WASM exports.
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
for (var ch = 0; ch < 16; ch++) {
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -611,6 +771,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||||
|
|||||||
@@ -60,11 +60,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
|
let lastGetProjectStateCallback = null;
|
||||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||||
|
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||||
autoSaveTimer = setTimeout(async () => {
|
autoSaveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const state = getProjectStateCallback();
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
if (!state || (!state.tracks && !state.main_session)) return;
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
const dataJson = JSON.stringify(state);
|
const dataJson = JSON.stringify(state);
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
@@ -76,10 +78,26 @@
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
|
||||||
|
async function flushTempAutoSave() {
|
||||||
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
||||||
|
try {
|
||||||
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
|
const dataJson = JSON.stringify(state);
|
||||||
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
|
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Flush temp project warning:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.SonicStorage = {
|
window.SonicStorage = {
|
||||||
exportProjectToSFS,
|
exportProjectToSFS,
|
||||||
importProjectFromSFSFile,
|
importProjectFromSFSFile,
|
||||||
scheduleTempAutoSave
|
scheduleTempAutoSave,
|
||||||
|
flushTempAutoSave
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||||
<script src="/static/js/services/api.js?v=202607271016"></script>
|
<script src="/static/js/services/api.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202607271016"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||||
<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=202608031415" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608070950" 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,68 @@
|
|||||||
|
"""Regression tests for legacy project upgrade (items must stay on their tracks
|
||||||
|
with correct bar positions)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.api.v1.projects import upgrade_project_json_if_needed
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_project():
|
||||||
|
return {
|
||||||
|
"id": "legacy_1",
|
||||||
|
"name": "Legacy",
|
||||||
|
"bpm": 120.0, # 1 bar = 2.0s
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 1",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": "abc.wav",
|
||||||
|
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
|
||||||
|
"midiItems": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 2",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": None,
|
||||||
|
"clips": [],
|
||||||
|
"midiItems": [
|
||||||
|
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
|
||||||
|
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_keeps_items_on_their_tracks():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
assert len(tracks) == 2
|
||||||
|
t1_items = tracks[0]["items"]
|
||||||
|
t2_items = tracks[1]["items"]
|
||||||
|
# Items must NOT be merged into the first track
|
||||||
|
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
|
||||||
|
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
|
||||||
|
assert t1_items[0]["id"] == "c1"
|
||||||
|
assert t2_items[0]["id"] == "m1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_uses_bpm_based_seconds_per_bar():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
|
||||||
|
assert tracks[0]["items"][0]["start_bar"] == 1.0
|
||||||
|
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
|
||||||
|
assert tracks[1]["items"][0]["start_bar"] == 2.0
|
||||||
|
assert tracks[1]["items"][0]["duration_bars"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_skips_new_format():
|
||||||
|
data = {"main_session": {"tracks": []}}
|
||||||
|
assert upgrade_project_json_if_needed(data) is data
|
||||||
@@ -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))
|
||||||
Reference in New Issue
Block a user