Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7325fbfc45 | |||
| 94b2d2ef41 | |||
| f616edce49 | |||
| 6018263044 | |||
| 36edc9daca | |||
| 71278f2aba | |||
| 022ba38a5e | |||
| 71c3bafdb5 | |||
| 34ad02dd6b | |||
| 47b1633bd3 | |||
| 0182abf7ea | |||
| 9b6de7f857 | |||
| 84ab4ae823 | |||
| 25471e6ea5 | |||
| 0dc95386f2 | |||
| d8227904b6 | |||
| 4d10b9485b | |||
| adccf6430a | |||
| 73025fc387 | |||
| 9f762c2a78 | |||
| a0b6110fd1 | |||
| e88ce2e3ea | |||
| 827693dfc4 | |||
| d04631c7d3 | |||
| 224fd54a56 | |||
| befc0d35fd | |||
| 5e3a283bd9 | |||
| 4a04bc04ef | |||
| dc384b3102 | |||
| 0b9079632a | |||
| 9be6991222 | |||
| 0853a13f62 | |||
| c09f994ede | |||
| 249e2afea2 | |||
| b65188fcc8 | |||
| ba5883951d | |||
| 7000529d8d | |||
| 1de6bd6b57 | |||
| c435d00897 | |||
| b075a5eca7 | |||
| 36ee2bdf90 | |||
| 5423686c73 | |||
| 4db777eb5d | |||
| 1989bfb105 | |||
| d1995307f5 | |||
| f647291523 | |||
| fdaabe3a08 | |||
| 548ab1258f | |||
| 2c2ee59d7d | |||
| 7e5f406226 | |||
| ad5cda5a40 | |||
| 05e6a467d8 | |||
| 4b43f027ce | |||
| 175bce6743 | |||
| 3380b0e201 | |||
| 619a2484ce | |||
| 24ac703d4d | |||
| c47eb5d718 | |||
| 3a345c3f4e | |||
| 86b304e173 | |||
| 01d02a9d71 | |||
| 2bf9f8bcb8 | |||
| 99a445b789 | |||
| ee39dda5b9 | |||
| be8430fbd9 | |||
| f1b00eb060 | |||
| d81afba93d | |||
| 223254eb07 | |||
| 698fa818e9 | |||
| 60d05e2b4d | |||
| 0ea994fb2f | |||
| eb370319cb | |||
| 9b25766fca | |||
| dfbeb24a33 | |||
| 3c4f106e14 | |||
| 4f5aee6830 | |||
| 42d64f93d8 | |||
| 6c11daccf8 | |||
| 88007dce34 | |||
| 1790f743d6 | |||
| ef6370f92e | |||
| 3cac617aff | |||
| e1550c8ffe | |||
| c60803ee2a | |||
| 9c5507f7b2 | |||
| 47e87c8363 | |||
| 652fb98d2c |
+112
-10
@@ -1,7 +1,16 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
||||
headers: Dict[str, str] = {}
|
||||
body: Dict[str, Any] = {}
|
||||
|
||||
import json
|
||||
|
||||
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
]
|
||||
# Private ranges: only reachable when the target host is one the user has
|
||||
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||
_PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # ULA
|
||||
]
|
||||
|
||||
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||
|
||||
|
||||
def _configured_ai_hosts(user_id: str) -> set:
|
||||
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||
hosts = set()
|
||||
configs = _load_ai_configs()
|
||||
providers = configs.get(user_id) or _get_default_providers()
|
||||
for p in providers:
|
||||
base = (p.get("api_base_url") or "").strip()
|
||||
if not base:
|
||||
continue
|
||||
try:
|
||||
host = urlparse(base).hostname
|
||||
if host:
|
||||
hosts.add(host.lower())
|
||||
except Exception:
|
||||
continue
|
||||
return hosts
|
||||
|
||||
|
||||
async def _resolve_host_ips(hostname: str):
|
||||
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||
ips = []
|
||||
for info in infos:
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(info[4][0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ips
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _validate_target_url(url: str, user_id: str):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||
|
||||
allowed_hosts = _configured_ai_hosts(user_id)
|
||||
|
||||
# Hostname-level fast path for loopback hosts
|
||||
if hostname in _LOOPBACK_HOSTS:
|
||||
if hostname in allowed_hosts:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
# Try direct IP parse (hostname may itself be an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
ips = [ip]
|
||||
except ValueError:
|
||||
ips = await _resolve_host_ips(hostname)
|
||||
|
||||
if not ips:
|
||||
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||
|
||||
for ip in ips:
|
||||
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||
if hostname in allowed_hosts:
|
||||
continue
|
||||
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
|
||||
@router.post("/proxy")
|
||||
async def proxy_llm(req: ProxyRequest):
|
||||
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||
await _validate_target_url(req.url, current_user["user_id"])
|
||||
# Never forward the app's own auth token upstream.
|
||||
headers = {
|
||||
k: v for k, v in req.headers.items()
|
||||
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
req.url,
|
||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
||||
json=req.body
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||
raw = resp.text
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||
raise HTTPException(status_code=502, detail=msg)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from app.config import settings
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
from app.api.v1.projects import get_optional_user
|
||||
from app.models.user import get_db_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
def _safe_file_id(file_id: str) -> str:
|
||||
"""Strip any path components from a client-supplied file id."""
|
||||
if not file_id:
|
||||
return ""
|
||||
return os.path.basename(file_id.replace("\\", "/"))
|
||||
|
||||
def _resolve_storage_path(file_id: str) -> str:
|
||||
"""Return the existing file path (processed first, then uploads) for a
|
||||
sanitized file id, or '' when not found."""
|
||||
fid = _safe_file_id(file_id)
|
||||
if not fid:
|
||||
return ""
|
||||
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||
p = os.path.join(d, fid)
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
file_id: str
|
||||
cut_start_ms: Optional[float] = None
|
||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
ext = os.path.splitext(file.filename or "")[1]
|
||||
if not ext:
|
||||
ext = ".wav"
|
||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||
# WAV into RAM and bounds disk usage).
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
size = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||
f.close()
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||
f.write(chunk)
|
||||
|
||||
# Save original filename as sidecar metadata
|
||||
import json
|
||||
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
||||
|
||||
@router.post("/edit")
|
||||
async def edit_audio(req: EditRequest):
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
||||
if not _resolve_storage_path(req.file_id):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import edit_audio_task
|
||||
task = edit_audio_task.delay(req.dict())
|
||||
task = edit_audio_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id
|
||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
||||
|
||||
@router.get("/download/{file_id}")
|
||||
async def download_audio(file_id: str):
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
||||
elif os.path.exists(upload_path):
|
||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
path = _resolve_storage_path(file_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||
|
||||
@router.get("/waveform/{file_id}")
|
||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_peak_waveform
|
||||
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
||||
"""
|
||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_rms_waveform
|
||||
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(req.file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import analyze_ai_task
|
||||
task = analyze_ai_task.delay(
|
||||
file_id=req.file_id,
|
||||
file_id=_safe_file_id(req.file_id),
|
||||
api_base_url=req.api_base_url,
|
||||
model=req.model
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/export")
|
||||
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
|
||||
"""
|
||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
source_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
source_path = upload_path
|
||||
else:
|
||||
source_path = _resolve_storage_path(req.file_id)
|
||||
if not source_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import export_audio_task
|
||||
task = export_audio_task.delay(
|
||||
file_id=req.file_id,
|
||||
file_id=_safe_file_id(req.file_id),
|
||||
format=req.format,
|
||||
sample_rate=req.sample_rate,
|
||||
bit_depth=req.bit_depth
|
||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/ai-scan")
|
||||
async def ai_scan_audio(req: AIScanRequest):
|
||||
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
"""
|
||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||
"""
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.python_tools_engine import PythonToolsEngine
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
|
||||
+79
-14
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
import threading
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from app.models.user import get_db_connection
|
||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
||||
from app.core.auth import (
|
||||
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||
COOKIE_NAME, X_AUTH_HEADER,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||
_LOGIN_LOCK = threading.Lock()
|
||||
MAX_LOGIN_ATTEMPTS = 10
|
||||
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||
LOGIN_BLOCK_SEC = 900
|
||||
|
||||
def _check_login_ratelimit(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||
_LOGIN_FAILURES[ip] = stamps
|
||||
|
||||
def _record_login_failure(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||
stamps.append(now)
|
||||
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||
|
||||
def _record_login_success(ip: str):
|
||||
with _LOGIN_LOCK:
|
||||
_LOGIN_FAILURES.pop(ip, None)
|
||||
|
||||
def _set_auth_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
COOKIE_NAME, token,
|
||||
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||
# path="/" (default); secure flag set by proxy when behind TLS
|
||||
)
|
||||
|
||||
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||
token = None
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.split(" ")[1]
|
||||
elif x_auth_token:
|
||||
token = x_auth_token
|
||||
elif request.cookies.get(COOKIE_NAME):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||
token = authorization.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
||||
)
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
async def login(req: LoginRequest, request: Request):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_login_ratelimit(client_ip)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
||||
conn.close()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
if not verify_password(password, user["hashed_password"]):
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||
|
||||
return {
|
||||
_record_login_success(client_ip)
|
||||
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
||||
"role": user["role"],
|
||||
"must_change_password": bool(user["must_change_password"])
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
def _validate_password_strength(password: str):
|
||||
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||
lowered = password.lower()
|
||||
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest):
|
||||
async def register(req: RegisterRequest, request: Request):
|
||||
username = req.username.strip()
|
||||
email = req.email.strip()
|
||||
password = req.password.strip()
|
||||
_validate_password_strength(password)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
||||
conn.close()
|
||||
|
||||
token = create_token(user_id, username, "standard", False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
||||
"role": "standard",
|
||||
"must_change_password": False
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
||||
conn.close()
|
||||
|
||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"message": "Đổi mật khẩu thành công!",
|
||||
"access_token": new_token
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, new_token)
|
||||
return resp
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import os
|
||||
import platform
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MEDIA_EXTS = {
|
||||
".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus",
|
||||
".mid", ".midi"
|
||||
}
|
||||
|
||||
AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus"}
|
||||
MIDI_EXTS = {".mid", ".midi"}
|
||||
|
||||
|
||||
def _safe_path(path: str) -> str:
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Thiếu path")
|
||||
if "\x00" in path:
|
||||
raise HTTPException(status_code=400, detail="Path không hợp lệ")
|
||||
return os.path.normpath(path)
|
||||
|
||||
|
||||
REAL_FS_TYPES = {
|
||||
"ext2", "ext3", "ext4", "xfs", "btrfs", "jfs", "reiserfs",
|
||||
"ntfs", "ntfs3", "vfat", "exfat", "fat", "hfs", "hfsplus", "apfs",
|
||||
"zfs", "f2fs", "iso9660", "udf", "nfs", "nfs4", "cifs", "smb3", "fuseblk",
|
||||
}
|
||||
|
||||
PSEUDO_FS_TYPES = {
|
||||
"proc", "sysfs", "devpts", "tmpfs", "devtmpfs", "overlay", "squashfs",
|
||||
"cgroup", "cgroup2", "pstore", "securityfs", "debugfs", "tracefs",
|
||||
"configfs", "fusectl", "hugetlbfs", "mqueue", "binfmt_misc", "nsfs",
|
||||
"autofs", "ramfs", "efivarfs", "rpc_pipefs", "fuse", "fusefs",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/computer")
|
||||
async def list_computer_roots(current_user: dict = Depends(get_current_user)):
|
||||
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
|
||||
system = platform.system()
|
||||
roots = []
|
||||
if system == "Windows":
|
||||
import string
|
||||
for drive in string.ascii_uppercase:
|
||||
root = drive + ":\\"
|
||||
try:
|
||||
if os.path.exists(root):
|
||||
roots.append({"path": root, "name": drive + ":", "is_dir": True})
|
||||
except OSError:
|
||||
continue
|
||||
else:
|
||||
# Unix/Linux/macOS: chỉ liệt kê filesystem thật, bỏ pseudo/docker/systemd mounts
|
||||
seen = set()
|
||||
try:
|
||||
with open("/proc/mounts", "r") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
device, mount, fstype = parts[0], parts[1], parts[2]
|
||||
if fstype in PSEUDO_FS_TYPES:
|
||||
continue
|
||||
if fstype not in REAL_FS_TYPES:
|
||||
# giữ mount point root "/" nếu không thuộc pseudo
|
||||
if mount != "/":
|
||||
continue
|
||||
if mount in seen:
|
||||
continue
|
||||
seen.add(mount)
|
||||
# lọc mount point rác kiểu /run/credentials/...
|
||||
if mount.startswith("/run/") or mount.startswith("/var/lib/docker"):
|
||||
continue
|
||||
try:
|
||||
if os.path.isdir(mount):
|
||||
label = mount if mount != "/" else "Root (/)"
|
||||
roots.append({"path": mount, "name": label, "is_dir": True})
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
# macOS fallback: liệt kê /Volumes
|
||||
if system == "Darwin":
|
||||
try:
|
||||
for name in sorted(os.listdir("/Volumes")):
|
||||
full = os.path.join("/Volumes", name)
|
||||
if os.path.isdir(full):
|
||||
roots.append({"path": full, "name": name, "is_dir": True})
|
||||
except OSError:
|
||||
pass
|
||||
if not roots:
|
||||
roots = [{"path": "/", "name": "Root (/)", "is_dir": True}]
|
||||
return {"system": system, "roots": roots}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
async def browse_directory(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy thư mục")
|
||||
|
||||
dirs, files = [], []
|
||||
try:
|
||||
entries = os.listdir(resolved)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=403, detail=f"Không thể đọc thư mục: {e}")
|
||||
|
||||
for name in entries:
|
||||
if name.startswith("."):
|
||||
continue
|
||||
full = os.path.join(resolved, name)
|
||||
try:
|
||||
if os.path.isdir(full):
|
||||
dirs.append({"name": name, "path": full, "is_dir": True})
|
||||
else:
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
try:
|
||||
size = os.path.getsize(full)
|
||||
except OSError:
|
||||
size = 0
|
||||
kind = "midi" if ext in MIDI_EXTS else ("audio" if ext in AUDIO_EXTS else "other")
|
||||
files.append({
|
||||
"name": name,
|
||||
"path": full,
|
||||
"is_dir": False,
|
||||
"size_mb": round(size / (1024 * 1024), 2),
|
||||
"ext": ext,
|
||||
"kind": kind
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
dirs.sort(key=lambda d: d["name"].lower())
|
||||
files.sort(key=lambda f: f["name"].lower())
|
||||
parent = os.path.dirname(resolved)
|
||||
return {
|
||||
"path": resolved,
|
||||
"parent": parent if parent != resolved else None,
|
||||
"dirs": dirs,
|
||||
"files": files
|
||||
}
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def serve_local_file(path: str = Query(...), current_user: dict = Depends(get_current_user)):
|
||||
"""Phục vụ file audio/MIDI cục bộ để preview."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isfile(resolved):
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy file")
|
||||
ext = os.path.splitext(resolved)[1].lower()
|
||||
if ext not in MEDIA_EXTS:
|
||||
raise HTTPException(status_code=403, detail="Loại file không được hỗ trợ preview")
|
||||
media_type = "audio/wav" if ext in AUDIO_EXTS else "audio/midi"
|
||||
return FileResponse(resolved, media_type=media_type, filename=os.path.basename(resolved))
|
||||
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
||||
|
||||
# Gửi task xuống Celery Worker
|
||||
from app.tasks.worker import mix_multitrack_task
|
||||
task = mix_multitrack_task.delay(req.dict())
|
||||
task = mix_multitrack_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
|
||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||
"""
|
||||
from app.tasks.worker import process_multitrack_session_task
|
||||
task = process_multitrack_session_task.delay(req.dict())
|
||||
task = process_multitrack_session_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
|
||||
+49
-11
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
|
||||
from app.core.soundfont_inspector import SoundFontInspector
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -79,11 +79,23 @@ async def upload_soundfont(
|
||||
background_tasks: BackgroundTasks = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
||||
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if not PluginManager.validate_sf2_header(contents):
|
||||
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||
# 500MB; reading the whole body into RAM would OOM the server).
|
||||
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
||||
contents = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
contents.extend(chunk)
|
||||
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
||||
|
||||
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
@@ -139,15 +151,36 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
||||
@router.get("/soundfonts/download/{sf_id}")
|
||||
async def download_soundfont_asset(sf_id: str):
|
||||
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):
|
||||
continue
|
||||
for ext in [".sf2", ".sf3"]:
|
||||
for fname in os.listdir(base_dir):
|
||||
fbase, fext = os.path.splitext(fname)
|
||||
if fext.lower() == ext and fbase.lower() == clean_id.lower():
|
||||
full = os.path.join(base_dir, fname)
|
||||
return FileResponse(full, media_type="application/octet-stream", filename=f"soundfont{ext}")
|
||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||
# samples, so any SF3 would play silence in the browser.
|
||||
for fname in os.listdir(base_dir):
|
||||
fbase, fext = os.path.splitext(fname)
|
||||
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
|
||||
full = os.path.join(base_dir, fname)
|
||||
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
|
||||
# Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
|
||||
for fname in os.listdir(base_dir):
|
||||
fbase, fext = os.path.splitext(fname)
|
||||
if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
|
||||
full = os.path.join(base_dir, fname)
|
||||
try:
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
|
||||
if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
|
||||
return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
|
||||
result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
|
||||
if result != full and os.path.exists(result):
|
||||
return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
|
||||
except Exception as e:
|
||||
print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
|
||||
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
|
||||
raise HTTPException(status_code=404, detail="SoundFont asset not found")
|
||||
|
||||
|
||||
@@ -161,8 +194,13 @@ async def render_project(
|
||||
req: RenderRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
engine = PythonRenderEngine()
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
|
||||
# Prevent path traversal: strip any directory components and force .wav.
|
||||
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
||||
if not safe_name.lower().endswith(".wav"):
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
|
||||
+31
-9
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
return project_data
|
||||
|
||||
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 = []
|
||||
for t in tracks:
|
||||
track_id = str(t.get("id", ""))
|
||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
"id": c.get("id"),
|
||||
"name": c.get("name", "Audio Clip"),
|
||||
"type": "AUDIO_ITEM",
|
||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
||||
"duration_bars": 4.0,
|
||||
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||
"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,
|
||||
"source_data": {
|
||||
"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"),
|
||||
"name": m.get("name", "MIDI Item"),
|
||||
"type": "MIDI_ITEM",
|
||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
||||
"duration_bars": m.get("duration", 4.0),
|
||||
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"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", [])
|
||||
}
|
||||
})
|
||||
@@ -283,13 +288,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
exists = cursor.fetchone()
|
||||
if not exists:
|
||||
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||
|
||||
|
||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||
|
||||
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||
# update bypassed the quota entirely).
|
||||
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||
quota_row = cursor.fetchone()
|
||||
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||
used_row = cursor.fetchone()
|
||||
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||
max_bytes = storage_limit_mb * 1024 * 1024
|
||||
if used_bytes + new_size_bytes > max_bytes:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("""
|
||||
|
||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
configs = _load_ai_configs()
|
||||
configs[uid] = [p.dict() for p in req.providers]
|
||||
configs[uid] = [p.model_dump() for p in req.providers]
|
||||
_save_all(ai_configs=configs)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
||||
from app.models.user import get_db_connection
|
||||
from app.config import settings
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
||||
COOKIE_NAME = "sf_token"
|
||||
X_AUTH_HEADER = "X-Auth-Token"
|
||||
|
||||
def _load_or_create_secret_key() -> str:
|
||||
"""Persistent random SECRET_KEY.
|
||||
|
||||
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
|
||||
first run). Never falls back to a hardcoded value: a known secret lets
|
||||
anyone forge admin tokens.
|
||||
"""
|
||||
env_key = os.getenv("SECRET_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
|
||||
try:
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
if os.path.exists(key_file):
|
||||
with open(key_file, "r") as f:
|
||||
key = f.read().strip()
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
key = secrets.token_hex(32)
|
||||
with open(key_file, "w") as f:
|
||||
f.write(key)
|
||||
return key
|
||||
except Exception:
|
||||
# Last resort: ephemeral random key (all tokens invalid on restart).
|
||||
return secrets.token_hex(32)
|
||||
|
||||
SECRET_KEY = _load_or_create_secret_key()
|
||||
|
||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||
"""
|
||||
|
||||
+43
-21
@@ -1,4 +1,4 @@
|
||||
import os, logging
|
||||
import os, logging, math
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import scipy.signal as signal
|
||||
@@ -87,12 +87,18 @@ class PythonRenderEngine:
|
||||
return url_or_id
|
||||
return url_or_id
|
||||
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||
tracks = session.get("tracks", [])
|
||||
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||
|
||||
_channel_counter = 0
|
||||
|
||||
for track in session.get("tracks", []):
|
||||
for track in tracks:
|
||||
if solo_ids and track.get("id") not in solo_ids:
|
||||
continue
|
||||
track_type = track.get("type", "AUDIO")
|
||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
@@ -123,8 +129,17 @@ class PythonRenderEngine:
|
||||
try:
|
||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||
if sr != self.sample_rate:
|
||||
# Resampling fallback if simple, otherwise skip
|
||||
pass
|
||||
# Proper resampling: previously a silent no-op that
|
||||
# played 48kHz audio at the wrong speed/pitch.
|
||||
from scipy.signal import resample_poly
|
||||
g = math.gcd(sr, self.sample_rate)
|
||||
audio_data = resample_poly(
|
||||
audio_data,
|
||||
up=self.sample_rate // g,
|
||||
down=sr // g,
|
||||
axis=-1,
|
||||
)
|
||||
sr = self.sample_rate
|
||||
|
||||
# Handle channel mapping (Mono/Stereo)
|
||||
if len(audio_data.shape) == 1:
|
||||
@@ -148,7 +163,7 @@ class PythonRenderEngine:
|
||||
if actual_len > 0:
|
||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
||||
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||
|
||||
elif item_type == "MIDI_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
@@ -284,21 +299,27 @@ class PythonRenderEngine:
|
||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
||||
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||
|
||||
elif item_type == "SECTION_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
sec_id = source_data.get("referenced_section_id", "")
|
||||
if sec_id and sec_id in section_store:
|
||||
# Render nested section recursively
|
||||
sec_container = section_store[sec_id]
|
||||
sec_buffer = self.render_session_container(
|
||||
session=sec_container,
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
)
|
||||
# Render nested section recursively, cached per section id
|
||||
# so repeated section instances don't re-render every time.
|
||||
cache = _cache if _cache is not None else {}
|
||||
if sec_id in cache:
|
||||
sec_buffer = cache[sec_id]
|
||||
else:
|
||||
sec_buffer = self.render_session_container(
|
||||
session=section_store[sec_id],
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples,
|
||||
_cache=cache,
|
||||
)
|
||||
cache[sec_id] = sec_buffer
|
||||
|
||||
# Apply non-destructive crop/slicing on section buffer
|
||||
if offset_sample < total_samples:
|
||||
@@ -327,7 +348,7 @@ class PythonRenderEngine:
|
||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||
else:
|
||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||
try:
|
||||
@@ -341,14 +362,14 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = track_buffer[ch, indices]
|
||||
track_buffer = dry + wet * 0.5
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
if HAS_PEDALBOARD:
|
||||
try:
|
||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||
else:
|
||||
# Fallback reverb using exponentially decaying noise room impulse response
|
||||
try:
|
||||
@@ -368,7 +389,7 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = conv
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
# Process track volume
|
||||
if HAS_PEDALBOARD:
|
||||
@@ -410,7 +431,8 @@ class PythonRenderEngine:
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
total_samples=total_samples,
|
||||
_cache={},
|
||||
)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
|
||||
+339
-39
@@ -83,6 +83,35 @@ class SoundFontConverter:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _encode_sample_ogg(self, pcm: bytes, rate: int, tmp_dir: str) -> bytes:
|
||||
"""Encode a single mono 16-bit PCM slice to an Ogg Vorbis stream."""
|
||||
tmp_wav = os.path.join(tmp_dir, "sample_tmp.wav")
|
||||
tmp_ogg = os.path.join(tmp_dir, "sample_tmp.ogg")
|
||||
try:
|
||||
with open(tmp_wav, "wb") as fw:
|
||||
with wave.open(fw, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(rate)
|
||||
w.writeframes(pcm)
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", tmp_wav,
|
||||
"-c:a", "libvorbis", "-q:a", "3",
|
||||
"-f", "ogg", tmp_ogg
|
||||
], capture_output=True, timeout=600, check=True)
|
||||
with open(tmp_ogg, "rb") as fo:
|
||||
return fo.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Sample OGG encode failed: {e}")
|
||||
return b""
|
||||
finally:
|
||||
for p in [tmp_wav, tmp_ogg]:
|
||||
try:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
|
||||
has_ogg = self._check_ffmpeg_ogg()
|
||||
if not has_ogg:
|
||||
@@ -100,7 +129,7 @@ class SoundFontConverter:
|
||||
logger.warning("Not a valid SF2 file")
|
||||
return False
|
||||
|
||||
# Find smpl chunk recursively
|
||||
# Find smpl chunk (PCM sample data) recursively
|
||||
smpl = _find_chunk(data, b"smpl")
|
||||
if smpl is None:
|
||||
logger.warning("No smpl chunk found in SF2")
|
||||
@@ -113,46 +142,103 @@ class SoundFontConverter:
|
||||
logger.warning("Sample data too small")
|
||||
return False
|
||||
|
||||
tmp_wav = sf3_path + ".tmp.wav"
|
||||
tmp_ogg = sf3_path + ".tmp.ogg"
|
||||
# Locate shdr (sample headers) inside the pdta LIST
|
||||
pdta = _find_list_of_type(data, b"pdta", 12)
|
||||
if not pdta:
|
||||
logger.warning("No pdta LIST found")
|
||||
return False
|
||||
_, _, pdta_size, pdta_data_off = pdta
|
||||
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
|
||||
if not shdr:
|
||||
logger.warning("No shdr chunk found")
|
||||
return False
|
||||
_, _, shdr_size, shdr_data_off = shdr
|
||||
if shdr_size <= 0 or shdr_size % 46 != 0:
|
||||
logger.warning(f"Invalid shdr size {shdr_size}")
|
||||
return False
|
||||
n_samples = shdr_size // 46
|
||||
|
||||
# Locate ifil (version) inside the INFO LIST
|
||||
ifil_abs = None
|
||||
info = _find_list_of_type(data, b"INFO", 12)
|
||||
if info:
|
||||
_, _, info_size, info_data_off = info
|
||||
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
|
||||
if ifil:
|
||||
ifil_abs = ifil[3]
|
||||
|
||||
import tempfile
|
||||
tmp_dir = tempfile.mkdtemp(prefix="sf3conv_")
|
||||
ogg_parts = []
|
||||
new_shdr = bytearray()
|
||||
byte_offset = 0
|
||||
ogg_bytes = 0
|
||||
try:
|
||||
# Write samples as WAV
|
||||
with open(tmp_wav, "wb") as fw:
|
||||
with wave.open(fw, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(44100)
|
||||
w.writeframes(sample_data)
|
||||
for i in range(n_samples):
|
||||
base = shdr_data_off + i * 46
|
||||
# shdr layout: name[20] | start(u32) end(u32) loopstart(u32) loopend(u32) samplerate(i32) ...
|
||||
start, end, startloop, endloop, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
|
||||
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
|
||||
ogg_stream = b""
|
||||
if end >= start and start * 2 < len(sample_data):
|
||||
pcm = sample_data[start * 2:(end + 1) * 2]
|
||||
if len(pcm) >= 4:
|
||||
safe_rate = rate if 1000 < rate < 192000 else 44100
|
||||
ogg_stream = self._encode_sample_ogg(pcm, safe_rate, tmp_dir)
|
||||
# SF3: start/end are byte offsets into the concatenated OGG stream.
|
||||
# FluidSynth treats shdr `end` as EXCLUSIVE (reads [start..end-1]),
|
||||
# so end = start + ogg length.
|
||||
new_start = byte_offset
|
||||
new_end = byte_offset + len(ogg_stream)
|
||||
# OGG loop pointers are relative to the individual decompressed sample
|
||||
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
||||
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
|
||||
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
|
||||
new_stype = sampletype | 0x20
|
||||
new_shdr += data[base:base + 20] # sample name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
||||
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
|
||||
new_shdr += struct.pack("<H", new_stype)
|
||||
ogg_parts.append(ogg_stream)
|
||||
ogg_bytes += len(ogg_stream)
|
||||
byte_offset = new_start + len(ogg_stream)
|
||||
if len(ogg_stream) % 2 == 1:
|
||||
ogg_parts.append(b"\x00")
|
||||
byte_offset += 1
|
||||
|
||||
# Compress to Ogg Vorbis
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-i", tmp_wav,
|
||||
"-c:a", "libvorbis", "-q:a", "3",
|
||||
"-f", "ogg", tmp_ogg
|
||||
], capture_output=True, timeout=600, check=True)
|
||||
if not ogg_parts:
|
||||
logger.warning("No samples to encode")
|
||||
return False
|
||||
|
||||
with open(tmp_ogg, "rb") as fo:
|
||||
ogg_data = fo.read()
|
||||
|
||||
compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100
|
||||
logger.info(f"Compressed {len(sample_data)} -> {len(ogg_data)} bytes ({compression:.0f}%)")
|
||||
|
||||
ogg_padded = ogg_data if len(ogg_data) % 2 == 0 else ogg_data + b"\x00"
|
||||
new_smpl_size = len(ogg_data)
|
||||
ogg_padded = b"".join(ogg_parts)
|
||||
if len(ogg_padded) % 2 == 1:
|
||||
ogg_padded += b"\x00"
|
||||
new_smpl_size = len(ogg_padded)
|
||||
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
||||
new_padded = len(ogg_padded)
|
||||
delta = new_padded - old_padded
|
||||
delta = len(ogg_padded) - old_padded
|
||||
|
||||
# Rebuild file: replace smpl chunk and update all sizes
|
||||
out = bytearray()
|
||||
out.extend(data[:smpl_head_off]) # up to smpl chunk header
|
||||
out.extend(data[:smpl_head_off]) # up to smpl chunk header (excl. id)
|
||||
out.extend(b"smpl") # chunk id (required for valid SF3)
|
||||
out.extend(struct.pack("<I", new_smpl_size)) # new smpl size
|
||||
out.extend(ogg_padded) # compressed data (even-padded)
|
||||
out.extend(data[smpl_head_off + 8 + old_padded:]) # rest of file
|
||||
out.extend(ogg_padded) # concatenated per-sample OGG streams
|
||||
rest_off = smpl_head_off + 8 + old_padded
|
||||
rest = bytearray(data[rest_off:])
|
||||
# Patch the shdr sample headers inside the pdta copy
|
||||
shdr_in_rest = shdr_data_off - rest_off
|
||||
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
|
||||
logger.warning("shdr not found after smpl chunk")
|
||||
return False
|
||||
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
|
||||
out.extend(rest)
|
||||
|
||||
data_out = bytes(out)
|
||||
|
||||
# SF3 requires version 3.0 so FluidSynth treats it as an SF3 file
|
||||
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
|
||||
data_out = data_out[:ifil_abs] + struct.pack("<HH", 3, 0) + data_out[ifil_abs + 4:]
|
||||
|
||||
# Find sdta LIST and update its size
|
||||
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
||||
if sdta:
|
||||
@@ -160,12 +246,11 @@ class SoundFontConverter:
|
||||
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
||||
|
||||
# Update RIFF root size
|
||||
old_riff_size = struct.unpack("<I", data_out[4:8])[0]
|
||||
new_total = len(data_out) - 8
|
||||
data_out = _update_size(data_out, 4, new_total)
|
||||
|
||||
with open(sf3_path, "wb") as fout:
|
||||
fout.write(bytes(data_out))
|
||||
fout.write(data_out)
|
||||
|
||||
# Validate: check that RIFF size matches actual size
|
||||
written = os.path.getsize(sf3_path)
|
||||
@@ -174,6 +259,7 @@ class SoundFontConverter:
|
||||
logger.warning(f"Size mismatch: RIFF says {parsed_riff}, actual is {written - 8}")
|
||||
return False
|
||||
|
||||
logger.info(f"Converted {n_samples} samples -> {ogg_bytes} bytes OGG ({100 * (1 - ogg_bytes / max(len(sample_data), 1)):.0f}% smaller)")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -185,12 +271,50 @@ class SoundFontConverter:
|
||||
traceback.print_exc()
|
||||
return False
|
||||
finally:
|
||||
for p in [tmp_wav, tmp_ogg]:
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _sf3_plays_audio(path: str) -> bool:
|
||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||
against shipping malformed SF3 files that silently play nothing).
|
||||
|
||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||
high-level Synth() class does not exist in this binding, so it is never
|
||||
used here.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
import fluidsynth as _fs
|
||||
import numpy as np
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fl = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||
if h < 0:
|
||||
return False
|
||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||
frames = 8820 # 0.2s
|
||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||
_fs.fluid_synth_write_float(
|
||||
_fl, frames, buf.ctypes.data, 0, 1,
|
||||
buf.ctypes.data + frames * 4, 0, 1
|
||||
)
|
||||
_fs.fluid_synth_noteoff(_fl, 0, 60)
|
||||
rms = float(np.sqrt(np.mean(buf ** 2)))
|
||||
return rms > 1e-4
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
_fs.delete_fluid_synth(_fl)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _find_sf3_converter():
|
||||
@@ -208,9 +332,18 @@ class SoundFontConverter:
|
||||
|
||||
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
|
||||
|
||||
# Reuse a working SF3 if it is newer than the SF2 AND actually plays
|
||||
# audio. Malformed SF3s (e.g. produced by an older converter) are
|
||||
# re-converted automatically instead of being shipped silently broken.
|
||||
if os.path.exists(sf3_path) and os.path.getmtime(sf3_path) >= os.path.getmtime(sf2_path):
|
||||
logger.info(f"SF3 already up-to-date: {sf3_path}")
|
||||
return sf3_path
|
||||
if self._sf3_plays_audio(sf3_path):
|
||||
logger.info(f"SF3 already up-to-date: {sf3_path}")
|
||||
return sf3_path
|
||||
logger.warning(f"Existing SF3 does not play audio, re-converting: {sf3_path}")
|
||||
try:
|
||||
os.remove(sf3_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
converter = self._find_sf3_converter()
|
||||
try:
|
||||
@@ -228,9 +361,16 @@ class SoundFontConverter:
|
||||
|
||||
if converter == "python":
|
||||
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
|
||||
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
|
||||
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
|
||||
return sf3_path
|
||||
if self._sf3_plays_audio(sf3_path):
|
||||
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
|
||||
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
|
||||
return sf3_path
|
||||
# Conversion produced a broken file — never ship it
|
||||
logger.warning(f"Converted SF3 failed audio verification, removing: {sf3_path}")
|
||||
try:
|
||||
os.remove(sf3_path)
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path")
|
||||
return sf2_path
|
||||
|
||||
@@ -242,6 +382,165 @@ class SoundFontConverter:
|
||||
logger.error(f"Error converting {sf2_path}: {e}")
|
||||
return sf2_path
|
||||
|
||||
def sf3_to_sf2(self, sf3_path: str, sf2_path: str = None) -> str:
|
||||
"""Convert an SF3 (Ogg Vorbis samples) soundfont into a playable SF2.
|
||||
|
||||
The client FluidSynth WASM cannot decode Ogg Vorbis/SF3 samples, so any
|
||||
SF3 soundfont (converted or uploaded) plays silence. Decompressing to
|
||||
SF2 makes every instrument audible again.
|
||||
"""
|
||||
if not os.path.exists(sf3_path):
|
||||
raise FileNotFoundError(f"Source SF3 file not found: {sf3_path}")
|
||||
|
||||
try:
|
||||
with open(sf3_path, "rb") as f:
|
||||
data = f.read()
|
||||
except Exception as e:
|
||||
raise IOError(f"Cannot read {sf3_path}: {e}")
|
||||
|
||||
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"sfbk":
|
||||
raise ValueError(f"Not a valid SoundFont file: {sf3_path}")
|
||||
|
||||
smpl = _find_chunk(data, b"smpl")
|
||||
if not smpl:
|
||||
raise ValueError("No smpl chunk found")
|
||||
smpl_head_off, _, smpl_old_size, smpl_data_off = smpl
|
||||
sample_data = data[smpl_data_off:smpl_data_off + smpl_old_size]
|
||||
|
||||
pdta = _find_list_of_type(data, b"pdta", 12)
|
||||
if not pdta:
|
||||
raise ValueError("No pdta LIST found")
|
||||
_, _, pdta_size, pdta_data_off = pdta
|
||||
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
|
||||
if not shdr:
|
||||
raise ValueError("No shdr chunk found")
|
||||
_, _, shdr_size, shdr_data_off = shdr
|
||||
if shdr_size <= 0 or shdr_size % 46 != 0:
|
||||
raise ValueError(f"Invalid shdr size {shdr_size}")
|
||||
n_samples = shdr_size // 46
|
||||
|
||||
ifil_abs = None
|
||||
info = _find_list_of_type(data, b"INFO", 12)
|
||||
if info:
|
||||
_, _, info_size, info_data_off = info
|
||||
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
|
||||
if ifil:
|
||||
ifil_abs = ifil[3]
|
||||
|
||||
import tempfile
|
||||
tmp_dir = tempfile.mkdtemp(prefix="sf2conv_")
|
||||
pcm_parts = []
|
||||
new_shdr = bytearray()
|
||||
frame_offset = 0
|
||||
total_pcm_bytes = 0
|
||||
try:
|
||||
for i in range(n_samples):
|
||||
base = shdr_data_off + i * 46
|
||||
name = data[base:base + 20]
|
||||
start, end, loopstart, loopend, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
|
||||
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
|
||||
frames = 0
|
||||
pcm = b""
|
||||
if end > start and start < len(sample_data):
|
||||
# FluidSynth reads the OGG region as [start..end-1]
|
||||
ogg = sample_data[start:min(end, len(sample_data))]
|
||||
if len(ogg) >= 4 and ogg[:4] == b"OggS":
|
||||
pcm = self._decode_sample_ogg(ogg, tmp_dir)
|
||||
frames = len(pcm) // 2
|
||||
new_start = frame_offset
|
||||
new_end = frame_offset + frames
|
||||
# SF3 loop points are relative to the decompressed sample; SF2 needs absolute
|
||||
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
||||
new_loopend = loopend + new_start if (loopstart or loopend) else 0
|
||||
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
|
||||
new_stype = sampletype & ~0x20
|
||||
new_shdr += name
|
||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
||||
new_shdr += data[base + 40:base + 44]
|
||||
new_shdr += struct.pack("<H", new_stype)
|
||||
pcm_parts.append(pcm)
|
||||
frame_offset += frames
|
||||
if frames:
|
||||
total_pcm_bytes += frames * 2
|
||||
|
||||
if total_pcm_bytes == 0:
|
||||
logger.warning("SF3 contained no decodable samples")
|
||||
return sf3_path
|
||||
|
||||
new_smpl_size = total_pcm_bytes
|
||||
if new_smpl_size % 2 == 1:
|
||||
new_smpl_size += 1
|
||||
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
||||
delta = new_smpl_size - old_padded
|
||||
|
||||
out = bytearray()
|
||||
out.extend(data[:smpl_head_off])
|
||||
out.extend(b"smpl")
|
||||
out.extend(struct.pack("<I", new_smpl_size))
|
||||
for part in pcm_parts:
|
||||
out.extend(part)
|
||||
if total_pcm_bytes % 2 == 1:
|
||||
out.extend(b"\x00")
|
||||
rest_off = smpl_head_off + 8 + old_padded
|
||||
rest = bytearray(data[rest_off:])
|
||||
shdr_in_rest = shdr_data_off - rest_off
|
||||
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
|
||||
logger.warning("shdr not found after smpl chunk")
|
||||
return sf3_path
|
||||
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
|
||||
out.extend(rest)
|
||||
|
||||
data_out = bytes(out)
|
||||
|
||||
# Back to SF2 version 2.01
|
||||
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
|
||||
data_out = data_out[:ifil_abs] + struct.pack("<HH", 2, 1) + data_out[ifil_abs + 4:]
|
||||
|
||||
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
||||
if sdta:
|
||||
lh_off, _, lh_size, ld_off = sdta
|
||||
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
||||
|
||||
data_out = _update_size(data_out, 4, len(data_out) - 8)
|
||||
|
||||
sf2_path = sf2_path or (os.path.splitext(sf3_path)[0] + ".sf2")
|
||||
with open(sf2_path, "wb") as fout:
|
||||
fout.write(data_out)
|
||||
logger.info(f"Converted SF3 -> SF2: {sf2_path} ({os.path.getsize(sf2_path) / 1048576:.1f} MB)")
|
||||
return sf2_path
|
||||
finally:
|
||||
try:
|
||||
import shutil
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _decode_sample_ogg(self, ogg: bytes, tmp_dir: str) -> bytes:
|
||||
"""Decode an Ogg Vorbis stream to mono 16-bit PCM; returns PCM bytes."""
|
||||
tmp_ogg = os.path.join(tmp_dir, "sample.ogg")
|
||||
tmp_pcm = os.path.join(tmp_dir, "sample.pcm")
|
||||
try:
|
||||
with open(tmp_ogg, "wb") as fo:
|
||||
fo.write(ogg)
|
||||
r = subprocess.run(
|
||||
["ffmpeg", "-y", "-v", "error", "-i", tmp_ogg, "-f", "s16le", "-ac", "1", tmp_pcm],
|
||||
capture_output=True, timeout=600)
|
||||
if r.returncode != 0:
|
||||
logger.warning(f"OGG decode failed: {r.stderr.decode(errors='replace')[:120]}")
|
||||
return b""
|
||||
with open(tmp_pcm, "rb") as fp:
|
||||
return fp.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"OGG decode error: {e}")
|
||||
return b""
|
||||
finally:
|
||||
for p in [tmp_ogg, tmp_pcm]:
|
||||
try:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def batch_convert_all(self):
|
||||
for sdir in self.target_dirs:
|
||||
if not os.path.isdir(sdir):
|
||||
@@ -249,3 +548,4 @@ class SoundFontConverter:
|
||||
for fname in sorted(os.listdir(sdir)):
|
||||
if fname.lower().endswith(".sf2"):
|
||||
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
|
||||
|
||||
|
||||
+59
-39
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
import numpy as np
|
||||
import functools
|
||||
from ctypes import c_int, c_char_p, c_void_p
|
||||
from ctypes import c_char_p
|
||||
|
||||
def midi_note_to_freq(note_number: int) -> float:
|
||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
|
||||
return _PLUGIN_MANAGER_INSTANCE
|
||||
|
||||
def load_soundfont_cached(path: str):
|
||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
||||
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||
|
||||
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||
do not exist in this binding, so they are never used here.
|
||||
"""
|
||||
global _FLUID_CACHE
|
||||
if not HAS_PYFLUIDSYNTH:
|
||||
return None
|
||||
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
|
||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||
return fl
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
||||
font_id = fl.sfload(path)
|
||||
fl.program_select(0, font_id, 0, 0)
|
||||
import fluidsynth as _fs
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||
fl = _fs.new_fluid_synth(_settings)
|
||||
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||
if font_id < 0:
|
||||
_fs.delete_fluid_synth(fl)
|
||||
return None
|
||||
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||
_FLUID_CACHE[path] = (fl, 1)
|
||||
return fl
|
||||
except Exception:
|
||||
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
|
||||
fl, ref = _FLUID_CACHE[path]
|
||||
if ref <= 1:
|
||||
try:
|
||||
fl.delete()
|
||||
import fluidsynth as _fs
|
||||
_fs.delete_fluid_synth(fl)
|
||||
except Exception:
|
||||
pass
|
||||
del _FLUID_CACHE[path]
|
||||
@@ -245,38 +256,47 @@ class PluginManager:
|
||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||
path = os.path.join(d, f)
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.Synth()
|
||||
fid = fl.sfload(path)
|
||||
if fid < 0:
|
||||
fl.delete()
|
||||
continue
|
||||
presets = []
|
||||
_fl = fluidsynth._fl
|
||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
||||
_fl.fluid_preset_get_name.restype = c_char_p
|
||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
||||
if sfont_ptr:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
name_val = c_char_p(name_ptr).value
|
||||
if name_val:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": name_val.decode("utf-8", errors="replace")
|
||||
})
|
||||
fl.delete()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
import fluidsynth as _fs
|
||||
# Low-level CFFI API (same as render_engine); never use
|
||||
# the high-level Synth() class that this binding lacks.
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_synth = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||
if fid < 0:
|
||||
continue
|
||||
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||
presets = []
|
||||
if sfont:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
try:
|
||||
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
if hasattr(_fs, "ffi"):
|
||||
raw = _fs.ffi.string(name_ptr)
|
||||
else:
|
||||
raw = c_char_p(name_ptr).value
|
||||
if raw:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": raw.decode("utf-8", errors="replace")
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
finally:
|
||||
try:
|
||||
_fs.delete_fluid_synth(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
import traceback; traceback.print_exc()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
||||
|
||||
+33
-27
@@ -1,8 +1,11 @@
|
||||
import os, threading
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from app.config import settings
|
||||
from app.api.v1.audio import router as audio_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
@@ -14,24 +17,40 @@ from app.api.v1.user_config import router as user_config_router
|
||||
from app.api.v1.ai_proxy import router as ai_proxy_router
|
||||
from app.api.v1.ai_presets import router as ai_presets_router
|
||||
from app.api.v1.plugins import router as plugins_router
|
||||
from app.api.v1.media import router as media_router
|
||||
from app.core.auth import seed_admin
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
|
||||
# Ensure storage directories exist
|
||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine")
|
||||
_SF_SCANNER_STOP = None
|
||||
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
seed_admin()
|
||||
scanner = SoundFontAutoScanner()
|
||||
global _SF_SCANNER_STOP
|
||||
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||
yield
|
||||
# Shutdown
|
||||
if _SF_SCANNER_STOP is not None:
|
||||
_SF_SCANNER_STOP.set()
|
||||
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -53,25 +72,8 @@ app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config
|
||||
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
# Seed admin user on startup
|
||||
@app.on_event("startup")
|
||||
async def startup_seed_admin():
|
||||
seed_admin()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_convert_soundfonts():
|
||||
def _run():
|
||||
try:
|
||||
SoundFontConverter().batch_convert_all()
|
||||
except Exception as e:
|
||||
print(f"[Startup] SoundFont conversion error: {e}")
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_sf_scanner():
|
||||
scanner = SoundFontAutoScanner()
|
||||
scanner.start_background(interval=30)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
@@ -79,14 +81,18 @@ async def get_index():
|
||||
if not os.path.exists(index_path):
|
||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||
with open(index_path, "r", encoding="utf-8") as file:
|
||||
return HTMLResponse(content=file.read(), status_code=200)
|
||||
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")
|
||||
async def get_favicon():
|
||||
import os
|
||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||
if os.path.exists(favicon_path):
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||
return HTMLResponse(content="", status_code=404)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||
"color": { "type": ["string", "null"], "default": null },
|
||||
"volume_db": { "type": "number", "default": 0.0 },
|
||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||
"mute": { "type": "boolean", "default": false },
|
||||
|
||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.config import settings
|
||||
|
||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||
# dev database is never touched by the test suite.
|
||||
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
|
||||
def get_db_connection():
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||
# cleanup consistent when users are deleted.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
@@ -89,6 +95,16 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
|
||||
# Placeholder user for anonymous autosave: projects are saved with
|
||||
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||
if not cursor.fetchone():
|
||||
import secrets as _secrets
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||
""", (_secrets.token_hex(32), time.time()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
+6784
-610
File diff suppressed because it is too large
Load Diff
+790
-125
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 = [
|
||||
{
|
||||
id: 'arpeggio',
|
||||
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
|
||||
} else {
|
||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||
},
|
||||
body: JSON.stringify({ url, headers, body })
|
||||
});
|
||||
}
|
||||
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
||||
return {
|
||||
DEFAULT_TOOLS,
|
||||
REARRANGE_TOOL_SPEC,
|
||||
REARRANGE_EXTEND_TOOL_SPEC,
|
||||
REARRANGE_SCENARIOS,
|
||||
detectRearrangeScenario,
|
||||
buildRearrangeMessage,
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
if (!track.midiItems || !track.midiItems.length) continue;
|
||||
if (track.muted) continue;
|
||||
|
||||
var isSameTrack = track.id === targetTrackId;
|
||||
var trackGhostNotes = [];
|
||||
|
||||
for (var j = 0; j < track.midiItems.length; j++) {
|
||||
@@ -32,7 +33,10 @@
|
||||
var itemStartBeat = item.startTime / secondsPerBeat;
|
||||
var itemEndBeat = (item.startTime + item.duration) / secondsPerBeat;
|
||||
|
||||
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) continue;
|
||||
// Same-track items always contribute notes (show whole track); cross-track only when overlapping the window
|
||||
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) {
|
||||
if (!isSameTrack) continue;
|
||||
}
|
||||
|
||||
var notes = item.notes || [];
|
||||
for (var k = 0; k < notes.length; k++) {
|
||||
@@ -40,7 +44,7 @@
|
||||
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
||||
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
||||
|
||||
if (noteAbsStart >= windowEndBeat) continue;
|
||||
if (noteAbsStart >= windowEndBeat && !isSameTrack) continue;
|
||||
|
||||
trackGhostNotes.push({
|
||||
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
||||
@@ -48,6 +52,7 @@
|
||||
relative_start_beat: noteAbsStart - windowStartBeat,
|
||||
duration_beats: (note.duration_beats || 1),
|
||||
velocity: note.velocity,
|
||||
item_id: item.id,
|
||||
original_track_name: track.name,
|
||||
original_track_color: track.color || '#888888'
|
||||
});
|
||||
@@ -59,6 +64,7 @@
|
||||
track_id: track.id,
|
||||
track_name: track.name,
|
||||
track_color: track.color || '#6b7280',
|
||||
isSameTrack: isSameTrack,
|
||||
notes: trackGhostNotes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
(function () {
|
||||
const RENDER_BLOCK = 512;
|
||||
const QUEUE_TARGET = 4;
|
||||
const QUEUE_TARGET = 16;
|
||||
let _audioCtx = null;
|
||||
let _fluidModule = null;
|
||||
let _synthPtr = null;
|
||||
@@ -22,14 +22,21 @@
|
||||
let _loadedFonts = {};
|
||||
let _activeOscillators = {};
|
||||
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 _loadPromises = {};
|
||||
let _sfloadSeq = 0;
|
||||
|
||||
const getCtx = function () {
|
||||
if (_audioCtx) {
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_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;
|
||||
}
|
||||
@@ -38,7 +45,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = ctx.createGain();
|
||||
_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;
|
||||
}
|
||||
@@ -51,7 +59,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = window.__sharedAudioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
||||
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return window.__sharedAudioCtx;
|
||||
};
|
||||
@@ -59,6 +68,36 @@
|
||||
const SonicSF = {
|
||||
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);
|
||||
_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) {
|
||||
if (_initialized && _fluidModule) return;
|
||||
if (_initPromise) return _initPromise;
|
||||
@@ -79,19 +118,27 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_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);
|
||||
|
||||
// ── Renderer selection ──
|
||||
// ScriptProcessor is the default and FINAL choice: it is
|
||||
// pull-based (onaudioprocess is invoked by the audio thread),
|
||||
// so it cannot starve when the main thread is busy (font
|
||||
// loading, WASM decode, UI). The AudioWorklet push model
|
||||
// (setInterval on the main thread) starves under load and
|
||||
// produces SILENCE on this machine — repeatedly confirmed.
|
||||
// The deprecation console warning is purely cosmetic.
|
||||
var _useScriptNode = true;
|
||||
try {
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
|
||||
console.log("[SonicSF] Worklet registered OK");
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
|
||||
console.log("[SonicSF] Worklet registered OK (unused)");
|
||||
} catch (e) {
|
||||
console.warn("[SonicSF] Worklet reg failed:", e);
|
||||
}
|
||||
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
|
||||
|
||||
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
||||
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
||||
@@ -104,6 +151,11 @@
|
||||
},
|
||||
TOTAL_MEMORY: TOTAL_MEMORY,
|
||||
printErr: function (msg) {
|
||||
// "No preset found on channel" is FluidSynth's
|
||||
// expected notice when a soundfont simply has no
|
||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||
// font) — the note is just silent, not an error.
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -114,9 +166,9 @@
|
||||
|
||||
_settingsPtr = _fluidModule._new_fluid_settings();
|
||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.sample-rate", _audioCtx.sampleRate || 44100);
|
||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 2.0);
|
||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 1.0);
|
||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.polyphony", 256);
|
||||
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 1);
|
||||
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 0);
|
||||
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.ladspa.active", 0);
|
||||
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
|
||||
console.log("[SonicSF] FluidSynth settings configured");
|
||||
@@ -132,7 +184,15 @@
|
||||
|
||||
if (!_useScriptNode) {
|
||||
try {
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
|
||||
// Force stereo output regardless of the device's
|
||||
// channel count — FluidSynth renders stereo, and a
|
||||
// mono output would crash the worklet (out[1] undefined).
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge', {
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit'
|
||||
});
|
||||
_workletNode.connect(_gainNode);
|
||||
console.log("[SonicSF] AudioWorklet node connected via gain");
|
||||
_startRenderLoop();
|
||||
@@ -146,17 +206,31 @@
|
||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||
var lp = _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) {
|
||||
var left = e.outputBuffer.getChannelData(0);
|
||||
var right = e.outputBuffer.getChannelData(1);
|
||||
var sz = left.length;
|
||||
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);
|
||||
var hf = _fluidModule.HEAPF32;
|
||||
var lpb = lp >> 2, rpb = rp >> 2;
|
||||
for (var si = 0; si < sz; si++) {
|
||||
left[si] = hf[lpb + si];
|
||||
right[si] = hf[rpb + si];
|
||||
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||
// 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) {}
|
||||
};
|
||||
@@ -186,11 +260,16 @@
|
||||
},
|
||||
|
||||
_tryLoadSFL: function (buf, ext) {
|
||||
var fname = '/' + ext + '_' + Date.now();
|
||||
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
||||
var cPath = this._allocCStr(fname);
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 1);
|
||||
// reset_presets = 0: loading a NEW soundfont must NOT reset the
|
||||
// presets already selected on other channels. With 1, FluidSynth
|
||||
// re-points every channel to the new font's preset 0, so loading a
|
||||
// second instrument silently changes the first one's sound
|
||||
// (decay/loop envelope…).
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 0);
|
||||
_fluidModule._free(cPath);
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
return handle;
|
||||
@@ -203,19 +282,63 @@
|
||||
_currentSfId = sfId;
|
||||
return true;
|
||||
}
|
||||
// Deduplicate concurrent loads: rapid key presses (or several armed
|
||||
// tracks) all call loadSoundFont for the same font before the first
|
||||
// load resolves. Without this, the same soundfont is sfload'd several
|
||||
// times (handles 1,2,3,4…) — wasting the 256MB WASM heap and stalling
|
||||
// notes until each load finishes (audible lag, then silence).
|
||||
if (!_loadPromises[sfId]) {
|
||||
_loadPromises[sfId] = this._doLoadSoundFont(sfId).then(function (ok) {
|
||||
// Do NOT cache failures: a transient error (network hiccup,
|
||||
// memory pressure) must not permanently kill the instrument —
|
||||
// the next note retries the load and recovers.
|
||||
if (!ok) delete _loadPromises[sfId];
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
return _loadPromises[sfId];
|
||||
},
|
||||
|
||||
_doLoadSoundFont: async function (sfId) {
|
||||
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 buf = cache ? await cache.getBuffer(sfId) : null;
|
||||
if (!buf) {
|
||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
if (buf) {
|
||||
var cachedOk = this._tryLoadSFL(buf, '.sf3');
|
||||
if (cachedOk === -1) cachedOk = this._tryLoadSFL(buf, '.sf2');
|
||||
if (cachedOk !== -1) {
|
||||
_sfHandleMap.set(sfId, cachedOk);
|
||||
_currentSfId = sfId;
|
||||
_loadedFonts[sfId] = true;
|
||||
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
|
||||
return true;
|
||||
}
|
||||
// Stale/corrupt cache (e.g. old SF3 buffers the WASM can't
|
||||
// decode) — drop it and re-download from the server.
|
||||
console.warn("[SonicSF] Cached SoundFont unplayable, re-downloading:", sfId);
|
||||
try { await cache.saveBuffer(sfId, null); } catch (e2) {}
|
||||
}
|
||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||
// 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;
|
||||
}
|
||||
buf = await resp.arrayBuffer();
|
||||
if (cache) await cache.saveBuffer(sfId, buf);
|
||||
resp = resp2;
|
||||
}
|
||||
buf = await resp.arrayBuffer();
|
||||
if (cache) await cache.saveBuffer(sfId, buf);
|
||||
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
||||
if (sfHandle === -1) {
|
||||
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
|
||||
@@ -245,16 +368,16 @@
|
||||
if (!ok) return;
|
||||
}
|
||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
||||
if (!_engineChMap[engKey]) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (!_engineChMap[engKey]) {
|
||||
var allocCh = this.allocateChannel(bank);
|
||||
_engineChMap[engKey] = allocCh;
|
||||
channel = allocCh;
|
||||
} else {
|
||||
_engineChMap[engKey] = channel;
|
||||
channel = _engineChMap[engKey];
|
||||
}
|
||||
} else {
|
||||
channel = _engineChMap[engKey];
|
||||
} else if (!_engineChMap[engKey]) {
|
||||
_engineChMap[engKey] = channel;
|
||||
}
|
||||
var sfHandle = _sfHandleMap.get(sfId);
|
||||
if (sfHandle !== undefined) {
|
||||
@@ -371,10 +494,11 @@
|
||||
},
|
||||
|
||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
||||
)));
|
||||
var parsedPitch = parseInt(note);
|
||||
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||
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 usedBank = 0, usedProg = 0;
|
||||
if (synthEngine) {
|
||||
@@ -407,30 +531,99 @@
|
||||
var self = this;
|
||||
var doNote = function () {
|
||||
try {
|
||||
var finalBank = usedBank;
|
||||
var finalProg = usedProg;
|
||||
var finalBank = parseInt(usedBank);
|
||||
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;
|
||||
if (_channels[ch] && _channels[ch].program !== undefined) {
|
||||
finalBank = _channels[ch].bank;
|
||||
finalProg = _channels[ch].program;
|
||||
if (_channels[ch].sfId !== undefined) {
|
||||
finalSfId = _channels[ch].sfId;
|
||||
var cachedCh = _channels[ch];
|
||||
// The note's own synth engine (track instrument) is
|
||||
// authoritative. Channel state is only a cache: it must never
|
||||
// mask the track's instrument, otherwise multi-track ARM or a
|
||||
// re-picked instrument plays the wrong soundfont. Without an
|
||||
// engine, fall back to the soundfont configured on the channel.
|
||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||
finalBank = parseInt(cachedCh.bank) || 0;
|
||||
finalProg = parseInt(cachedCh.program) || 0;
|
||||
finalSfId = cachedCh.sfId;
|
||||
}
|
||||
// Ensure the soundfont is actually loaded before the note plays.
|
||||
// Quick instrument pick on a track does not pre-load it, so load
|
||||
// lazily here and retry the note once the font is ready.
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
// Program change at note time, not call time — ensures correct
|
||||
// instrument for each item regardless of processing order.
|
||||
if (synthEngine || program !== undefined) {
|
||||
// Skip if the channel already has this exact instrument (avoids
|
||||
// per-note soundfont reloads that cause audible crackle/glitches).
|
||||
// ⚠️ 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) {
|
||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||
if (sfHandle !== undefined) {
|
||||
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) {}
|
||||
} else {
|
||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } catch (e) {}
|
||||
}
|
||||
if (!_channels[ch]) _channels[ch] = {};
|
||||
_channels[ch].bank = finalBank;
|
||||
_channels[ch].program = finalProg;
|
||||
_channels[ch].sfId = finalSfId;
|
||||
}
|
||||
console.log("[SonicSF] noteOn ch:", ch, "pitch:", midiPitch, "vel:", midiVel);
|
||||
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||
@@ -446,7 +639,7 @@
|
||||
if (arr.length === 0) delete _activeNotes[noteMapKey];
|
||||
}
|
||||
} catch (e) {}
|
||||
}, durSec * 1000);
|
||||
}, durationMs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[SonicSF] FluidSynth noteOn error:", e);
|
||||
@@ -461,6 +654,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) {
|
||||
var ctx = getCtx();
|
||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||
@@ -477,7 +695,11 @@
|
||||
var oscType = 'triangle';
|
||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||
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;
|
||||
}
|
||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||
@@ -495,7 +717,8 @@
|
||||
osc.frequency.setValueAtTime(freq, 0);
|
||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||
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;
|
||||
noteGain.gain.setValueAtTime(0, startAt);
|
||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||
@@ -516,9 +739,17 @@
|
||||
|
||||
stopAll: function () {
|
||||
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++) {
|
||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||
try { _fluidModule._fluid_synth_all_sounds_off(_synthPtr, ch); } catch (e) {}
|
||||
}
|
||||
}
|
||||
while (_scheduledNotes.length > 0) {
|
||||
@@ -535,6 +766,7 @@
|
||||
} catch (e) {}
|
||||
});
|
||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||
_activeNotes = {};
|
||||
},
|
||||
|
||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||
@@ -559,10 +791,13 @@
|
||||
var leftPtr = _leftBufPtr;
|
||||
var rightPtr = _rightBufPtr;
|
||||
var block = RENDER_BLOCK;
|
||||
var queueDepth = 0;
|
||||
var maxQueue = QUEUE_TARGET;
|
||||
var queueDepth = 0;
|
||||
var lastTick = performance.now();
|
||||
var frameMs = (block / _audioCtx.sampleRate) * 1000;
|
||||
|
||||
var _dbgPeak = 0;
|
||||
// Track consumption by wall-clock time instead of async messages — immune
|
||||
// to message-latency races that could underrun (silence gaps → crackle).
|
||||
function pushFrame() {
|
||||
if (!Module || !synth || !node) return;
|
||||
try {
|
||||
@@ -573,18 +808,6 @@
|
||||
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
|
||||
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
|
||||
var rightArr = new Float32Array(Module.HEAPF32.subarray(rpb, rpb + block));
|
||||
var peak = 0;
|
||||
var avg = 0;
|
||||
for (var si = 0; si < leftArr.length; si++) {
|
||||
var abs = leftArr[si] > 0 ? leftArr[si] : -leftArr[si];
|
||||
if (abs > peak) peak = abs;
|
||||
avg += abs;
|
||||
}
|
||||
avg /= leftArr.length;
|
||||
if (!_dbgPeak) {
|
||||
_dbgPeak = 1;
|
||||
console.log("[SonicSF] FRAME peak:", peak.toFixed(6), "avg:", avg.toFixed(8), "gain check:", Module._fluid_synth_get_gain ? Module._fluid_synth_get_gain(synth) : 'N/A');
|
||||
}
|
||||
node.port.postMessage({ type: 'PCM', L: leftArr, R: rightArr }, [leftArr.buffer, rightArr.buffer]);
|
||||
queueDepth++;
|
||||
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
|
||||
@@ -595,14 +818,16 @@
|
||||
_renderTimer = null;
|
||||
return;
|
||||
}
|
||||
var needed = maxQueue - queueDepth;
|
||||
var now = performance.now();
|
||||
queueDepth = Math.max(0, queueDepth - (now - lastTick) / frameMs);
|
||||
lastTick = now;
|
||||
var needed = Math.min(maxQueue - queueDepth, maxQueue);
|
||||
for (var i = 0; i < needed; i++) {
|
||||
pushFrame();
|
||||
}
|
||||
queueDepth = Math.max(0, queueDepth - 1);
|
||||
}
|
||||
|
||||
_renderTimer = setInterval(fillLoop, Math.max(8, (block / _audioCtx.sampleRate) * 1000 * 0.75));
|
||||
_renderTimer = setInterval(fillLoop, Math.max(4, frameMs * 0.5));
|
||||
}
|
||||
|
||||
function _stopRenderLoop() {
|
||||
|
||||
@@ -60,11 +60,13 @@
|
||||
}
|
||||
|
||||
let autoSaveTimer = null;
|
||||
let lastGetProjectStateCallback = null;
|
||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const state = getProjectStateCallback();
|
||||
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||
if (!state || (!state.tracks && !state.main_session)) return;
|
||||
const dataJson = JSON.stringify(state);
|
||||
localStorage.setItem('sonic_temp_project', dataJson);
|
||||
@@ -76,10 +78,26 @@
|
||||
}
|
||||
}, 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 = {
|
||||
exportProjectToSFS,
|
||||
importProjectFromSFSFile,
|
||||
scheduleTempAutoSave
|
||||
scheduleTempAutoSave,
|
||||
flushTempAutoSave
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -15,29 +15,28 @@ class FluidSynthBridge extends AudioWorkletProcessor {
|
||||
|
||||
process(inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
if (!out) return true;
|
||||
if (!out || out.length === 0) return true;
|
||||
this.called++;
|
||||
const numCh = out.length;
|
||||
const len = out[0].length;
|
||||
const qL = this.leftQ;
|
||||
const qR = this.rightQ;
|
||||
let fi = 0;
|
||||
let si = 0;
|
||||
// Handle any output channel count (mono devices produce 1 channel, so
|
||||
// out[1] may be undefined — never write into a missing channel).
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
|
||||
out[0][i] = qL[fi][si];
|
||||
out[1][i] = qR[fi][si];
|
||||
if (fi >= qL.length) {
|
||||
for (let c = 0; c < numCh; c++) out[c][i] = 0;
|
||||
continue;
|
||||
}
|
||||
for (let c = 0; c < numCh; c++) {
|
||||
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
|
||||
}
|
||||
si++;
|
||||
if (si >= qL[fi].length) { fi++; si = 0; }
|
||||
}
|
||||
if (fi > 0) { this.leftQ.splice(0, fi); this.rightQ.splice(0, fi); }
|
||||
if (this.called % 50 === 0) {
|
||||
var pk = 0;
|
||||
for (var j = 0; j < len; j++) {
|
||||
var v = out[0][j] > 0 ? out[0][j] : -out[0][j];
|
||||
if (v > pk) pk = v;
|
||||
}
|
||||
if (pk > 0) console.log('[FluidSynth:bridge] process #' + this.called + ' peak:' + pk.toFixed(6) + ' q:' + qL.length);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||
@@ -8,22 +8,23 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></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/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/soundfontPlayer.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608060630"></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/pianoRollTabService.js?v=202607272044"></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/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607302132" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608061030" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
@@ -380,6 +381,32 @@
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
||||
}
|
||||
|
||||
/* Media Explorer: horizontal volume slider + selected file row */
|
||||
input[type=range].me-fader-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #111;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type=range].me-fader-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
height: 16px;
|
||||
width: 12px;
|
||||
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
|
||||
border: 1px solid #000;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-row-selected {
|
||||
background-color: #3399ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.file-row-selected .file-icon { color: #ffffff !important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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,349 @@
|
||||
# DETAILED SPECIFICATION FOR DAW MEDIA EXPLORER PANEL INTERFACE & HTML5 SOURCE CODE
|
||||
|
||||
This document details the design structure, features, and executable HTML5 source code for a general-purpose Media Explorer Panel interface styled after the REAPER DAW.
|
||||
|
||||
---
|
||||
|
||||
## I. DETAILED INTERFACE REGIONS
|
||||
|
||||
The interface is divided vertically into 3 primary functional regions, arranged from top to bottom:
|
||||
|
||||
### 1. Top Navigation Toolbar
|
||||
|
||||
* **Left Navigation Button Group:** **←** (Back), **→** (Forward), **↑** (Up to Parent Directory), **↻** (Refresh).
|
||||
* **Directory Address Bar:** Displays the current folder path with a drop-down menu for quick-access path selection.
|
||||
* **Filter / Search Box:** Input field supporting quick file filtering and keyword searching.
|
||||
* **View Mode Button:** Toggle button for switching file display layouts (*Details* / *List* view).
|
||||
|
||||
### 2. Middle Split Panel (Directory Tree + File List)
|
||||
|
||||
* **Left Directory Tree View:**
|
||||
* Hierarchical tree structure displaying project directories, system shortcuts, drives, and audio sample library folders.
|
||||
* Supports expand/collapse toggle buttons and highlighted background states indicating the actively selected folder.
|
||||
|
||||
|
||||
* **Right File List Table:**
|
||||
* Displays audio and MIDI files with standard category icons.
|
||||
* Highlights the currently selected file with a prominent blue row background.
|
||||
|
||||
|
||||
|
||||
### 3. Bottom Preview & Transport Panel
|
||||
|
||||
* **Transport Control Bar & Playback Parameters:**
|
||||
* **Transport Buttons:** **■** (Stop), **▶** (Play), **❚❚** (Pause), **↻** (Loop/Repeat), **⚡ Auto-Play** (Automatically previews files upon selection).
|
||||
* **Parameter Controls:** Pitch adjustment (Pitch matching), Rate (Playback speed factor), Volume Slider (Gain adjustment in dB).
|
||||
* **Media Type Badge:** Label displaying file format classification (*MIDI* / *Audio*).
|
||||
|
||||
|
||||
* **Visualizer Canvas Display & Metadata:**
|
||||
* **Visualizer Canvas (Left):** Dark display area rendering an overview Piano Roll note grid (for MIDI files) or waveform display (for Audio files), integrated with a timeline/bar ruler and white Playhead cursor.
|
||||
* **Metadata Text Box (Right):** Information pane displaying detailed file parameters (MIDI event count, duration, sample rate / resolution).
|
||||
|
||||
|
||||
* **Footer Status Bar:**
|
||||
* Displays current playback timestamp vs. total file length.
|
||||
* Name of the currently selected/playing file.
|
||||
* Tempo metadata (BPM) and playback rate scale factor.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## II. EXECUTABLE HTML5 & TAILWIND CSS SOURCE CODE
|
||||
|
||||
Below is the complete HTML5 source code integrating Web Audio API synthesis, a Canvas visualizer, and real-time interactive file selection and audio preview playback:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DAW Media Explorer Panel Component</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; user-select: none; }
|
||||
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* REAPER Classic Panel Style */
|
||||
.reaper-panel {
|
||||
background: #c0c0c0;
|
||||
border: 2px solid #ffffff;
|
||||
border-right-color: #808080;
|
||||
border-bottom-color: #808080;
|
||||
}
|
||||
.reaper-inset {
|
||||
background: #ffffff;
|
||||
border: 1px solid #808080;
|
||||
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
.reaper-dark-inset {
|
||||
background: #181818;
|
||||
border: 1px solid #3a3a3a;
|
||||
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.8);
|
||||
}
|
||||
.file-row-selected {
|
||||
background-color: #3399ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.file-row-selected .file-icon { color: #ffffff !important; }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-track { background: #e0e0e0; border-left: 1px solid #a0a0a0; }
|
||||
::-webkit-scrollbar-thumb { background: #b0b0b0; border: 1px solid #ffffff; border-right-color: #707070; border-bottom-color: #707070; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #909090; }
|
||||
|
||||
/* Volume Slider */
|
||||
input[type=range].fader-slider {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: #111; height: 6px; border-radius: 3px; border: 1px solid #555;
|
||||
}
|
||||
input[type=range].fader-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; height: 16px; width: 12px;
|
||||
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
|
||||
border: 1px solid #000; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,0.5); cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-900 h-screen flex items-center justify-center p-2 overflow-hidden">
|
||||
|
||||
<!-- MEDIA EXPLORER PANEL CONTAINER -->
|
||||
<div class="w-full max-w-4xl h-[420px] flex flex-col reaper-panel text-slate-900 overflow-hidden shadow-2xl relative">
|
||||
|
||||
<!-- 1. TOP NAVIGATION TOOLBAR -->
|
||||
<div class="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
||||
<div class="flex items-center gap-1 flex-1 mr-2">
|
||||
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</button>
|
||||
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward">
|
||||
<i class="fa-solid fa-arrow-right"></i>
|
||||
</button>
|
||||
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 flex items-center reaper-inset h-5 px-1 bg-white">
|
||||
<i class="fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"></i>
|
||||
<input type="text" id="addressBarInput" value="Root:\Media Library" class="w-full text-xs outline-none bg-transparent font-sans text-slate-800" readonly>
|
||||
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center reaper-inset h-5 px-1 bg-white w-40">
|
||||
<input type="text" placeholder="Filter/Search..." class="w-full text-xs outline-none bg-transparent font-sans text-slate-800">
|
||||
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
|
||||
</div>
|
||||
<button class="px-2 py-0.5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold">
|
||||
<span>Details</span> <i class="fa-solid fa-caret-down text-[9px]"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. MIDDLE SPLIT VIEW (DIRECTORY TREE + FILE LIST) -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
<!-- DIRECTORY TREE (LEFT) -->
|
||||
<div class="w-60 reaper-inset m-1 mr-0 overflow-y-auto p-1 text-xs select-none bg-white">
|
||||
<div class="space-y-0.5 font-sans">
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> <Track Templates></div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> <Project Directory></div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Computer</div>
|
||||
|
||||
<!-- Selected Folder -->
|
||||
<div id="folderMidi" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer font-semibold text-slate-900 bg-slate-300 rounded-sm">
|
||||
<i class="fa-solid fa-minus text-[9px] text-slate-600"></i>
|
||||
<i class="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
|
||||
</div>
|
||||
|
||||
<div class="pl-3 space-y-0.5">
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 cursor-pointer text-slate-800">
|
||||
<i class="fa-solid fa-plus text-[9px] text-slate-500"></i> <i class="fa-solid fa-folder text-[#d9a752]"></i> Sound Effects
|
||||
</div>
|
||||
<div id="folderDigitalJuice" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer text-slate-800 pl-4">
|
||||
<i class="fa-solid fa-folder text-[#d9a752]"></i> MIDI Collections
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> Desktop</div>
|
||||
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Documents</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FILE LIST TABLE (RIGHT) -->
|
||||
<div class="flex-1 reaper-inset m-1 overflow-y-auto relative bg-white">
|
||||
<table class="w-full text-xs text-left border-collapse">
|
||||
<thead class="sticky top-0 bg-[#e0e0e0] border-b border-[#a0a0a0] text-slate-800 font-semibold select-none shadow-sm z-10">
|
||||
<tr><th class="py-1 px-2 border-r border-[#b0b0b0]">File</th></tr>
|
||||
</thead>
|
||||
<tbody id="fileListTbody" class="font-sans text-slate-800">
|
||||
<!-- Dynamic File Rows -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 3. BOTTOM PREVIEW CONTROL BAR & VISUALIZER CANVAS -->
|
||||
<div class="h-32 bg-[#d4d0c8] border-t border-[#808080] p-1.5 flex flex-col justify-between text-xs shrink-0 select-none">
|
||||
|
||||
<!-- CONTROLS ROW -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="btnStop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-800"><i class="fa-solid fa-square text-[10px]"></i></button>
|
||||
<button id="btnPlay" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-emerald-700 font-bold"><i class="fa-solid fa-play text-xs"></i></button>
|
||||
<button id="btnPause" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-amber-700"><i class="fa-solid fa-pause text-xs"></i></button>
|
||||
<button id="btnLoop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-700"><i class="fa-solid fa-rotate-right text-xs"></i></button>
|
||||
<button id="btnAutoPlay" class="h-6 px-2 bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border border-slate-700 rounded-sm font-bold text-[10px] flex items-center gap-1 shadow-sm">
|
||||
<i class="fa-solid fa-bolt text-[9px]"></i> <span>Auto-Play</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 font-mono text-[11px]">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Pitch:</span>
|
||||
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-14">
|
||||
<input type="number" value="0.00" step="0.5" class="w-full text-xs text-right outline-none bg-transparent">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>Rate:</span>
|
||||
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-12">
|
||||
<input type="number" value="1.0" step="0.1" class="w-full text-xs text-right outline-none bg-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-sans text-slate-700">Volume:</span>
|
||||
<input id="volSlider" type="range" min="-60" max="12" step="0.5" value="0" class="fader-slider w-28">
|
||||
<div class="reaper-inset px-1 bg-white h-5 flex items-center justify-center w-14 font-mono text-[11px]">
|
||||
<span id="volDbText">0.00 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="badgeType" class="px-2 py-0.5 bg-purple-950 text-purple-300 border border-purple-800 font-mono font-bold text-[10px] rounded-sm">
|
||||
MIDI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANVAS & METADATA ROW -->
|
||||
<div class="flex items-stretch gap-2 my-1 h-16">
|
||||
<div class="flex-1 reaper-dark-inset relative overflow-hidden">
|
||||
<canvas id="previewCanvas" class="w-full h-full block cursor-pointer"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="w-56 reaper-dark-inset p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto">
|
||||
<div id="metadataContent">
|
||||
76 MIDI events<br>
|
||||
Length: 16 quarter notes<br>
|
||||
Length: 0:08.000 (est)<br>
|
||||
Ticks per quarter note: 480
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM STATUS BAR -->
|
||||
<div class="h-5 bg-[#c0c0c0] border-t border-[#ffffff] flex items-center justify-between text-[11px] font-mono px-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<div id="timeRange" class="reaper-inset px-1.5 bg-white text-slate-900 font-bold">0.0000 / 16.0000</div>
|
||||
<div class="reaper-inset px-1.5 bg-white text-slate-900">0.0000</div>
|
||||
<div class="reaper-inset px-1.5 bg-white text-slate-900">16.0000</div>
|
||||
</div>
|
||||
|
||||
<div id="statusFileName" class="text-slate-800 font-bold truncate max-w-xs">File_Selected.mid</div>
|
||||
<div id="statusBpm" class="text-slate-700">130 bpm x0.923</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT LOGIC -->
|
||||
<script>
|
||||
const sampleDb = {
|
||||
midi: [
|
||||
{ name: "MIDI_Loop_01.mid", events: 95, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
|
||||
{ name: "MIDI_Loop_02_Bass.mid", events: 48, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
|
||||
{ name: "MIDI_Loop_03_Lead.mid", events: 110, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
|
||||
{ name: "MIDI_Loop_04.mid", events: 76, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
|
||||
{ name: "MIDI_Loop_05_Bass.mid", events: 52, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
|
||||
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 }
|
||||
]
|
||||
};
|
||||
|
||||
let selectedFile = sampleDb.midi[3];
|
||||
const fileListTbody = document.getElementById('fileListTbody');
|
||||
const previewCanvas = document.getElementById('previewCanvas');
|
||||
const canvasCtx = previewCanvas.getContext('2d');
|
||||
|
||||
function renderFiles() {
|
||||
fileListTbody.innerHTML = '';
|
||||
sampleDb.midi.forEach((f) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `cursor-pointer hover:bg-blue-100 ${f.name === selectedFile.name ? 'file-row-selected' : ''}`;
|
||||
tr.innerHTML = `<td class="py-1 px-2 flex items-center gap-2"><i class="fa-solid fa-music text-purple-600 file-icon"></i><span>${f.name}</span></td>`;
|
||||
tr.onclick = () => {
|
||||
selectedFile = f;
|
||||
renderFiles();
|
||||
updatePreview();
|
||||
};
|
||||
fileListTbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
document.getElementById('statusFileName').innerText = selectedFile.name;
|
||||
document.getElementById('metadataContent').innerHTML = `${selectedFile.events} MIDI events<br>Length: ${selectedFile.lengthQn} quarter notes<br>Length: ${selectedFile.time} (est)<br>Ticks per quarter note: ${selectedFile.tpqn}`;
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
function renderCanvas() {
|
||||
previewCanvas.width = previewCanvas.clientWidth;
|
||||
previewCanvas.height = previewCanvas.clientHeight;
|
||||
const w = previewCanvas.width, h = previewCanvas.height;
|
||||
canvasCtx.clearRect(0, 0, w, h);
|
||||
|
||||
// Grid
|
||||
canvasCtx.strokeStyle = '#222';
|
||||
for (let y = 0; y < h - 14; y += 8) {
|
||||
canvasCtx.beginPath(); canvasCtx.moveTo(0, y); canvasCtx.lineTo(w, y); canvasCtx.stroke();
|
||||
}
|
||||
canvasCtx.strokeStyle = '#333';
|
||||
for (let b = 0; b <= 16; b += 4) {
|
||||
const x = (b / 16) * w;
|
||||
canvasCtx.beginPath(); canvasCtx.moveTo(x, 0); canvasCtx.lineTo(x, h - 14); canvasCtx.stroke();
|
||||
}
|
||||
|
||||
// Notes
|
||||
canvasCtx.fillStyle = '#9ca3af';
|
||||
for (let i = 0; i < selectedFile.events; i++) {
|
||||
const noteX = ((i * 17 + 76) % 95) / 100 * w;
|
||||
const noteY = ((i * 13 + 76) % (h - 24)) + 4;
|
||||
canvasCtx.fillRect(noteX, noteY, Math.max(8, (i % 5 + 1) * 12), 3);
|
||||
}
|
||||
|
||||
// Bar Ruler
|
||||
canvasCtx.fillStyle = '#111';
|
||||
canvasCtx.fillRect(0, h - 14, w, 14);
|
||||
canvasCtx.fillStyle = '#888';
|
||||
canvasCtx.font = '9px JetBrains Mono';
|
||||
for (let b = 0; b <= 12; b += 4) {
|
||||
canvasCtx.fillText(b.toString(), (b / 16) * w + 2, h - 3);
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = () => { renderFiles(); updatePreview(); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
@@ -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():
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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