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
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any, Dict, List
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from app.api.v1.auth import get_current_user
|
||||||
|
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
|||||||
headers: Dict[str, str] = {}
|
headers: Dict[str, str] = {}
|
||||||
body: Dict[str, Any] = {}
|
body: Dict[str, Any] = {}
|
||||||
|
|
||||||
import json
|
|
||||||
|
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||||
|
_BLOCKED_NETWORKS = [
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||||
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
|
]
|
||||||
|
# Private ranges: only reachable when the target host is one the user has
|
||||||
|
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||||
|
_PRIVATE_NETWORKS = [
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"), # ULA
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_ai_hosts(user_id: str) -> set:
|
||||||
|
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||||
|
hosts = set()
|
||||||
|
configs = _load_ai_configs()
|
||||||
|
providers = configs.get(user_id) or _get_default_providers()
|
||||||
|
for p in providers:
|
||||||
|
base = (p.get("api_base_url") or "").strip()
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
host = urlparse(base).hostname
|
||||||
|
if host:
|
||||||
|
hosts.add(host.lower())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_host_ips(hostname: str):
|
||||||
|
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||||
|
ips = []
|
||||||
|
for info in infos:
|
||||||
|
try:
|
||||||
|
ips.append(ipaddress.ip_address(info[4][0]))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return ips
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_target_url(url: str, user_id: str):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||||
|
if parsed.username or parsed.password:
|
||||||
|
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||||
|
hostname = (parsed.hostname or "").lower()
|
||||||
|
if not hostname:
|
||||||
|
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||||
|
|
||||||
|
allowed_hosts = _configured_ai_hosts(user_id)
|
||||||
|
|
||||||
|
# Hostname-level fast path for loopback hosts
|
||||||
|
if hostname in _LOOPBACK_HOSTS:
|
||||||
|
if hostname in allowed_hosts:
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||||
|
|
||||||
|
# Try direct IP parse (hostname may itself be an IP)
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
ips = [ip]
|
||||||
|
except ValueError:
|
||||||
|
ips = await _resolve_host_ips(hostname)
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||||
|
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||||
|
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||||
|
if hostname in allowed_hosts:
|
||||||
|
continue
|
||||||
|
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxy")
|
@router.post("/proxy")
|
||||||
async def proxy_llm(req: ProxyRequest):
|
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||||
|
await _validate_target_url(req.url, current_user["user_id"])
|
||||||
|
# Never forward the app's own auth token upstream.
|
||||||
|
headers = {
|
||||||
|
k: v for k, v in req.headers.items()
|
||||||
|
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||||
resp = await client.post(
|
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||||
req.url,
|
|
||||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
|
||||||
json=req.body
|
|
||||||
)
|
|
||||||
raw = resp.text
|
raw = resp.text
|
||||||
try:
|
try:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
|||||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||||
raise HTTPException(status_code=502, detail=msg)
|
raise HTTPException(status_code=502, detail=msg)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import json
|
import json
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.auth import get_current_user
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
from app.api.v1.projects import get_optional_user
|
from app.api.v1.projects import get_optional_user
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||||
|
|
||||||
|
def _safe_file_id(file_id: str) -> str:
|
||||||
|
"""Strip any path components from a client-supplied file id."""
|
||||||
|
if not file_id:
|
||||||
|
return ""
|
||||||
|
return os.path.basename(file_id.replace("\\", "/"))
|
||||||
|
|
||||||
|
def _resolve_storage_path(file_id: str) -> str:
|
||||||
|
"""Return the existing file path (processed first, then uploads) for a
|
||||||
|
sanitized file id, or '' when not found."""
|
||||||
|
fid = _safe_file_id(file_id)
|
||||||
|
if not fid:
|
||||||
|
return ""
|
||||||
|
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||||
|
p = os.path.join(d, fid)
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
return ""
|
||||||
|
|
||||||
class EditRequest(BaseModel):
|
class EditRequest(BaseModel):
|
||||||
file_id: str
|
file_id: str
|
||||||
cut_start_ms: Optional[float] = None
|
cut_start_ms: Optional[float] = None
|
||||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
ext = os.path.splitext(file.filename)[1]
|
ext = os.path.splitext(file.filename or "")[1]
|
||||||
if not ext:
|
if not ext:
|
||||||
ext = ".wav"
|
ext = ".wav"
|
||||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||||
|
|
||||||
|
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||||
|
# WAV into RAM and bounds disk usage).
|
||||||
with open(file_path, "wb") as f:
|
with open(file_path, "wb") as f:
|
||||||
content = await file.read()
|
size = 0
|
||||||
f.write(content)
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||||
|
f.close()
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
# Save original filename as sidecar metadata
|
# Save original filename as sidecar metadata
|
||||||
import json
|
import json
|
||||||
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
|||||||
|
|
||||||
@router.post("/edit")
|
@router.post("/edit")
|
||||||
async def edit_audio(req: EditRequest):
|
async def edit_audio(req: EditRequest):
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
|
|
||||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
if not _resolve_storage_path(req.file_id):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import edit_audio_task
|
from app.tasks.worker import edit_audio_task
|
||||||
task = edit_audio_task.delay(req.dict())
|
task = edit_audio_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id
|
"task_id": task.id
|
||||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
|||||||
|
|
||||||
@router.get("/download/{file_id}")
|
@router.get("/download/{file_id}")
|
||||||
async def download_audio(file_id: str):
|
async def download_audio(file_id: str):
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
path = _resolve_storage_path(file_id)
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
if not path:
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
if os.path.exists(processed_path):
|
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
|
||||||
|
|
||||||
@router.get("/waveform/{file_id}")
|
@router.get("/waveform/{file_id}")
|
||||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||||
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
|||||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_peak_waveform
|
from app.core.dsp_utils import generate_peak_waveform
|
||||||
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
|||||||
"""
|
"""
|
||||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
file_path = _resolve_storage_path(file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.core.dsp_utils import generate_rms_waveform
|
from app.core.dsp_utils import generate_rms_waveform
|
||||||
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
|||||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
file_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not file_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import analyze_ai_task
|
from app.tasks.worker import analyze_ai_task
|
||||||
task = analyze_ai_task.delay(
|
task = analyze_ai_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
api_base_url=req.api_base_url,
|
api_base_url=req.api_base_url,
|
||||||
model=req.model
|
model=req.model
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/export")
|
@router.post("/export")
|
||||||
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
|
|||||||
"""
|
"""
|
||||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||||
"""
|
"""
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
source_path = _resolve_storage_path(req.file_id)
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
if not source_path:
|
||||||
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
source_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
source_path = upload_path
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
from app.tasks.worker import export_audio_task
|
from app.tasks.worker import export_audio_task
|
||||||
task = export_audio_task.delay(
|
task = export_audio_task.delay(
|
||||||
file_id=req.file_id,
|
file_id=_safe_file_id(req.file_id),
|
||||||
format=req.format,
|
format=req.format,
|
||||||
sample_rate=req.sample_rate,
|
sample_rate=req.sample_rate,
|
||||||
bit_depth=req.bit_depth
|
bit_depth=req.bit_depth
|
||||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
"file_id": req.file_id
|
"file_id": _safe_file_id(req.file_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/ai-scan")
|
@router.post("/ai-scan")
|
||||||
async def ai_scan_audio(req: AIScanRequest):
|
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||||
"""
|
"""
|
||||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||||
"""
|
"""
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
|||||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||||
|
|
||||||
file_path = None
|
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||||
if req.file_id:
|
|
||||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
|
||||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
|
||||||
if os.path.exists(processed_path):
|
|
||||||
file_path = processed_path
|
|
||||||
elif os.path.exists(upload_path):
|
|
||||||
file_path = upload_path
|
|
||||||
|
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path:
|
||||||
data, sr = sf.read(file_path)
|
data, sr = sf.read(file_path)
|
||||||
if data.ndim > 1:
|
if data.ndim > 1:
|
||||||
data = data.T
|
data = data.T
|
||||||
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
|||||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||||
"""
|
"""
|
||||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||||
|
if current_user:
|
||||||
|
enforce_password_changed(current_user)
|
||||||
from app.core.python_tools_engine import PythonToolsEngine
|
from app.core.python_tools_engine import PythonToolsEngine
|
||||||
from app.core.ai_dsp_engine import AIDSPEngine
|
from app.core.ai_dsp_engine import AIDSPEngine
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|||||||
+79
-14
@@ -1,10 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
import threading
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
from app.core.auth import (
|
||||||
|
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||||
|
COOKIE_NAME, X_AUTH_HEADER,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||||
|
_LOGIN_LOCK = threading.Lock()
|
||||||
|
MAX_LOGIN_ATTEMPTS = 10
|
||||||
|
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||||
|
LOGIN_BLOCK_SEC = 900
|
||||||
|
|
||||||
|
def _check_login_ratelimit(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||||
|
_LOGIN_FAILURES[ip] = stamps
|
||||||
|
|
||||||
|
def _record_login_failure(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||||
|
stamps.append(now)
|
||||||
|
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||||
|
|
||||||
|
def _record_login_success(ip: str):
|
||||||
|
with _LOGIN_LOCK:
|
||||||
|
_LOGIN_FAILURES.pop(ip, None)
|
||||||
|
|
||||||
|
def _set_auth_cookie(response: Response, token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
COOKIE_NAME, token,
|
||||||
|
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||||
|
# path="/" (default); secure flag set by proxy when behind TLS
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||||
|
token = None
|
||||||
|
if authorization and authorization.startswith("Bearer "):
|
||||||
|
token = authorization.split(" ")[1]
|
||||||
|
elif x_auth_token:
|
||||||
|
token = x_auth_token
|
||||||
|
elif request.cookies.get(COOKIE_NAME):
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||||
token = authorization.split(" ")[1]
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
async def login(req: LoginRequest):
|
async def login(req: LoginRequest, request: Request):
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_login_ratelimit(client_ip)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
if not user or not user["is_active"]:
|
if not user or not user["is_active"]:
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
if not verify_password(password, user["hashed_password"]):
|
if not verify_password(password, user["hashed_password"]):
|
||||||
|
_record_login_failure(client_ip)
|
||||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||||
|
|
||||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||||
|
_record_login_success(client_ip)
|
||||||
return {
|
|
||||||
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
|||||||
"role": user["role"],
|
"role": user["role"],
|
||||||
"must_change_password": bool(user["must_change_password"])
|
"must_change_password": bool(user["must_change_password"])
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _validate_password_strength(password: str):
|
||||||
|
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||||
|
if len(password) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||||
|
lowered = password.lower()
|
||||||
|
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||||
|
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register(req: RegisterRequest):
|
async def register(req: RegisterRequest, request: Request):
|
||||||
username = req.username.strip()
|
username = req.username.strip()
|
||||||
email = req.email.strip()
|
email = req.email.strip()
|
||||||
password = req.password.strip()
|
password = req.password.strip()
|
||||||
|
_validate_password_strength(password)
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
token = create_token(user_id, username, "standard", False)
|
token = create_token(user_id, username, "standard", False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"access_token": token,
|
"access_token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": user_id,
|
"id": user_id,
|
||||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
|||||||
"role": "standard",
|
"role": "standard",
|
||||||
"must_change_password": False
|
"must_change_password": False
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||||
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||||
return {
|
resp = JSONResponse({
|
||||||
"message": "Đổi mật khẩu thành công!",
|
"message": "Đổi mật khẩu thành công!",
|
||||||
"access_token": new_token
|
"access_token": new_token
|
||||||
}
|
})
|
||||||
|
_set_auth_cookie(resp, new_token)
|
||||||
|
return resp
|
||||||
|
|
||||||
@router.get("/profile")
|
@router.get("/profile")
|
||||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -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
|
# Gửi task xuống Celery Worker
|
||||||
from app.tasks.worker import mix_multitrack_task
|
from app.tasks.worker import mix_multitrack_task
|
||||||
task = mix_multitrack_task.delay(req.dict())
|
task = mix_multitrack_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
|
|||||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||||
"""
|
"""
|
||||||
from app.tasks.worker import process_multitrack_session_task
|
from app.tasks.worker import process_multitrack_session_task
|
||||||
task = process_multitrack_session_task.delay(req.dict())
|
task = process_multitrack_session_task.delay(req.model_dump())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task.id,
|
"task_id": task.id,
|
||||||
|
|||||||
+49
-11
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
|
|||||||
from app.core.soundfont_inspector import SoundFontInspector
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
from app.core.soundfont_converter import SoundFontConverter
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
from app.api.v1.auth import get_current_user
|
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -79,11 +79,23 @@ async def upload_soundfont(
|
|||||||
background_tasks: BackgroundTasks = None,
|
background_tasks: BackgroundTasks = None,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
||||||
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||||
|
|
||||||
contents = await file.read()
|
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||||
if not PluginManager.validate_sf2_header(contents):
|
# 500MB; reading the whole body into RAM would OOM the server).
|
||||||
|
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
||||||
|
contents = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
contents.extend(chunk)
|
||||||
|
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
||||||
|
|
||||||
|
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
||||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||||
|
|
||||||
file_ext = os.path.splitext(file.filename)[1]
|
file_ext = os.path.splitext(file.filename)[1]
|
||||||
@@ -139,15 +151,36 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
|||||||
@router.get("/soundfonts/download/{sf_id}")
|
@router.get("/soundfonts/download/{sf_id}")
|
||||||
async def download_soundfont_asset(sf_id: str):
|
async def download_soundfont_asset(sf_id: str):
|
||||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||||
|
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||||
|
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||||
|
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||||
|
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||||
if not os.path.isdir(base_dir):
|
if not os.path.isdir(base_dir):
|
||||||
continue
|
continue
|
||||||
for ext in [".sf2", ".sf3"]:
|
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||||
for fname in os.listdir(base_dir):
|
# samples, so any SF3 would play silence in the browser.
|
||||||
fbase, fext = os.path.splitext(fname)
|
for fname in os.listdir(base_dir):
|
||||||
if fext.lower() == ext and fbase.lower() == clean_id.lower():
|
fbase, fext = os.path.splitext(fname)
|
||||||
full = os.path.join(base_dir, fname)
|
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
|
||||||
return FileResponse(full, media_type="application/octet-stream", filename=f"soundfont{ext}")
|
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")
|
raise HTTPException(status_code=404, detail="SoundFont asset not found")
|
||||||
|
|
||||||
|
|
||||||
@@ -161,8 +194,13 @@ async def render_project(
|
|||||||
req: RenderRequest,
|
req: RenderRequest,
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
enforce_password_changed(current_user)
|
||||||
engine = PythonRenderEngine()
|
engine = PythonRenderEngine()
|
||||||
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
|
# Prevent path traversal: strip any directory components and force .wav.
|
||||||
|
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
||||||
|
if not safe_name.lower().endswith(".wav"):
|
||||||
|
safe_name += ".wav"
|
||||||
|
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||||
try:
|
try:
|
||||||
result_path = engine.render_project(req.project_json, output_path)
|
result_path = engine.render_project(req.project_json, output_path)
|
||||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||||
|
|||||||
+31
-9
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
return project_data
|
return project_data
|
||||||
|
|
||||||
tracks = project_data.get("tracks", [])
|
tracks = project_data.get("tracks", [])
|
||||||
|
# Legacy format stores item start times in SECONDS; convert using the real
|
||||||
|
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||||
|
# position for any tempo other than the one where 1 bar = 4s).
|
||||||
|
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||||
|
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||||
upgraded_tracks = []
|
upgraded_tracks = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
track_id = str(t.get("id", ""))
|
track_id = str(t.get("id", ""))
|
||||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": c.get("id"),
|
"id": c.get("id"),
|
||||||
"name": c.get("name", "Audio Clip"),
|
"name": c.get("name", "Audio Clip"),
|
||||||
"type": "AUDIO_ITEM",
|
"type": "AUDIO_ITEM",
|
||||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": 4.0,
|
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||||
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
|||||||
"id": m.get("id"),
|
"id": m.get("id"),
|
||||||
"name": m.get("name", "MIDI Item"),
|
"name": m.get("name", "MIDI Item"),
|
||||||
"type": "MIDI_ITEM",
|
"type": "MIDI_ITEM",
|
||||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||||
"duration_bars": m.get("duration", 4.0),
|
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||||
"clip_start_offset_bars": 0.0,
|
"clip_start_offset_bars": 0.0,
|
||||||
"source_data": {
|
"source_data": {
|
||||||
"total_buffer_bars": m.get("duration", 8.0),
|
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||||
"notes": m.get("notes", [])
|
"notes": m.get("notes", [])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -283,13 +288,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
|||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||||
exists = cursor.fetchone()
|
existing = cursor.fetchone()
|
||||||
if not exists:
|
if not existing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||||
|
|
||||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||||
|
|
||||||
|
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||||
|
# update bypassed the quota entirely).
|
||||||
|
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||||
|
quota_row = cursor.fetchone()
|
||||||
|
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||||
|
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||||
|
used_row = cursor.fetchone()
|
||||||
|
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||||
|
max_bytes = storage_limit_mb * 1024 * 1024
|
||||||
|
if used_bytes + new_size_bytes > max_bytes:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||||
|
)
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
|||||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||||
uid = _get_user_id(authorization)
|
uid = _get_user_id(authorization)
|
||||||
configs = _load_ai_configs()
|
configs = _load_ai_configs()
|
||||||
configs[uid] = [p.dict() for p in req.providers]
|
configs[uid] = [p.model_dump() for p in req.providers]
|
||||||
_save_all(ai_configs=configs)
|
_save_all(ai_configs=configs)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
|||||||
from app.models.user import get_db_connection
|
from app.models.user import get_db_connection
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
COOKIE_NAME = "sf_token"
|
||||||
|
X_AUTH_HEADER = "X-Auth-Token"
|
||||||
|
|
||||||
|
def _load_or_create_secret_key() -> str:
|
||||||
|
"""Persistent random SECRET_KEY.
|
||||||
|
|
||||||
|
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
|
||||||
|
first run). Never falls back to a hardcoded value: a known secret lets
|
||||||
|
anyone forge admin tokens.
|
||||||
|
"""
|
||||||
|
env_key = os.getenv("SECRET_KEY", "").strip()
|
||||||
|
if env_key:
|
||||||
|
return env_key
|
||||||
|
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
|
||||||
|
try:
|
||||||
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
|
if os.path.exists(key_file):
|
||||||
|
with open(key_file, "r") as f:
|
||||||
|
key = f.read().strip()
|
||||||
|
if len(key) >= 32:
|
||||||
|
return key
|
||||||
|
key = secrets.token_hex(32)
|
||||||
|
with open(key_file, "w") as f:
|
||||||
|
f.write(key)
|
||||||
|
return key
|
||||||
|
except Exception:
|
||||||
|
# Last resort: ephemeral random key (all tokens invalid on restart).
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
SECRET_KEY = _load_or_create_secret_key()
|
||||||
|
|
||||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+43
-21
@@ -1,4 +1,4 @@
|
|||||||
import os, logging
|
import os, logging, math
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
import scipy.signal as signal
|
import scipy.signal as signal
|
||||||
@@ -87,12 +87,18 @@ class PythonRenderEngine:
|
|||||||
return url_or_id
|
return url_or_id
|
||||||
return url_or_id
|
return url_or_id
|
||||||
|
|
||||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
|
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||||
|
tracks = session.get("tracks", [])
|
||||||
|
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||||
|
|
||||||
_channel_counter = 0
|
_channel_counter = 0
|
||||||
|
|
||||||
for track in session.get("tracks", []):
|
for track in tracks:
|
||||||
|
if solo_ids and track.get("id") not in solo_ids:
|
||||||
|
continue
|
||||||
track_type = track.get("type", "AUDIO")
|
track_type = track.get("type", "AUDIO")
|
||||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||||
|
|
||||||
@@ -123,8 +129,17 @@ class PythonRenderEngine:
|
|||||||
try:
|
try:
|
||||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||||
if sr != self.sample_rate:
|
if sr != self.sample_rate:
|
||||||
# Resampling fallback if simple, otherwise skip
|
# Proper resampling: previously a silent no-op that
|
||||||
pass
|
# played 48kHz audio at the wrong speed/pitch.
|
||||||
|
from scipy.signal import resample_poly
|
||||||
|
g = math.gcd(sr, self.sample_rate)
|
||||||
|
audio_data = resample_poly(
|
||||||
|
audio_data,
|
||||||
|
up=self.sample_rate // g,
|
||||||
|
down=sr // g,
|
||||||
|
axis=-1,
|
||||||
|
)
|
||||||
|
sr = self.sample_rate
|
||||||
|
|
||||||
# Handle channel mapping (Mono/Stereo)
|
# Handle channel mapping (Mono/Stereo)
|
||||||
if len(audio_data.shape) == 1:
|
if len(audio_data.shape) == 1:
|
||||||
@@ -148,7 +163,7 @@ class PythonRenderEngine:
|
|||||||
if actual_len > 0:
|
if actual_len > 0:
|
||||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||||
|
|
||||||
elif item_type == "MIDI_ITEM":
|
elif item_type == "MIDI_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
@@ -284,21 +299,27 @@ class PythonRenderEngine:
|
|||||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||||
|
|
||||||
elif item_type == "SECTION_ITEM":
|
elif item_type == "SECTION_ITEM":
|
||||||
source_data = item.get("source_data", {})
|
source_data = item.get("source_data", {})
|
||||||
sec_id = source_data.get("referenced_section_id", "")
|
sec_id = source_data.get("referenced_section_id", "")
|
||||||
if sec_id and sec_id in section_store:
|
if sec_id and sec_id in section_store:
|
||||||
# Render nested section recursively
|
# Render nested section recursively, cached per section id
|
||||||
sec_container = section_store[sec_id]
|
# so repeated section instances don't re-render every time.
|
||||||
sec_buffer = self.render_session_container(
|
cache = _cache if _cache is not None else {}
|
||||||
session=sec_container,
|
if sec_id in cache:
|
||||||
section_store=section_store,
|
sec_buffer = cache[sec_id]
|
||||||
bpm=bpm,
|
else:
|
||||||
time_sig_num=time_sig_num,
|
sec_buffer = self.render_session_container(
|
||||||
total_samples=total_samples
|
session=section_store[sec_id],
|
||||||
)
|
section_store=section_store,
|
||||||
|
bpm=bpm,
|
||||||
|
time_sig_num=time_sig_num,
|
||||||
|
total_samples=total_samples,
|
||||||
|
_cache=cache,
|
||||||
|
)
|
||||||
|
cache[sec_id] = sec_buffer
|
||||||
|
|
||||||
# Apply non-destructive crop/slicing on section buffer
|
# Apply non-destructive crop/slicing on section buffer
|
||||||
if offset_sample < total_samples:
|
if offset_sample < total_samples:
|
||||||
@@ -327,7 +348,7 @@ class PythonRenderEngine:
|
|||||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||||
try:
|
try:
|
||||||
@@ -341,14 +362,14 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = track_buffer[ch, indices]
|
wet[ch, :] = track_buffer[ch, indices]
|
||||||
track_buffer = dry + wet * 0.5
|
track_buffer = dry + wet * 0.5
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||||
elif fx_type == "reverb":
|
elif fx_type == "reverb":
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
try:
|
try:
|
||||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||||
else:
|
else:
|
||||||
# Fallback reverb using exponentially decaying noise room impulse response
|
# Fallback reverb using exponentially decaying noise room impulse response
|
||||||
try:
|
try:
|
||||||
@@ -368,7 +389,7 @@ class PythonRenderEngine:
|
|||||||
wet[ch, :] = conv
|
wet[ch, :] = conv
|
||||||
track_buffer = dry + wet * 0.4
|
track_buffer = dry + wet * 0.4
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||||
|
|
||||||
# Process track volume
|
# Process track volume
|
||||||
if HAS_PEDALBOARD:
|
if HAS_PEDALBOARD:
|
||||||
@@ -410,7 +431,8 @@ class PythonRenderEngine:
|
|||||||
section_store=section_store,
|
section_store=section_store,
|
||||||
bpm=bpm,
|
bpm=bpm,
|
||||||
time_sig_num=time_sig_num,
|
time_sig_num=time_sig_num,
|
||||||
total_samples=total_samples
|
total_samples=total_samples,
|
||||||
|
_cache={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalization to prevent clipping
|
# Normalization to prevent clipping
|
||||||
|
|||||||
+339
-39
@@ -83,6 +83,35 @@ class SoundFontConverter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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:
|
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
|
||||||
has_ogg = self._check_ffmpeg_ogg()
|
has_ogg = self._check_ffmpeg_ogg()
|
||||||
if not has_ogg:
|
if not has_ogg:
|
||||||
@@ -100,7 +129,7 @@ class SoundFontConverter:
|
|||||||
logger.warning("Not a valid SF2 file")
|
logger.warning("Not a valid SF2 file")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Find smpl chunk recursively
|
# Find smpl chunk (PCM sample data) recursively
|
||||||
smpl = _find_chunk(data, b"smpl")
|
smpl = _find_chunk(data, b"smpl")
|
||||||
if smpl is None:
|
if smpl is None:
|
||||||
logger.warning("No smpl chunk found in SF2")
|
logger.warning("No smpl chunk found in SF2")
|
||||||
@@ -113,46 +142,103 @@ class SoundFontConverter:
|
|||||||
logger.warning("Sample data too small")
|
logger.warning("Sample data too small")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tmp_wav = sf3_path + ".tmp.wav"
|
# Locate shdr (sample headers) inside the pdta LIST
|
||||||
tmp_ogg = sf3_path + ".tmp.ogg"
|
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:
|
try:
|
||||||
# Write samples as WAV
|
for i in range(n_samples):
|
||||||
with open(tmp_wav, "wb") as fw:
|
base = shdr_data_off + i * 46
|
||||||
with wave.open(fw, "wb") as w:
|
# shdr layout: name[20] | start(u32) end(u32) loopstart(u32) loopend(u32) samplerate(i32) ...
|
||||||
w.setnchannels(1)
|
start, end, startloop, endloop, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
|
||||||
w.setsampwidth(2)
|
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
|
||||||
w.setframerate(44100)
|
ogg_stream = b""
|
||||||
w.writeframes(sample_data)
|
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
|
if not ogg_parts:
|
||||||
subprocess.run([
|
logger.warning("No samples to encode")
|
||||||
"ffmpeg", "-y", "-i", tmp_wav,
|
return False
|
||||||
"-c:a", "libvorbis", "-q:a", "3",
|
|
||||||
"-f", "ogg", tmp_ogg
|
|
||||||
], capture_output=True, timeout=600, check=True)
|
|
||||||
|
|
||||||
with open(tmp_ogg, "rb") as fo:
|
ogg_padded = b"".join(ogg_parts)
|
||||||
ogg_data = fo.read()
|
if len(ogg_padded) % 2 == 1:
|
||||||
|
ogg_padded += b"\x00"
|
||||||
compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100
|
new_smpl_size = len(ogg_padded)
|
||||||
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)
|
|
||||||
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
|
||||||
new_padded = len(ogg_padded)
|
delta = len(ogg_padded) - old_padded
|
||||||
delta = new_padded - old_padded
|
|
||||||
|
|
||||||
# Rebuild file: replace smpl chunk and update all sizes
|
# Rebuild file: replace smpl chunk and update all sizes
|
||||||
out = bytearray()
|
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(struct.pack("<I", new_smpl_size)) # new smpl size
|
||||||
out.extend(ogg_padded) # compressed data (even-padded)
|
out.extend(ogg_padded) # concatenated per-sample OGG streams
|
||||||
out.extend(data[smpl_head_off + 8 + old_padded:]) # rest of file
|
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)
|
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
|
# Find sdta LIST and update its size
|
||||||
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
sdta = _find_list_of_type(data_out, b"sdta", 12)
|
||||||
if sdta:
|
if sdta:
|
||||||
@@ -160,12 +246,11 @@ class SoundFontConverter:
|
|||||||
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
|
||||||
|
|
||||||
# Update RIFF root size
|
# Update RIFF root size
|
||||||
old_riff_size = struct.unpack("<I", data_out[4:8])[0]
|
|
||||||
new_total = len(data_out) - 8
|
new_total = len(data_out) - 8
|
||||||
data_out = _update_size(data_out, 4, new_total)
|
data_out = _update_size(data_out, 4, new_total)
|
||||||
|
|
||||||
with open(sf3_path, "wb") as fout:
|
with open(sf3_path, "wb") as fout:
|
||||||
fout.write(bytes(data_out))
|
fout.write(data_out)
|
||||||
|
|
||||||
# Validate: check that RIFF size matches actual size
|
# Validate: check that RIFF size matches actual size
|
||||||
written = os.path.getsize(sf3_path)
|
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}")
|
logger.warning(f"Size mismatch: RIFF says {parsed_riff}, actual is {written - 8}")
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
@@ -185,12 +271,50 @@ class SoundFontConverter:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return False
|
return False
|
||||||
finally:
|
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:
|
try:
|
||||||
if os.path.exists(p):
|
_fs.delete_fluid_synth(_fl)
|
||||||
os.remove(p)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_sf3_converter():
|
def _find_sf3_converter():
|
||||||
@@ -208,9 +332,18 @@ class SoundFontConverter:
|
|||||||
|
|
||||||
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
|
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):
|
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}")
|
if self._sf3_plays_audio(sf3_path):
|
||||||
return 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()
|
converter = self._find_sf3_converter()
|
||||||
try:
|
try:
|
||||||
@@ -228,9 +361,16 @@ class SoundFontConverter:
|
|||||||
|
|
||||||
if converter == "python":
|
if converter == "python":
|
||||||
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
|
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)
|
if self._sf3_plays_audio(sf3_path):
|
||||||
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
|
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
|
||||||
return sf3_path
|
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")
|
logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path")
|
||||||
return sf2_path
|
return sf2_path
|
||||||
|
|
||||||
@@ -242,6 +382,165 @@ class SoundFontConverter:
|
|||||||
logger.error(f"Error converting {sf2_path}: {e}")
|
logger.error(f"Error converting {sf2_path}: {e}")
|
||||||
return sf2_path
|
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):
|
def batch_convert_all(self):
|
||||||
for sdir in self.target_dirs:
|
for sdir in self.target_dirs:
|
||||||
if not os.path.isdir(sdir):
|
if not os.path.isdir(sdir):
|
||||||
@@ -249,3 +548,4 @@ class SoundFontConverter:
|
|||||||
for fname in sorted(os.listdir(sdir)):
|
for fname in sorted(os.listdir(sdir)):
|
||||||
if fname.lower().endswith(".sf2"):
|
if fname.lower().endswith(".sf2"):
|
||||||
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
|
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
|
||||||
|
|
||||||
|
|||||||
+59
-39
@@ -2,7 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import functools
|
import functools
|
||||||
from ctypes import c_int, c_char_p, c_void_p
|
from ctypes import c_char_p
|
||||||
|
|
||||||
def midi_note_to_freq(note_number: int) -> float:
|
def midi_note_to_freq(note_number: int) -> float:
|
||||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||||
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
|
|||||||
return _PLUGIN_MANAGER_INSTANCE
|
return _PLUGIN_MANAGER_INSTANCE
|
||||||
|
|
||||||
def load_soundfont_cached(path: str):
|
def load_soundfont_cached(path: str):
|
||||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||||
|
|
||||||
|
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||||
|
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||||
|
do not exist in this binding, so they are never used here.
|
||||||
|
"""
|
||||||
global _FLUID_CACHE
|
global _FLUID_CACHE
|
||||||
if not HAS_PYFLUIDSYNTH:
|
if not HAS_PYFLUIDSYNTH:
|
||||||
return None
|
return None
|
||||||
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
|
|||||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||||
return fl
|
return fl
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
_settings = _fs.new_fluid_settings()
|
||||||
font_id = fl.sfload(path)
|
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||||
fl.program_select(0, font_id, 0, 0)
|
fl = _fs.new_fluid_synth(_settings)
|
||||||
|
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||||
|
if font_id < 0:
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
|
return None
|
||||||
|
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||||
_FLUID_CACHE[path] = (fl, 1)
|
_FLUID_CACHE[path] = (fl, 1)
|
||||||
return fl
|
return fl
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
|
|||||||
fl, ref = _FLUID_CACHE[path]
|
fl, ref = _FLUID_CACHE[path]
|
||||||
if ref <= 1:
|
if ref <= 1:
|
||||||
try:
|
try:
|
||||||
fl.delete()
|
import fluidsynth as _fs
|
||||||
|
_fs.delete_fluid_synth(fl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
del _FLUID_CACHE[path]
|
del _FLUID_CACHE[path]
|
||||||
@@ -245,38 +256,47 @@ class PluginManager:
|
|||||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||||
path = os.path.join(d, f)
|
path = os.path.join(d, f)
|
||||||
try:
|
try:
|
||||||
import fluidsynth
|
import fluidsynth as _fs
|
||||||
fl = fluidsynth.Synth()
|
# Low-level CFFI API (same as render_engine); never use
|
||||||
fid = fl.sfload(path)
|
# the high-level Synth() class that this binding lacks.
|
||||||
if fid < 0:
|
_settings = _fs.new_fluid_settings()
|
||||||
fl.delete()
|
_synth = _fs.new_fluid_synth(_settings)
|
||||||
continue
|
try:
|
||||||
presets = []
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||||
_fl = fluidsynth._fl
|
if fid < 0:
|
||||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
continue
|
||||||
_fl.fluid_preset_get_name.restype = c_char_p
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
presets = []
|
||||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
if sfont:
|
||||||
if sfont_ptr:
|
for bank in range(0, 2):
|
||||||
for bank in range(0, 2):
|
for prog_num in range(0, 128):
|
||||||
for prog_num in range(0, 128):
|
try:
|
||||||
try:
|
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
except Exception:
|
||||||
except Exception:
|
break
|
||||||
break
|
if preset:
|
||||||
if preset:
|
try:
|
||||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||||
if name_ptr:
|
if name_ptr:
|
||||||
name_val = c_char_p(name_ptr).value
|
if hasattr(_fs, "ffi"):
|
||||||
if name_val:
|
raw = _fs.ffi.string(name_ptr)
|
||||||
presets.append({
|
else:
|
||||||
"bank": bank,
|
raw = c_char_p(name_ptr).value
|
||||||
"program": prog_num,
|
if raw:
|
||||||
"name": name_val.decode("utf-8", errors="replace")
|
presets.append({
|
||||||
})
|
"bank": bank,
|
||||||
fl.delete()
|
"program": prog_num,
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
"name": raw.decode("utf-8", errors="replace")
|
||||||
return presets[:256]
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||||
|
return presets[:256]
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_fs.delete_fluid_synth(_synth)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback; traceback.print_exc()
|
import traceback; traceback.print_exc()
|
||||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
||||||
|
|||||||
+33
-27
@@ -1,8 +1,11 @@
|
|||||||
import os, threading
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.v1.audio import router as audio_router
|
from app.api.v1.audio import router as audio_router
|
||||||
from app.api.v1.tasks import router as tasks_router
|
from app.api.v1.tasks import router as tasks_router
|
||||||
@@ -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_proxy import router as ai_proxy_router
|
||||||
from app.api.v1.ai_presets import router as ai_presets_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.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.auth import seed_admin
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||||
|
|
||||||
# Ensure storage directories exist
|
# Ensure storage directories exist
|
||||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||||
|
|
||||||
app = FastAPI(title="SonicForge API Engine")
|
_SF_SCANNER_STOP = None
|
||||||
|
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup
|
||||||
|
seed_admin()
|
||||||
|
scanner = SoundFontAutoScanner()
|
||||||
|
global _SF_SCANNER_STOP
|
||||||
|
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
if _SF_SCANNER_STOP is not None:
|
||||||
|
_SF_SCANNER_STOP.set()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||||
|
|
||||||
|
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||||
|
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -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_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||||
app.include_router(ai_presets_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(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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def get_index():
|
async def get_index():
|
||||||
@@ -79,14 +81,18 @@ async def get_index():
|
|||||||
if not os.path.exists(index_path):
|
if not os.path.exists(index_path):
|
||||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||||
with open(index_path, "r", encoding="utf-8") as file:
|
with open(index_path, "r", encoding="utf-8") as file:
|
||||||
return HTMLResponse(content=file.read(), status_code=200)
|
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||||
|
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||||
|
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||||
|
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||||
|
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@app.get("/favicon.svg")
|
@app.get("/favicon.svg")
|
||||||
async def get_favicon():
|
async def get_favicon():
|
||||||
import os
|
|
||||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||||
if os.path.exists(favicon_path):
|
if os.path.exists(favicon_path):
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||||
return HTMLResponse(content="", status_code=404)
|
return HTMLResponse(content="", status_code=404)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"id": { "type": "string" },
|
"id": { "type": "string" },
|
||||||
"name": { "type": "string" },
|
"name": { "type": "string" },
|
||||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||||
|
"color": { "type": ["string", "null"], "default": null },
|
||||||
"volume_db": { "type": "number", "default": 0.0 },
|
"volume_db": { "type": "number", "default": 0.0 },
|
||||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||||
"mute": { "type": "boolean", "default": false },
|
"mute": { "type": "boolean", "default": false },
|
||||||
|
|||||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
|||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||||
|
# dev database is never touched by the test suite.
|
||||||
|
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||||
|
# cleanup consistent when users are deleted.
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -89,6 +95,16 @@ def init_db():
|
|||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Placeholder user for anonymous autosave: projects are saved with
|
||||||
|
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||||
|
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
import secrets as _secrets
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||||
|
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||||
|
""", (_secrets.token_hex(32), time.time()))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
+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 = [
|
const REARRANGE_SCENARIOS = [
|
||||||
{
|
{
|
||||||
id: 'arpeggio',
|
id: 'arpeggio',
|
||||||
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
|
|||||||
} else {
|
} else {
|
||||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||||
|
},
|
||||||
body: JSON.stringify({ url, headers, body })
|
body: JSON.stringify({ url, headers, body })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
|||||||
return {
|
return {
|
||||||
DEFAULT_TOOLS,
|
DEFAULT_TOOLS,
|
||||||
REARRANGE_TOOL_SPEC,
|
REARRANGE_TOOL_SPEC,
|
||||||
|
REARRANGE_EXTEND_TOOL_SPEC,
|
||||||
REARRANGE_SCENARIOS,
|
REARRANGE_SCENARIOS,
|
||||||
detectRearrangeScenario,
|
detectRearrangeScenario,
|
||||||
buildRearrangeMessage,
|
buildRearrangeMessage,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
if (!track.midiItems || !track.midiItems.length) continue;
|
if (!track.midiItems || !track.midiItems.length) continue;
|
||||||
if (track.muted) continue;
|
if (track.muted) continue;
|
||||||
|
|
||||||
|
var isSameTrack = track.id === targetTrackId;
|
||||||
var trackGhostNotes = [];
|
var trackGhostNotes = [];
|
||||||
|
|
||||||
for (var j = 0; j < track.midiItems.length; j++) {
|
for (var j = 0; j < track.midiItems.length; j++) {
|
||||||
@@ -32,7 +33,10 @@
|
|||||||
var itemStartBeat = item.startTime / secondsPerBeat;
|
var itemStartBeat = item.startTime / secondsPerBeat;
|
||||||
var itemEndBeat = (item.startTime + item.duration) / 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 || [];
|
var notes = item.notes || [];
|
||||||
for (var k = 0; k < notes.length; k++) {
|
for (var k = 0; k < notes.length; k++) {
|
||||||
@@ -40,7 +44,7 @@
|
|||||||
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
||||||
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
||||||
|
|
||||||
if (noteAbsStart >= windowEndBeat) continue;
|
if (noteAbsStart >= windowEndBeat && !isSameTrack) continue;
|
||||||
|
|
||||||
trackGhostNotes.push({
|
trackGhostNotes.push({
|
||||||
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
relative_start_beat: noteAbsStart - windowStartBeat,
|
relative_start_beat: noteAbsStart - windowStartBeat,
|
||||||
duration_beats: (note.duration_beats || 1),
|
duration_beats: (note.duration_beats || 1),
|
||||||
velocity: note.velocity,
|
velocity: note.velocity,
|
||||||
|
item_id: item.id,
|
||||||
original_track_name: track.name,
|
original_track_name: track.name,
|
||||||
original_track_color: track.color || '#888888'
|
original_track_color: track.color || '#888888'
|
||||||
});
|
});
|
||||||
@@ -59,6 +64,7 @@
|
|||||||
track_id: track.id,
|
track_id: track.id,
|
||||||
track_name: track.name,
|
track_name: track.name,
|
||||||
track_color: track.color || '#6b7280',
|
track_color: track.color || '#6b7280',
|
||||||
|
isSameTrack: isSameTrack,
|
||||||
notes: trackGhostNotes
|
notes: trackGhostNotes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
(function () {
|
(function () {
|
||||||
const RENDER_BLOCK = 512;
|
const RENDER_BLOCK = 512;
|
||||||
const QUEUE_TARGET = 4;
|
const QUEUE_TARGET = 16;
|
||||||
let _audioCtx = null;
|
let _audioCtx = null;
|
||||||
let _fluidModule = null;
|
let _fluidModule = null;
|
||||||
let _synthPtr = null;
|
let _synthPtr = null;
|
||||||
@@ -22,14 +22,21 @@
|
|||||||
let _loadedFonts = {};
|
let _loadedFonts = {};
|
||||||
let _activeOscillators = {};
|
let _activeOscillators = {};
|
||||||
let _gainNode = null;
|
let _gainNode = null;
|
||||||
|
let _pendingOutputDestination = null;
|
||||||
|
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||||
|
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||||
|
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||||
let _scheduledNotes = [];
|
let _scheduledNotes = [];
|
||||||
|
let _loadPromises = {};
|
||||||
|
let _sfloadSeq = 0;
|
||||||
|
|
||||||
const getCtx = function () {
|
const getCtx = function () {
|
||||||
if (_audioCtx) {
|
if (_audioCtx) {
|
||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return _audioCtx;
|
return _audioCtx;
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = ctx.createGain();
|
_gainNode = ctx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -51,7 +59,8 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = window.__sharedAudioCtx.createGain();
|
_gainNode = window.__sharedAudioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
return window.__sharedAudioCtx;
|
return window.__sharedAudioCtx;
|
||||||
};
|
};
|
||||||
@@ -59,6 +68,36 @@
|
|||||||
const SonicSF = {
|
const SonicSF = {
|
||||||
loadedFonts: _loadedFonts,
|
loadedFonts: _loadedFonts,
|
||||||
|
|
||||||
|
// Route the shared FluidSynth output through a per-track node (e.g. the
|
||||||
|
// track's gainNode) so the track's FX chain / fader / pan affect the
|
||||||
|
// soundfont instrument. Pass null to restore the default master-bus route.
|
||||||
|
setOutputDestination: function (node) {
|
||||||
|
try {
|
||||||
|
if (_gainNode) {
|
||||||
|
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||||
|
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||||
|
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||||
|
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||||
|
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||||
|
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||||
|
// ngay sau setOutputDestination lần 2).
|
||||||
|
if (dest === _outputDestination) return;
|
||||||
|
_gainNode.disconnect();
|
||||||
|
_gainNode.connect(dest);
|
||||||
|
_outputDestination = dest;
|
||||||
|
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||||
|
} else {
|
||||||
|
_pendingOutputDestination = node || null;
|
||||||
|
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SonicSF] setOutputDestination error:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getOutputNode: function () {
|
||||||
|
return _gainNode;
|
||||||
|
},
|
||||||
|
|
||||||
init: async function (audioContext) {
|
init: async function (audioContext) {
|
||||||
if (_initialized && _fluidModule) return;
|
if (_initialized && _fluidModule) return;
|
||||||
if (_initPromise) return _initPromise;
|
if (_initPromise) return _initPromise;
|
||||||
@@ -79,19 +118,27 @@
|
|||||||
if (!_gainNode) {
|
if (!_gainNode) {
|
||||||
_gainNode = _audioCtx.createGain();
|
_gainNode = _audioCtx.createGain();
|
||||||
_gainNode.gain.value = 0.3;
|
_gainNode.gain.value = 0.3;
|
||||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||||
|
_gainNode.connect(_outputDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||||
|
|
||||||
|
// ── 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;
|
var _useScriptNode = true;
|
||||||
try {
|
try {
|
||||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
|
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
|
||||||
console.log("[SonicSF] Worklet registered OK");
|
console.log("[SonicSF] Worklet registered OK (unused)");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[SonicSF] Worklet reg failed:", e);
|
console.warn("[SonicSF] Worklet reg failed:", e);
|
||||||
}
|
}
|
||||||
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
|
|
||||||
|
|
||||||
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
||||||
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
||||||
@@ -104,6 +151,11 @@
|
|||||||
},
|
},
|
||||||
TOTAL_MEMORY: TOTAL_MEMORY,
|
TOTAL_MEMORY: TOTAL_MEMORY,
|
||||||
printErr: function (msg) {
|
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);
|
console.warn('[FluidSynth:err]', msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -114,9 +166,9 @@
|
|||||||
|
|
||||||
_settingsPtr = _fluidModule._new_fluid_settings();
|
_settingsPtr = _fluidModule._new_fluid_settings();
|
||||||
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.sample-rate", _audioCtx.sampleRate || 44100);
|
_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_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_setint(_settingsPtr, "synth.ladspa.active", 0);
|
||||||
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
|
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
|
||||||
console.log("[SonicSF] FluidSynth settings configured");
|
console.log("[SonicSF] FluidSynth settings configured");
|
||||||
@@ -132,7 +184,15 @@
|
|||||||
|
|
||||||
if (!_useScriptNode) {
|
if (!_useScriptNode) {
|
||||||
try {
|
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);
|
_workletNode.connect(_gainNode);
|
||||||
console.log("[SonicSF] AudioWorklet node connected via gain");
|
console.log("[SonicSF] AudioWorklet node connected via gain");
|
||||||
_startRenderLoop();
|
_startRenderLoop();
|
||||||
@@ -146,17 +206,31 @@
|
|||||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||||
|
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||||
|
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||||
|
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||||
|
// buffer + re-malloc khi đổi.
|
||||||
|
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
spn.onaudioprocess = function (e) {
|
spn.onaudioprocess = function (e) {
|
||||||
var left = e.outputBuffer.getChannelData(0);
|
var left = e.outputBuffer.getChannelData(0);
|
||||||
var right = e.outputBuffer.getChannelData(1);
|
var right = e.outputBuffer.getChannelData(1);
|
||||||
var sz = left.length;
|
var sz = left.length;
|
||||||
try {
|
try {
|
||||||
|
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||||
|
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||||
|
lp = _fluidModule._malloc(sz * 4);
|
||||||
|
rp = _fluidModule._malloc(sz * 4);
|
||||||
|
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||||
|
}
|
||||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||||
var hf = _fluidModule.HEAPF32;
|
var hf = _fluidModule.HEAPF32;
|
||||||
var lpb = lp >> 2, rpb = rp >> 2;
|
var lpb = lp >> 2, rpb = rp >> 2;
|
||||||
for (var si = 0; si < sz; si++) {
|
for (var si = 0; si < sz; si++) {
|
||||||
left[si] = hf[lpb + si];
|
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||||
right[si] = hf[rpb + si];
|
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||||
|
var L = hf[lpb + si], R = hf[rpb + si];
|
||||||
|
left[si] = isFinite(L) ? L : 0;
|
||||||
|
right[si] = isFinite(R) ? R : 0;
|
||||||
}
|
}
|
||||||
} catch (er) {}
|
} catch (er) {}
|
||||||
};
|
};
|
||||||
@@ -186,11 +260,16 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_tryLoadSFL: function (buf, ext) {
|
_tryLoadSFL: function (buf, ext) {
|
||||||
var fname = '/' + ext + '_' + Date.now();
|
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
|
||||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||||
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
||||||
var cPath = this._allocCStr(fname);
|
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);
|
_fluidModule._free(cPath);
|
||||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||||
return handle;
|
return handle;
|
||||||
@@ -203,19 +282,63 @@
|
|||||||
_currentSfId = sfId;
|
_currentSfId = sfId;
|
||||||
return true;
|
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 {
|
try {
|
||||||
|
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
|
||||||
|
// thành rác trong khi channel state vẫn trỏ tới → program_select
|
||||||
|
// bị skip (progAlreadySet) → noteon trên handle đã unload →
|
||||||
|
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
|
||||||
|
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
|
||||||
|
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
|
||||||
var cache = window.SonicSFStorage;
|
var cache = window.SonicSFStorage;
|
||||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||||
if (!buf) {
|
if (buf) {
|
||||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
var cachedOk = this._tryLoadSFL(buf, '.sf3');
|
||||||
var resp = await fetch(url);
|
if (cachedOk === -1) cachedOk = this._tryLoadSFL(buf, '.sf2');
|
||||||
if (!resp.ok) {
|
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);
|
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf = await resp.arrayBuffer();
|
resp = resp2;
|
||||||
if (cache) await cache.saveBuffer(sfId, buf);
|
|
||||||
}
|
}
|
||||||
|
buf = await resp.arrayBuffer();
|
||||||
|
if (cache) await cache.saveBuffer(sfId, buf);
|
||||||
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
||||||
if (sfHandle === -1) {
|
if (sfHandle === -1) {
|
||||||
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
|
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
|
||||||
@@ -245,16 +368,16 @@
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
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);
|
var allocCh = this.allocateChannel(bank);
|
||||||
_engineChMap[engKey] = allocCh;
|
_engineChMap[engKey] = allocCh;
|
||||||
channel = allocCh;
|
channel = allocCh;
|
||||||
} else {
|
} else {
|
||||||
_engineChMap[engKey] = channel;
|
channel = _engineChMap[engKey];
|
||||||
}
|
}
|
||||||
} else {
|
} else if (!_engineChMap[engKey]) {
|
||||||
channel = _engineChMap[engKey];
|
_engineChMap[engKey] = channel;
|
||||||
}
|
}
|
||||||
var sfHandle = _sfHandleMap.get(sfId);
|
var sfHandle = _sfHandleMap.get(sfId);
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
@@ -371,10 +494,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
var parsedPitch = parseInt(note);
|
||||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
|
||||||
)));
|
if (isNaN(rawVel)) rawVel = 100;
|
||||||
|
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
|
||||||
var _origChannel = channel;
|
var _origChannel = channel;
|
||||||
var usedBank = 0, usedProg = 0;
|
var usedBank = 0, usedProg = 0;
|
||||||
if (synthEngine) {
|
if (synthEngine) {
|
||||||
@@ -407,30 +531,99 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var doNote = function () {
|
var doNote = function () {
|
||||||
try {
|
try {
|
||||||
var finalBank = usedBank;
|
var finalBank = parseInt(usedBank);
|
||||||
var finalProg = usedProg;
|
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||||
|
var finalProg = parseInt(usedProg);
|
||||||
|
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||||
if (_channels[ch] && _channels[ch].program !== undefined) {
|
var cachedCh = _channels[ch];
|
||||||
finalBank = _channels[ch].bank;
|
// The note's own synth engine (track instrument) is
|
||||||
finalProg = _channels[ch].program;
|
// authoritative. Channel state is only a cache: it must never
|
||||||
if (_channels[ch].sfId !== undefined) {
|
// mask the track's instrument, otherwise multi-track ARM or a
|
||||||
finalSfId = _channels[ch].sfId;
|
// 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
|
// Program change at note time, not call time — ensures correct
|
||||||
// instrument for each item regardless of processing order.
|
// 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;
|
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||||
|
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||||
if (sfHandle !== undefined) {
|
if (sfHandle !== undefined) {
|
||||||
try {
|
try {
|
||||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||||
|
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||||
|
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||||
|
// định → font không có → cache channel = (128,0)
|
||||||
|
// INVALID → note sau skip re-select (progAlreadySet)
|
||||||
|
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||||
|
if (finalBank === 128) {
|
||||||
|
var _vKey = finalSfId || ('h' + sfHandle);
|
||||||
|
if (_validPercCache[_vKey] === undefined) {
|
||||||
|
var _found = null;
|
||||||
|
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||||
|
var _bk = _b === 0 ? 128 : 0;
|
||||||
|
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||||
|
try {
|
||||||
|
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||||
|
_found = [_bk, _p];
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_validPercCache[_vKey] = _found;
|
||||||
|
}
|
||||||
|
if (_validPercCache[_vKey]) {
|
||||||
|
finalBank = _validPercCache[_vKey][0];
|
||||||
|
finalProg = _validPercCache[_vKey][1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else {
|
||||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||||
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } 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);
|
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||||
@@ -446,7 +639,7 @@
|
|||||||
if (arr.length === 0) delete _activeNotes[noteMapKey];
|
if (arr.length === 0) delete _activeNotes[noteMapKey];
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}, durSec * 1000);
|
}, durationMs);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("[SonicSF] FluidSynth noteOn error:", 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) {
|
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||||
var ctx = getCtx();
|
var ctx = getCtx();
|
||||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||||
@@ -477,7 +695,11 @@
|
|||||||
var oscType = 'triangle';
|
var oscType = 'triangle';
|
||||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||||
var prog = program !== undefined ? parseInt(program) : 0;
|
var prog = program !== undefined ? parseInt(program) : 0;
|
||||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||||
|
// truyền — trước đây override program của track bằng cache channel
|
||||||
|
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||||
|
// nhạc cụ của track TRƯỚC).
|
||||||
|
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||||
prog = _channels[channel].program || prog;
|
prog = _channels[channel].program || prog;
|
||||||
}
|
}
|
||||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||||
@@ -495,7 +717,8 @@
|
|||||||
osc.frequency.setValueAtTime(freq, 0);
|
osc.frequency.setValueAtTime(freq, 0);
|
||||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||||
var durSec = durationMs / 1000;
|
var durSec = durationMs / 1000;
|
||||||
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||||
|
if (isNaN(vel)) vel = 0.8;
|
||||||
var targetGain = vel * volFactor;
|
var targetGain = vel * volFactor;
|
||||||
noteGain.gain.setValueAtTime(0, startAt);
|
noteGain.gain.setValueAtTime(0, startAt);
|
||||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||||
@@ -516,9 +739,17 @@
|
|||||||
|
|
||||||
stopAll: function () {
|
stopAll: function () {
|
||||||
if (_initialized && _fluidModule) {
|
if (_initialized && _fluidModule) {
|
||||||
|
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
||||||
|
// phòng all_notes_off không có trong WASM exports.
|
||||||
|
Object.keys(_activeNotes).forEach(function (key) {
|
||||||
|
var parts = key.split(':');
|
||||||
|
var pitch = parseInt(parts[1], 10);
|
||||||
|
(_activeNotes[key] || []).forEach(function (ch) {
|
||||||
|
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
for (var ch = 0; ch < 16; ch++) {
|
for (var ch = 0; ch < 16; ch++) {
|
||||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||||
try { _fluidModule._fluid_synth_all_sounds_off(_synthPtr, ch); } catch (e) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
while (_scheduledNotes.length > 0) {
|
while (_scheduledNotes.length > 0) {
|
||||||
@@ -535,6 +766,7 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||||
|
_activeNotes = {};
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||||
@@ -559,10 +791,13 @@
|
|||||||
var leftPtr = _leftBufPtr;
|
var leftPtr = _leftBufPtr;
|
||||||
var rightPtr = _rightBufPtr;
|
var rightPtr = _rightBufPtr;
|
||||||
var block = RENDER_BLOCK;
|
var block = RENDER_BLOCK;
|
||||||
var queueDepth = 0;
|
|
||||||
var maxQueue = QUEUE_TARGET;
|
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() {
|
function pushFrame() {
|
||||||
if (!Module || !synth || !node) return;
|
if (!Module || !synth || !node) return;
|
||||||
try {
|
try {
|
||||||
@@ -573,18 +808,6 @@
|
|||||||
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
|
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
|
||||||
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
|
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
|
||||||
var rightArr = new Float32Array(Module.HEAPF32.subarray(rpb, rpb + 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]);
|
node.port.postMessage({ type: 'PCM', L: leftArr, R: rightArr }, [leftArr.buffer, rightArr.buffer]);
|
||||||
queueDepth++;
|
queueDepth++;
|
||||||
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
|
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
|
||||||
@@ -595,14 +818,16 @@
|
|||||||
_renderTimer = null;
|
_renderTimer = null;
|
||||||
return;
|
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++) {
|
for (var i = 0; i < needed; i++) {
|
||||||
pushFrame();
|
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() {
|
function _stopRenderLoop() {
|
||||||
|
|||||||
@@ -60,11 +60,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
|
let lastGetProjectStateCallback = null;
|
||||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||||
|
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||||
autoSaveTimer = setTimeout(async () => {
|
autoSaveTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const state = getProjectStateCallback();
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
if (!state || (!state.tracks && !state.main_session)) return;
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
const dataJson = JSON.stringify(state);
|
const dataJson = JSON.stringify(state);
|
||||||
localStorage.setItem('sonic_temp_project', dataJson);
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
@@ -76,10 +78,26 @@
|
|||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
|
||||||
|
async function flushTempAutoSave() {
|
||||||
|
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
||||||
|
try {
|
||||||
|
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||||
|
if (!state || (!state.tracks && !state.main_session)) return;
|
||||||
|
const dataJson = JSON.stringify(state);
|
||||||
|
localStorage.setItem('sonic_temp_project', dataJson);
|
||||||
|
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||||
|
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Flush temp project warning:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.SonicStorage = {
|
window.SonicStorage = {
|
||||||
exportProjectToSFS,
|
exportProjectToSFS,
|
||||||
importProjectFromSFSFile,
|
importProjectFromSFSFile,
|
||||||
scheduleTempAutoSave
|
scheduleTempAutoSave,
|
||||||
|
flushTempAutoSave
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -15,29 +15,28 @@ class FluidSynthBridge extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
process(inputs, outputs) {
|
process(inputs, outputs) {
|
||||||
const out = outputs[0];
|
const out = outputs[0];
|
||||||
if (!out) return true;
|
if (!out || out.length === 0) return true;
|
||||||
this.called++;
|
this.called++;
|
||||||
|
const numCh = out.length;
|
||||||
const len = out[0].length;
|
const len = out[0].length;
|
||||||
const qL = this.leftQ;
|
const qL = this.leftQ;
|
||||||
const qR = this.rightQ;
|
const qR = this.rightQ;
|
||||||
let fi = 0;
|
let fi = 0;
|
||||||
let si = 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++) {
|
for (let i = 0; i < len; i++) {
|
||||||
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
|
if (fi >= qL.length) {
|
||||||
out[0][i] = qL[fi][si];
|
for (let c = 0; c < numCh; c++) out[c][i] = 0;
|
||||||
out[1][i] = qR[fi][si];
|
continue;
|
||||||
|
}
|
||||||
|
for (let c = 0; c < numCh; c++) {
|
||||||
|
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
|
||||||
|
}
|
||||||
si++;
|
si++;
|
||||||
if (si >= qL[fi].length) { fi++; si = 0; }
|
if (si >= qL[fi].length) { fi++; si = 0; }
|
||||||
}
|
}
|
||||||
if (fi > 0) { this.leftQ.splice(0, fi); this.rightQ.splice(0, fi); }
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||||
@@ -8,22 +8,23 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></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@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="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/fluidsynthLoader.js?v=202607271245"></script>
|
||||||
<script src="/static/js/services/api.js?v=202607271016"></script>
|
<script src="/static/js/services/api.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202607271016"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
|
<script src="/static/js/services/soundfontPlayer.js?v=202608060630"></script>
|
||||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||||
<script src="/static/js/app.precompiled.js?v=202607302132" defer></script>
|
<script src="/static/js/app.precompiled.js?v=202608061030" defer></script>
|
||||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
@@ -380,6 +381,32 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
|
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>
|
</style>
|
||||||
</head>
|
</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():
|
def get_admin_token():
|
||||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json()["access_token"]
|
token = resp.json()["access_token"]
|
||||||
|
# Admin is seeded with must_change_password=1; the app blocks music
|
||||||
|
# processing until the first password change. Complete that flow here
|
||||||
|
# (keeping the same password) so feature tests run unblocked.
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": "admin123", "new_password": "admin123"})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regression tests for legacy project upgrade (items must stay on their tracks
|
||||||
|
with correct bar positions)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.api.v1.projects import upgrade_project_json_if_needed
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_project():
|
||||||
|
return {
|
||||||
|
"id": "legacy_1",
|
||||||
|
"name": "Legacy",
|
||||||
|
"bpm": 120.0, # 1 bar = 2.0s
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 1",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": "abc.wav",
|
||||||
|
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
|
||||||
|
"midiItems": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 2",
|
||||||
|
"volumeDb": 0.0,
|
||||||
|
"pan": 0.0,
|
||||||
|
"muted": False,
|
||||||
|
"solo": False,
|
||||||
|
"serverFileId": None,
|
||||||
|
"clips": [],
|
||||||
|
"midiItems": [
|
||||||
|
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
|
||||||
|
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_keeps_items_on_their_tracks():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
assert len(tracks) == 2
|
||||||
|
t1_items = tracks[0]["items"]
|
||||||
|
t2_items = tracks[1]["items"]
|
||||||
|
# Items must NOT be merged into the first track
|
||||||
|
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
|
||||||
|
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
|
||||||
|
assert t1_items[0]["id"] == "c1"
|
||||||
|
assert t2_items[0]["id"] == "m1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_uses_bpm_based_seconds_per_bar():
|
||||||
|
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||||
|
tracks = upgraded["main_session"]["tracks"]
|
||||||
|
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
|
||||||
|
assert tracks[0]["items"][0]["start_bar"] == 1.0
|
||||||
|
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
|
||||||
|
assert tracks[1]["items"][0]["start_bar"] == 2.0
|
||||||
|
assert tracks[1]["items"][0]["duration_bars"] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_skips_new_format():
|
||||||
|
data = {"main_session": {"tracks": []}}
|
||||||
|
assert upgrade_project_json_if_needed(data) is data
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Regression tests for security hardening.
|
||||||
|
|
||||||
|
Covers the vulnerabilities found during the 2026-08 audit:
|
||||||
|
- SSRF / open proxy on /api/v1/ai/proxy
|
||||||
|
- path traversal on render output and audio file ids
|
||||||
|
- unauthenticated filesystem access via /api/v1/media/*
|
||||||
|
- hardcoded SECRET_KEY
|
||||||
|
- quota bypass on project update
|
||||||
|
- audio resampling correctness in the render engine
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import soundfile as sf
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.config import settings
|
||||||
|
from app.core import auth as core_auth
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_token():
|
||||||
|
# test_auth_and_quota.py may have rotated the admin password; try both.
|
||||||
|
for pwd in ("admin123", "admin_new_password_2026"):
|
||||||
|
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
user = resp.json()["user"]
|
||||||
|
if user.get("must_change_password"):
|
||||||
|
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"old_password": pwd, "new_password": pwd})
|
||||||
|
if r.status_code == 200:
|
||||||
|
token = r.json()["access_token"]
|
||||||
|
return token
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def auth_headers():
|
||||||
|
return {"Authorization": f"Bearer {get_admin_token()}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. SSRF / open proxy ──
|
||||||
|
|
||||||
|
class TestAIProxySSRF:
|
||||||
|
def test_proxy_requires_auth(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", json={"url": "https://api.openai.com/v1", "body": {}})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_proxy_blocks_cloud_metadata(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://169.254.169.254/latest/meta-data/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_blocks_private_ip_not_configured(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://10.0.0.5/", "body": {}})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_proxy_rejects_non_http_scheme(self):
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "file:///etc/passwd", "body": {}})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_proxy_allows_configured_localhost_provider(self):
|
||||||
|
# localhost:11434 is in the default AI provider list; it must pass the
|
||||||
|
# SSRF check (and then fail to connect in this environment -> 502).
|
||||||
|
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||||
|
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Path traversal ──
|
||||||
|
|
||||||
|
class TestPathTraversal:
|
||||||
|
def test_render_output_filename_sanitized(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
project = {
|
||||||
|
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||||
|
"main_session": {"length_bars": 1, "tracks": []},
|
||||||
|
"section_store": {},
|
||||||
|
}
|
||||||
|
resp = client.post("/api/v1/plugins/render", headers=auth_headers(),
|
||||||
|
json={"project_json": project, "output_filename": "/tmp/evil_traversal.wav"})
|
||||||
|
# Absolute paths must be reduced to a basename inside PROCESSED_DIR.
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
out_path = resp.json()["path"]
|
||||||
|
assert os.path.dirname(out_path) == settings.PROCESSED_DIR
|
||||||
|
assert os.path.basename(out_path) == "evil_traversal.wav"
|
||||||
|
assert os.path.isfile(out_path)
|
||||||
|
|
||||||
|
def test_audio_download_rejects_traversal(self):
|
||||||
|
resp = client.get("/api/v1/audio/download/..%2F..%2Fapp%2Fconfig.py")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_ai_scan_rejects_traversal_file_id(self):
|
||||||
|
resp = client.post("/api/v1/audio/ai-scan",
|
||||||
|
json={"track_id": "1", "file_id": "../../app/config.py"})
|
||||||
|
# Traversal must NOT read the file: falls through to the demo branch.
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Filesystem exposure via media endpoints ──
|
||||||
|
|
||||||
|
class TestMediaAuth:
|
||||||
|
# Use a fresh client (no cookies from earlier logins) to prove 401.
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _fresh_client(self):
|
||||||
|
self.fresh = TestClient(app)
|
||||||
|
yield
|
||||||
|
self.fresh.close()
|
||||||
|
|
||||||
|
def test_media_computer_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/computer")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_browse_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/browse", params={"path": "/etc"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_media_file_requires_auth(self):
|
||||||
|
resp = self.fresh.get("/api/v1/media/file", params={"path": "/etc/passwd"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Secret key ──
|
||||||
|
|
||||||
|
class TestSecretKey:
|
||||||
|
def test_secret_key_not_hardcoded_default(self):
|
||||||
|
assert core_auth.SECRET_KEY != "sonicforge_secret_key_super_secure_2026"
|
||||||
|
assert len(core_auth.SECRET_KEY) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Quota enforcement on update ──
|
||||||
|
|
||||||
|
class TestQuotaUpdate:
|
||||||
|
def test_update_cloud_project_enforces_quota(self):
|
||||||
|
token = get_admin_token()
|
||||||
|
if not token:
|
||||||
|
pytest.skip("Cannot get admin token")
|
||||||
|
# Register a fresh user with a small quota (unique name per run so the
|
||||||
|
# test is re-runnable against a persistent DB).
|
||||||
|
import uuid as _uuid
|
||||||
|
uname = f"quota_user_{_uuid.uuid4().hex[:8]}"
|
||||||
|
resp = client.post("/api/v1/auth/register", json={
|
||||||
|
"username": uname, "email": f"{uname}@studio.com", "password": "quota_pass_123"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
user_token = resp.json()["access_token"]
|
||||||
|
user_headers = {"Authorization": f"Bearer {user_token}"}
|
||||||
|
|
||||||
|
# Shrink quota to 1 MB via admin API.
|
||||||
|
uid = resp.json()["user"]["id"]
|
||||||
|
r = client.put(f"/api/v1/admin/quotas/{uid}", headers=auth_headers(),
|
||||||
|
json={"storage_limit_mb": 1, "max_tracks": 16})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
# Save a small project.
|
||||||
|
small = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "small", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True, "tracks": []},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.post("/api/v1/projects/cloud", headers=user_headers,
|
||||||
|
json={"name": "small", "data_json": small})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
pid = r.json()["project_id"]
|
||||||
|
|
||||||
|
# Updating with a payload over the quota must be rejected (was a bypass).
|
||||||
|
items = [{
|
||||||
|
"type": "AUDIO_ITEM", "id": f"it_{i}", "name": "n",
|
||||||
|
"start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "", "gain": 1.0},
|
||||||
|
} for i in range(20000)]
|
||||||
|
huge = json.dumps({
|
||||||
|
"project_id": "p1",
|
||||||
|
"metadata": {"title": "huge", "bpm": 120, "time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||||
|
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||||
|
"length_bars": 16.0, "auto_compute_length": True,
|
||||||
|
"tracks": [{"id": "t", "name": "x", "type": "AUDIO", "items": items}]},
|
||||||
|
"section_store": {}})
|
||||||
|
r = client.put(f"/api/v1/projects/cloud/{pid}", headers=user_headers,
|
||||||
|
json={"name": "huge", "data_json": huge})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "Quota" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 6. Render engine: resampling correctness ──
|
||||||
|
|
||||||
|
class TestRenderResample:
|
||||||
|
def test_audio_item_resampled_to_engine_rate(self, tmp_path):
|
||||||
|
from app.core.render_engine import PythonRenderEngine
|
||||||
|
# 44.1kHz source, engine at 22.05kHz -> exactly 2x downsampling.
|
||||||
|
sr_src = 44100
|
||||||
|
t = np.arange(sr_src) / sr_src
|
||||||
|
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||||
|
src_path = os.path.join(settings.UPLOADS_DIR, "resample_test_tone.wav")
|
||||||
|
sf.write(src_path, tone, sr_src)
|
||||||
|
|
||||||
|
engine = PythonRenderEngine(sample_rate=22050)
|
||||||
|
session = {
|
||||||
|
"tracks": [{
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0.0, "pan": 0.0, "mute": False,
|
||||||
|
"items": [{
|
||||||
|
"type": "AUDIO_ITEM",
|
||||||
|
"start_bar": 0.0, "duration_bars": 4.0,
|
||||||
|
"clip_start_offset_bars": 0.0,
|
||||||
|
"source_data": {"audio_file_url": "/static/audio/uploads/resample_test_tone.wav", "gain": 1.0},
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
buf = engine.render_session_container(session, {}, bpm=120.0, time_sig_num=4,
|
||||||
|
total_samples=engine.sample_rate * 2)
|
||||||
|
# A 1s 440Hz tone must actually render energy (previously the SR
|
||||||
|
# mismatch silently skipped the audio).
|
||||||
|
assert np.max(np.abs(buf)) > 0.01
|
||||||
|
# Duration should be ~1 second at the engine rate, not 2.
|
||||||
|
nonzero = np.where(np.abs(buf[0]) > 1e-4)[0]
|
||||||
|
assert len(nonzero) > 0
|
||||||
|
assert (nonzero[-1] - nonzero[0]) < int(engine.sample_rate * 1.3)
|
||||||
|
try:
|
||||||
|
os.remove(src_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,993 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "bca1bb5f-b656-48a8-b113-8fff900188ab",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784710097790",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784710143.4476569
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "d627f532-b632-4ef0-a21f-80dcc2ac8396",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719724178",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719770.23144
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "a17fe50b-c51d-408d-b45c-c09645777e68",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784719775936",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784719821.9856758
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "38a0f344-05dc-47e3-a769-07426c57f5fe",
|
||||||
|
"name": "Test",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720540893",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Test",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Cartoon Capers Loop.mp3",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720586.9746954
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"name": "Rose",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "project_1784720555735",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose",
|
||||||
|
"bpm": 120.0,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16.0,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"fx_chain": [],
|
||||||
|
"synth_engine": {
|
||||||
|
"plugin_id": "synth",
|
||||||
|
"preset_id": "default",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1784720601.8155787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "525bdab5-e594-4a0c-a3e6-102a0abef816",
|
||||||
|
"name": "Rose (autosave 03/08)",
|
||||||
|
"data_json": {
|
||||||
|
"project_id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||||
|
"metadata": {
|
||||||
|
"title": "Rose (autosave 03/08)",
|
||||||
|
"bpm": 128,
|
||||||
|
"time_signature_numerator": 4,
|
||||||
|
"time_signature_denominator": 4,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"main_session": {
|
||||||
|
"id": "main",
|
||||||
|
"name": "MAIN SESSION",
|
||||||
|
"is_root": true,
|
||||||
|
"length_bars": 16,
|
||||||
|
"auto_compute_length": true,
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Track 01",
|
||||||
|
"type": "MIDI",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": "sf_DSK_Asian_DreamZ",
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057918746",
|
||||||
|
"name": "MIDI Item",
|
||||||
|
"type": "MIDI_ITEM",
|
||||||
|
"start_bar": 0,
|
||||||
|
"duration_bars": 4,
|
||||||
|
"clip_start_offset_bars": 0,
|
||||||
|
"source_data": {
|
||||||
|
"total_buffer_bars": 4,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"name": "Track 02",
|
||||||
|
"type": "AUDIO",
|
||||||
|
"volume_db": 0,
|
||||||
|
"pan": 0,
|
||||||
|
"mute": false,
|
||||||
|
"solo": false,
|
||||||
|
"instrument_id": null,
|
||||||
|
"instrument_program": null,
|
||||||
|
"instrument_name": null,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sub_tabs": [
|
||||||
|
{
|
||||||
|
"id": "midi_1785057919362",
|
||||||
|
"label": "Piano Roll: MIDI Item",
|
||||||
|
"type": "PIANO_ROLL",
|
||||||
|
"track_id": "1",
|
||||||
|
"target_id": "midi_1785057918746",
|
||||||
|
"parent_tab_id": null,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "note_1785057946413j2a54",
|
||||||
|
"pitch": 55,
|
||||||
|
"start_beat": 1.5,
|
||||||
|
"duration_beats": 0.125,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946488uv1f6r6q_2",
|
||||||
|
"pitch": 56,
|
||||||
|
"start_beat": 1.5166666666666666,
|
||||||
|
"duration_beats": 0.2333333333333334,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946505gxl5rre2_3",
|
||||||
|
"pitch": 57,
|
||||||
|
"start_beat": 1.75,
|
||||||
|
"duration_beats": 0.3833333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946522v0dmipvj_4",
|
||||||
|
"pitch": 60,
|
||||||
|
"start_beat": 2.1333333333333333,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579465381vqpeamx_5",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 2.4,
|
||||||
|
"duration_beats": 0.21666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946555rmpgksfp_6",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 2.6166666666666667,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946572cvrxri09_7",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 2.816666666666667,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946588nvmp7zld_8",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 3.066666666666667,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946605jkbnh8mf_9",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 3.216666666666667,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946638i28svktt_10",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 3.4833333333333334,
|
||||||
|
"duration_beats": 0.1499999999999999,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579466559iy45ml8_11",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 3.6333333333333333,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946672s712j5m6_12",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 3.7666666666666666,
|
||||||
|
"duration_beats": 0.1333333333333333,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946688pq858zcx_13",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 3.9,
|
||||||
|
"duration_beats": 0.18333333333333313,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579467054usc79tx_14",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 4.083333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946722dspmlfaa_15",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 4.233333333333333,
|
||||||
|
"duration_beats": 0.2999999999999998,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946755h5mvip3h_16",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 4.533333333333333,
|
||||||
|
"duration_beats": 0.15000000000000036,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946772a5e5iona_17",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 4.683333333333334,
|
||||||
|
"duration_beats": 0.16666666666666607,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946788cfqxk2c8_18",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 4.85,
|
||||||
|
"duration_beats": 0.2666666666666666,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_178505794682247xy477z_19",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 5.116666666666666,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946855nygukqis_20",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.366666666666666,
|
||||||
|
"duration_beats": 0.20000000000000018,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057946888oyfvmvq2_21",
|
||||||
|
"pitch": 79,
|
||||||
|
"start_beat": 5.566666666666666,
|
||||||
|
"duration_beats": 0.35000000000000053,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947455xrq7rfmm_22",
|
||||||
|
"pitch": 78,
|
||||||
|
"start_beat": 5.916666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579474722sn8bsed_23",
|
||||||
|
"pitch": 77,
|
||||||
|
"start_beat": 6.2,
|
||||||
|
"duration_beats": 0.31666666666666643,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947489mbv5z7q6_24",
|
||||||
|
"pitch": 76,
|
||||||
|
"start_beat": 6.516666666666667,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947505qmasllso_25",
|
||||||
|
"pitch": 75,
|
||||||
|
"start_beat": 6.8,
|
||||||
|
"duration_beats": 0.41666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947539wkqyr6fw_26",
|
||||||
|
"pitch": 74,
|
||||||
|
"start_beat": 7.216666666666667,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947572a2ydjwzu_27",
|
||||||
|
"pitch": 73,
|
||||||
|
"start_beat": 7.4,
|
||||||
|
"duration_beats": 0.1999999999999993,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947589b18pcser_28",
|
||||||
|
"pitch": 72,
|
||||||
|
"start_beat": 7.6,
|
||||||
|
"duration_beats": 0.16666666666666696,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947605bsiv751r_29",
|
||||||
|
"pitch": 71,
|
||||||
|
"start_beat": 7.766666666666667,
|
||||||
|
"duration_beats": 0.13333333333333375,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947622ii453i6x_30",
|
||||||
|
"pitch": 70,
|
||||||
|
"start_beat": 7.9,
|
||||||
|
"duration_beats": 0.18333333333333357,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947639zdv6seea_31",
|
||||||
|
"pitch": 69,
|
||||||
|
"start_beat": 8.083333333333334,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947672devle9p7_32",
|
||||||
|
"pitch": 68,
|
||||||
|
"start_beat": 8.45,
|
||||||
|
"duration_beats": 0.25,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947689x8rgn7ih_33",
|
||||||
|
"pitch": 67,
|
||||||
|
"start_beat": 8.7,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947722u5b36hrl_34",
|
||||||
|
"pitch": 66,
|
||||||
|
"start_beat": 9.033333333333333,
|
||||||
|
"duration_beats": 0.2833333333333332,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947772f9qp4st0_35",
|
||||||
|
"pitch": 65,
|
||||||
|
"start_beat": 9.316666666666666,
|
||||||
|
"duration_beats": 0.38333333333333286,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947805cf5bjiv1_36",
|
||||||
|
"pitch": 64,
|
||||||
|
"start_beat": 9.7,
|
||||||
|
"duration_beats": 0.6666666666666679,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947855ojfcoccj_37",
|
||||||
|
"pitch": 63,
|
||||||
|
"start_beat": 10.366666666666667,
|
||||||
|
"duration_beats": 0.36666666666666536,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_17850579478721jvhzolj_38",
|
||||||
|
"pitch": 62,
|
||||||
|
"start_beat": 10.733333333333333,
|
||||||
|
"duration_beats": 0.3000000000000007,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "note_1785057947889nqk9ckue_39",
|
||||||
|
"pitch": 61,
|
||||||
|
"start_beat": 11.033333333333333,
|
||||||
|
"duration_beats": 0.3333333333333339,
|
||||||
|
"velocity": 0.8,
|
||||||
|
"pan": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"duration": 7.5,
|
||||||
|
"instrument_program": 5,
|
||||||
|
"instrument_name": "BAN-DI",
|
||||||
|
"current_time": 0,
|
||||||
|
"color": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"section_store": {}
|
||||||
|
},
|
||||||
|
"updated_at": 1785058087.45
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Khôi phục các dự án cloud cũ (từ git history) vào bất kỳ sonicforge.db nào.
|
||||||
|
|
||||||
|
Cách dùng trên máy deployment thật (host game):
|
||||||
|
python3 tools/restore_cloud_projects.py /path/to/sonicforge.db
|
||||||
|
|
||||||
|
Script đọc backup projects (JSON) đã xuất từ git history và chèn vào DB chỉ định,
|
||||||
|
gán tất cả cho user_id được yêu cầu (mặc định: admin đầu tiên tìm thấy).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PROJECTS_BACKUP = os.path.join(os.path.dirname(__file__), "cloud_projects_backup.json")
|
||||||
|
|
||||||
|
|
||||||
|
def main(db_path: str, user_id: str = None):
|
||||||
|
if not os.path.exists(PROJECTS_BACKUP):
|
||||||
|
print(f"Không tìm thấy {PROJECTS_BACKUP}")
|
||||||
|
return 1
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Không tìm thấy DB: {db_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(PROJECTS_BACKUP) as f:
|
||||||
|
projects = json.load(f)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
row = conn.execute("SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1").fetchone()
|
||||||
|
if not row:
|
||||||
|
print("Không có user admin nào trong DB")
|
||||||
|
conn.close()
|
||||||
|
return 1
|
||||||
|
user_id = row["id"]
|
||||||
|
print(f"Gán tất cả cho admin: {user_id}")
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
for p in projects:
|
||||||
|
if conn.execute("SELECT id FROM projects WHERE id=?", (p["id"],)).fetchone():
|
||||||
|
print(f" bỏ qua (đã tồn tại): {p['name']}")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||||
|
(p["id"], user_id, p["name"], json.dumps(p["data_json"], ensure_ascii=False),
|
||||||
|
len(json.dumps(p["data_json"]).encode("utf-8")), p["updated_at"]))
|
||||||
|
restored += 1
|
||||||
|
print(f" đã khôi phục: {p['name']}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"\nHoàn tất: {restored} dự án đã khôi phục cho user {user_id}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 restore_cloud_projects.py <path/to/sonicforge.db> [user_id]")
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))
|
||||||
Reference in New Issue
Block a user