48 Commits

Author SHA1 Message Date
3dtours 8e3c652551 IMPROVE: PIANO ROLL TAB khi play thì playhead luôn nằm chính giữa piano roll 2026-08-07 22:10:37 +07:00
3dtours 393df8fd7b FIX: cho phép shift+click để chọn notes, drag để di chuyển các notes, ctrl+drag để copy nhanh các notes 2026-08-07 21:48:14 +07:00
3dtours 688a7a57e2 FIX: cho phép copy/cut các notes được chọn trong PIANO ROLL TAB 2026-08-07 21:39:18 +07:00
3dtours 652e745d0a IMPROVE: thay đổi giao diện của PIANO ROLL TAB 2026-08-07 21:22:13 +07:00
3dtours 45ab31d42e IMPROVE: thêm các tính năng cho MIDI trong PIANO ROLL TAB 2026-08-07 20:46:47 +07:00
3dtours c3182f6baa FIX: sửa lỗi không hiển thị đúng nội dung của SECTION-TAB trong section item 2026-08-07 18:58:29 +07:00
3dtours 1d9b426d15 FIX: sửa lỗi copy/cut paste không đúng item 2026-08-07 17:06:05 +07:00
3dtours 4f34491f3e FIX: sửa lỗi hiển thị animation của VU meter khi play MIDI 2026-08-07 15:57:49 +07:00
3dtours 9e269d3b79 FIX: sửa lỗi hiển thị animation của VU meter 2026-08-07 15:40:15 +07:00
3dtours 58c6ec794d FIX: sửa lỗi hiển thị số bar bị sai 2026-08-07 15:07:06 +07:00
3dtours 173a1f227b FIX: sửa lỗi lưu thay đổi trong SECTION-TAB thì cũng tạo duration trên MAIN SESSION 2026-08-07 11:08:39 +07:00
3dtours c1aa1eb4d4 FIX: sửa lỗi phát hiện đã không còn play midi note để PIANO ROLL TAB quay về vị trí 0 2026-08-07 10:41:45 +07:00
3dtours 6c16bf8410 FIX: sửa lỗi đồng bộ playhead vị trí play giữa MAIN SESSION/SECTION-TAB/PIANO ROLL TAB 2026-08-07 10:37:34 +07:00
3dtours 4981785913 FIX: sửa lỗi playhead đồng bộ giữa các tab, nhấn space có thể bind với phím play 2026-08-07 10:27:21 +07:00
3dtours bca661bffd FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION/SECTION-TAB không đồng bộ playhead 2026-08-07 10:12:18 +07:00
3dtours 4c820bfa77 FIX: sửa lỗi chuyển từ tab PIANO ROLL TAB sang MAIN SESSION và SECTION-TAB thì bị câm 2026-08-07 09:35:40 +07:00
3dtours 8cdf2942b9 FIX: sửa lỗi chuyển từ tab MAIN SESSION sang SECTION-TAB và PIANO ROLL TAB thì bị bypass 2026-08-07 09:13:03 +07:00
3dtours 23675dab83 FIX: Đã fix preview Media Explorer (v202608062345) 2026-08-06 20:45:56 +07:00
3dtours 71b9231b57 FIX: Đã fix block lồng + mất MIDI (v202608062330) 2026-08-06 20:42:27 +07:00
3dtours 93dc13b17a FIX: lỗi bypass của SECTION-TAB 2026-08-06 18:19:04 +07:00
3dtours f1e914fa62 FIX: lỗi: CC7 (channel volume) chỉ được set 100/0 (mute/unmute) — volume track KHÔNG áp qua channel → track MIDI dùng chung channel: CC7 của track set cuối thắng → volume của track kia vô tác dụng 2026-08-06 14:10:57 +07:00
3dtours 4dfab954d8 FIX: reset tất cả về mặc định khi tạo new project 2026-08-06 11:47:10 +07:00
3dtours e99e54773f FIX: Đã sắp xếp lại (v202608061030) 2026-08-06 08:29:23 +07:00
3dtours 0ba31c57bf FIX: Đã đổi tên (v202608060900) 2026-08-05 22:23:31 +07:00
3dtours 454dd91f96 FIX: Đã fix cross-machine (v202608060700) 2026-08-05 22:02:43 +07:00
3dtours e9f29e09ca FIX: Đã fix cross-machine (v202608060500) 2026-08-05 17:40:35 +07:00
3dtours 289746f187 FIX: Đã fix (v202608060430) — tab deactive → stop âm tab đó 2026-08-05 17:29:52 +07:00
3dtours b3ced7a7b3 FIX: Clone AI Var kế thừa midiChannel của track gốc → 2 track DÙNG CHUNG channel 2026-08-05 15:49:44 +07:00
3dtours 4233c1eeda FIX: Piano roll track 4 phát âm thanh instrument track 3 2026-08-05 15:17:13 +07:00
3dtours 35f7c3822f FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 15:05:44 +07:00
3dtours d37f4e7557 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery 2026-08-05 12:30:42 +07:00
3dtours 8a9d6b3c27 FIX: Piano roll tab không xuất âm thanh qua mastering chain được recovery nhưng âm thanh rất nhỏ 2026-08-05 12:16:00 +07:00
3dtours 55d3464b1e FIX: Piano roll tab không xuất âm thanh qua mastering chain 2026-08-05 12:10:50 +07:00
3dtours cfc114b9d7 FIX: khi ở MAIN SESSION đang play mà mở Piano roll tab thì âm thanh bị tắt 2026-08-04 22:12:50 +07:00
3dtours 856a8183b6 FIX: lỗi mở MIDI item ở trong PIANO ROLL TAB là bị lỗi âm thanh của main session 2026-08-04 22:07:48 +07:00
3dtours 0272912cff FIX: khi click vào track number thì bị mất âm thanh, cho phép lưu màu của track 2026-08-04 16:43:44 +07:00
3dtours c047934fc4 IMPROVE: cài đặt thêm tool để có thể phối lại cho đoạn midi item hoặc viết thêm cho nó 2026-08-04 15:23:11 +07:00
3dtours fc663921ff IMPROVE: khi zoom in thì giữ nguyên kích thước sau khi reload page, drag Audioclip item đi vị trí khác thì playhead vẫn play đúng vị trí âm thanh 2026-08-04 12:16:53 +07:00
3dtours fe5e8cb58e IMPROVE: gỡ bỏ các panel không cần thiết và tinh chỉnh phần export, thêm EQ Pro cho Mastering và FX Rack 2026-08-04 09:59:58 +07:00
3dtours 01fcf51fea FIX: thêm nút bypass cho midi items và audio items, Section item 2026-08-03 22:21:50 +07:00
3dtours 1af2119444 IMPROVE: thêm nút bypass cho midi items và audio items 2026-08-03 21:51:37 +07:00
3dtours e5b4321a55 FEAT: thêm FX RACK PANEL cho track, áp dụng cho midi item và audioclip item 2026-08-03 20:25:27 +07:00
3dtours ec5fedec33 FIX: sửa lỗi khi quét chọn duration để loop và khi bật nút loop thì tự động giãn ra bar 18 2026-08-03 19:41:42 +07:00
3dtours 8c1a8ead56 FIX: hiển thị MASTERING PANEL và các modules không bị lỗi 2026-08-03 18:29:34 +07:00
3dtours ed91e4534c IMPROVE: bổ sung thêm imager module ho MASTERING PANEL 2026-08-03 17:54:59 +07:00
3dtours 8fc1c2641b FIX: chỉnh sửa mute và unmute realtime, sửa lỗi save dự án và chèn chung các items vào một track 2026-08-03 17:22:19 +07:00
3dtours a9da813cb1 FEAT: thêm nút bypass cho track strip để bypass không qua mastering panel 2026-08-03 15:47:10 +07:00
3dtours 6f55d36085 feat: Media Explorer - auto tempo theo MIDI file + gõ tempo tay (tempoText/commitTempo) + focus folder cha giữa tree + điều hướng tree bằng phím mũi tên 2026-08-03 12:46:34 +07:00
32 changed files with 11012 additions and 1076 deletions
+112 -10
View File
@@ -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
View File
@@ -8,12 +8,32 @@ from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
import json import json
from app.config import settings from app.config import settings
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user, enforce_password_changed
from app.api.v1.projects import get_optional_user from app.api.v1.projects import get_optional_user
from app.models.user import get_db_connection from app.models.user import get_db_connection
router = APIRouter() router = APIRouter()
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
def _safe_file_id(file_id: str) -> str:
"""Strip any path components from a client-supplied file id."""
if not file_id:
return ""
return os.path.basename(file_id.replace("\\", "/"))
def _resolve_storage_path(file_id: str) -> str:
"""Return the existing file path (processed first, then uploads) for a
sanitized file id, or '' when not found."""
fid = _safe_file_id(file_id)
if not fid:
return ""
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
p = os.path.join(d, fid)
if os.path.isfile(p):
return p
return ""
class EditRequest(BaseModel): class EditRequest(BaseModel):
file_id: str file_id: str
cut_start_ms: Optional[float] = None cut_start_ms: Optional[float] = None
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
@router.post("/upload") @router.post("/upload")
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)): async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
if current_user:
enforce_password_changed(current_user)
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
ext = os.path.splitext(file.filename)[1] ext = os.path.splitext(file.filename or "")[1]
if not ext: if not ext:
ext = ".wav" ext = ".wav"
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}" file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
file_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = os.path.join(settings.UPLOADS_DIR, file_id)
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
# WAV into RAM and bounds disk usage).
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
content = await file.read() size = 0
f.write(content) while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > MAX_AUDIO_UPLOAD_BYTES:
f.close()
try:
os.remove(file_path)
except OSError:
pass
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
f.write(chunk)
# Save original filename as sidecar metadata # Save original filename as sidecar metadata
import json import json
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
@router.post("/edit") @router.post("/edit")
async def edit_audio(req: EditRequest): async def edit_audio(req: EditRequest):
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
# Use uploaded file if it exists, or look in processed if it was already edited # Use uploaded file if it exists, or look in processed if it was already edited
if not os.path.exists(upload_path) and not os.path.exists(processed_path): if not _resolve_storage_path(req.file_id):
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import edit_audio_task from app.tasks.worker import edit_audio_task
task = edit_audio_task.delay(req.dict()) task = edit_audio_task.delay(req.model_dump())
return { return {
"task_id": task.id "task_id": task.id
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
@router.get("/download/{file_id}") @router.get("/download/{file_id}")
async def download_audio(file_id: str): async def download_audio(file_id: str):
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) path = _resolve_storage_path(file_id)
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) if not path:
raise HTTPException(status_code=404, detail="File not found")
if os.path.exists(processed_path): return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
elif os.path.exists(upload_path):
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
raise HTTPException(status_code=404, detail="File not found")
@router.get("/waveform/{file_id}") @router.get("/waveform/{file_id}")
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)): async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
API endpoint vẽ Peak Waveform đồng bộ (Week 2). API endpoint vẽ Peak Waveform đồng bộ (Week 2).
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend. Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = _resolve_storage_path(file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.core.dsp_utils import generate_peak_waveform from app.core.dsp_utils import generate_peak_waveform
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
""" """
API endpoint vẽ RMS Waveform (mượt hơn peak). API endpoint vẽ RMS Waveform (mượt hơn peak).
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = _resolve_storage_path(file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.core.dsp_utils import generate_rms_waveform from app.core.dsp_utils import generate_rms_waveform
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4). API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục. Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id) file_path = _resolve_storage_path(req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id) if not file_path:
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import analyze_ai_task from app.tasks.worker import analyze_ai_task
task = analyze_ai_task.delay( task = analyze_ai_task.delay(
file_id=req.file_id, file_id=_safe_file_id(req.file_id),
api_base_url=req.api_base_url, api_base_url=req.api_base_url,
model=req.model model=req.model
) )
return { return {
"task_id": task.id, "task_id": task.id,
"file_id": req.file_id "file_id": _safe_file_id(req.file_id)
} }
@router.post("/export") @router.post("/export")
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
""" """
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG). API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
""" """
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id) source_path = _resolve_storage_path(req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id) if not source_path:
if os.path.exists(processed_path):
source_path = processed_path
elif os.path.exists(upload_path):
source_path = upload_path
else:
raise HTTPException(status_code=404, detail="File not found") raise HTTPException(status_code=404, detail="File not found")
from app.tasks.worker import export_audio_task from app.tasks.worker import export_audio_task
task = export_audio_task.delay( task = export_audio_task.delay(
file_id=req.file_id, file_id=_safe_file_id(req.file_id),
format=req.format, format=req.format,
sample_rate=req.sample_rate, sample_rate=req.sample_rate,
bit_depth=req.bit_depth bit_depth=req.bit_depth
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
return { return {
"task_id": task.id, "task_id": task.id,
"file_id": req.file_id "file_id": _safe_file_id(req.file_id)
} }
@router.post("/ai-scan") @router.post("/ai-scan")
async def ai_scan_audio(req: AIScanRequest): async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
""" """
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling. 17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment. Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
""" """
if current_user:
enforce_password_changed(current_user)
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
import numpy as np import numpy as np
file_path = None file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if req.file_id:
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
if file_path and os.path.exists(file_path): if file_path:
data, sr = sf.read(file_path) data, sr = sf.read(file_path)
if data.ndim > 1: if data.ndim > 1:
data = data.T data = data.T
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
Executes raw binary sample slice at exact zero-crossing coordinates. Executes raw binary sample slice at exact zero-crossing coordinates.
""" """
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
if current_user:
enforce_password_changed(current_user)
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
import numpy as np import numpy as np
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav" output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id) out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
file_path = None file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
if req.file_id:
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
if os.path.exists(processed_path):
file_path = processed_path
elif os.path.exists(upload_path):
file_path = upload_path
if file_path and os.path.exists(file_path): if file_path:
data, sr = sf.read(file_path) data, sr = sf.read(file_path)
if data.ndim > 1: if data.ndim > 1:
data = data.T data = data.T
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation. Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
""" """
user_id = current_user["user_id"] if current_user else "anonymous" user_id = current_user["user_id"] if current_user else "anonymous"
if current_user:
enforce_password_changed(current_user)
from app.core.python_tools_engine import PythonToolsEngine from app.core.python_tools_engine import PythonToolsEngine
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
+108 -14
View File
@@ -1,10 +1,15 @@
import uuid import uuid
import time import time
from fastapi import APIRouter, HTTPException, Header, Depends import threading
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
from fastapi.responses import JSONResponse
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional from typing import Optional
from app.models.user import get_db_connection from app.models.user import get_db_connection
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin from app.core.auth import (
hash_password, verify_password, create_token, decode_token, seed_admin,
COOKIE_NAME, X_AUTH_HEADER,
)
router = APIRouter() router = APIRouter()
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
old_password: str old_password: str
new_password: str new_password: str
def get_current_user(authorization: Optional[str] = Header(None)): # ── Brute-force guard: in-memory per-IP failed-login limiter ──
if not authorization or not authorization.startswith("Bearer "): _LOGIN_FAILURES = {} # ip -> [timestamps]
_LOGIN_LOCK = threading.Lock()
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW_SEC = 900 # 15 min
LOGIN_BLOCK_SEC = 900
def _check_login_ratelimit(ip: str):
now = time.time()
with _LOGIN_LOCK:
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
_LOGIN_FAILURES[ip] = stamps
def _record_login_failure(ip: str):
now = time.time()
with _LOGIN_LOCK:
stamps = _LOGIN_FAILURES.setdefault(ip, [])
stamps.append(now)
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
def _record_login_success(ip: str):
with _LOGIN_LOCK:
_LOGIN_FAILURES.pop(ip, None)
def _set_auth_cookie(response: Response, token: str):
response.set_cookie(
COOKIE_NAME, token,
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
# path="/" (default); secure flag set by proxy when behind TLS
)
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
token = None
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
elif x_auth_token:
token = x_auth_token
elif request.cookies.get(COOKIE_NAME):
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ") raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
token = authorization.split(" ")[1]
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ") raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
) )
@router.post("/login") @router.post("/login")
async def login(req: LoginRequest): async def login(req: LoginRequest, request: Request):
client_ip = request.client.host if request.client else "unknown"
_check_login_ratelimit(client_ip)
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
conn.close() conn.close()
if not user or not user["is_active"]: if not user or not user["is_active"]:
_record_login_failure(client_ip)
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
if not verify_password(password, user["hashed_password"]): if not verify_password(password, user["hashed_password"]):
_record_login_failure(client_ip)
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác") raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"]) token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
_record_login_success(client_ip)
return {
resp = JSONResponse({
"access_token": token, "access_token": token,
"user": { "user": {
"id": user["id"], "id": user["id"],
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
"role": user["role"], "role": user["role"],
"must_change_password": bool(user["must_change_password"]) "must_change_password": bool(user["must_change_password"])
} }
} })
_set_auth_cookie(resp, token)
return resp
def _validate_password_strength(password: str):
"""Minimal strength policy: >= 8 chars and not trivially common."""
if len(password) < 8:
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
lowered = password.lower()
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
@router.post("/register") @router.post("/register")
async def register(req: RegisterRequest): async def register(req: RegisterRequest, request: Request):
username = req.username.strip() username = req.username.strip()
email = req.email.strip() email = req.email.strip()
password = req.password.strip() password = req.password.strip()
_validate_password_strength(password)
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
conn.close() conn.close()
token = create_token(user_id, username, "standard", False) token = create_token(user_id, username, "standard", False)
return { resp = JSONResponse({
"access_token": token, "access_token": token,
"user": { "user": {
"id": user_id, "id": user_id,
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
"role": "standard", "role": "standard",
"must_change_password": False "must_change_password": False
} }
} })
_set_auth_cookie(resp, token)
return resp
@router.post("/change-password") @router.post("/change-password")
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)): async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
conn.close() conn.close()
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False) new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
return { resp = JSONResponse({
"message": "Đổi mật khẩu thành công!", "message": "Đổi mật khẩu thành công!",
"access_token": new_token "access_token": new_token
} })
_set_auth_cookie(resp, new_token)
return resp
@router.get("/profile") @router.get("/profile")
async def get_profile(current_user: dict = Depends(get_current_user)): async def get_profile(current_user: dict = Depends(get_current_user)):
@@ -194,3 +259,32 @@ async def get_profile(current_user: dict = Depends(get_current_user)):
"max_tracks": row["max_tracks"] or 16 "max_tracks": row["max_tracks"] or 16
} }
} }
@router.get("/first-time")
async def auth_first_time():
# "Lần đăng nhập đầu" = tài khoản admin vẫn dùng mật khẩu MẶC ĐỊNH
# (chưa từng đổi). Sau khi đổi lần đầu → first_time = false → UI xóa
# gợi ý username/mật khẩu (Tùy chọn - Admin có thể bỏ trống, lần đầu:
# admin123, nút Điền nhanh).
try:
conn = get_db_connection()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, hashed_password, must_change_password FROM users "
"WHERE LOWER(role) = 'admin' ORDER BY created_at ASC LIMIT 1"
)
row = cur.fetchone()
finally:
conn.close()
if not row:
return {"first_time": True}
still_default = False
try:
still_default = verify_password("admin123", row["hashed_password"])
except Exception:
still_default = False
return {"first_time": bool(row["must_change_password"]) and still_default}
except Exception:
return {"first_time": True}
+6 -4
View File
@@ -2,9 +2,11 @@ import os
import platform import platform
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.api.v1.auth import get_current_user
router = APIRouter() router = APIRouter()
MEDIA_EXTS = { MEDIA_EXTS = {
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
@router.get("/computer") @router.get("/computer")
async def list_computer_roots(): async def list_computer_roots(current_user: dict = Depends(get_current_user)):
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer).""" """Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
system = platform.system() system = platform.system()
roots = [] roots = []
@@ -97,7 +99,7 @@ async def list_computer_roots():
@router.get("/browse") @router.get("/browse")
async def browse_directory(path: str = Query(...)): async def browse_directory(path: str = Query(...), current_user: dict = Depends(get_current_user)):
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI.""" """Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
resolved = _safe_path(path) resolved = _safe_path(path)
if not os.path.isdir(resolved): if not os.path.isdir(resolved):
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
@router.get("/file") @router.get("/file")
async def serve_local_file(path: str = Query(...)): async def serve_local_file(path: str = Query(...), current_user: dict = Depends(get_current_user)):
"""Phục vụ file audio/MIDI cục bộ để preview.""" """Phục vụ file audio/MIDI cục bộ để preview."""
resolved = _safe_path(path) resolved = _safe_path(path)
if not os.path.isfile(resolved): if not os.path.isfile(resolved):
+2 -2
View File
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
# Gửi task xuống Celery Worker # Gửi task xuống Celery Worker
from app.tasks.worker import mix_multitrack_task from app.tasks.worker import mix_multitrack_task
task = mix_multitrack_task.delay(req.dict()) task = mix_multitrack_task.delay(req.model_dump())
return { return {
"task_id": task.id, "task_id": task.id,
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau. Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
""" """
from app.tasks.worker import process_multitrack_session_task from app.tasks.worker import process_multitrack_session_task
task = process_multitrack_session_task.delay(req.dict()) task = process_multitrack_session_task.delay(req.model_dump())
return { return {
"task_id": task.id, "task_id": task.id,
+26 -5
View File
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter() router = APIRouter()
@@ -79,11 +79,23 @@ async def upload_soundfont(
background_tasks: BackgroundTasks = None, background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
enforce_password_changed(current_user)
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))): if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed") raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
contents = await file.read() # Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
if not PluginManager.validate_sf2_header(contents): # 500MB; reading the whole body into RAM would OOM the server).
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
contents = bytearray()
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
contents.extend(chunk)
if len(contents) > MAX_SF_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header") raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
file_ext = os.path.splitext(file.filename)[1] file_ext = os.path.splitext(file.filename)[1]
@@ -139,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
@router.get("/soundfonts/download/{sf_id}") @router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str): async def download_soundfont_asset(sf_id: str):
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]: # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
if not os.path.isdir(base_dir): if not os.path.isdir(base_dir):
continue continue
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis) # Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
@@ -178,8 +194,13 @@ async def render_project(
req: RenderRequest, req: RenderRequest,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
enforce_password_changed(current_user)
engine = PythonRenderEngine() engine = PythonRenderEngine()
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav") # Prevent path traversal: strip any directory components and force .wav.
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
if not safe_name.lower().endswith(".wav"):
safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try: try:
result_path = engine.render_project(req.project_json, output_path) result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path} return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
+31 -9
View File
@@ -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("""
+1 -1
View File
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)): 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
View File
@@ -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
View File
@@ -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
+16 -10
View File
@@ -280,31 +280,37 @@ class SoundFontConverter:
@staticmethod @staticmethod
def _sf3_plays_audio(path: str) -> bool: def _sf3_plays_audio(path: str) -> bool:
"""Verify a SoundFont actually loads and renders audible audio (guards """Verify a SoundFont actually loads and renders audible audio (guards
against shipping malformed SF3 files that silently play nothing).""" against shipping malformed SF3 files that silently play nothing).
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
high-level Synth() class does not exist in this binding, so it is never
used here.
"""
if not os.path.exists(path): if not os.path.exists(path):
return False return False
try: try:
import fluidsynth import fluidsynth as _fs
import numpy as np import numpy as np
fl = fluidsynth.Synth() _settings = _fs.new_fluid_settings()
_fl = _fs.new_fluid_synth(_settings)
try: try:
h = fl.sfload(path) h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
if h < 0: if h < 0:
return False return False
fl.program_select(0, h, 0, 0) _fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
fl.noteon(0, 60, 100) _fs.fluid_synth_noteon(_fl, 0, 60, 100)
frames = 8820 # 0.2s frames = 8820 # 0.2s
buf = np.zeros(frames * 2, dtype=np.float32) buf = np.zeros(frames * 2, dtype=np.float32)
fluidsynth._fl.fluid_synth_write_float( _fs.fluid_synth_write_float(
fl.synth, frames, buf.ctypes.data, 0, 1, _fl, frames, buf.ctypes.data, 0, 1,
buf.ctypes.data + frames * 4, 0, 1 buf.ctypes.data + frames * 4, 0, 1
) )
fl.noteoff(0, 60) _fs.fluid_synth_noteoff(_fl, 0, 60)
rms = float(np.sqrt(np.mean(buf ** 2))) rms = float(np.sqrt(np.mean(buf ** 2)))
return rms > 1e-4 return rms > 1e-4
finally: finally:
try: try:
fl.delete() _fs.delete_fluid_synth(_fl)
except Exception: except Exception:
pass pass
except Exception: except Exception:
+59 -39
View File
@@ -2,7 +2,7 @@
import os import os
import numpy as np import numpy as np
import functools import functools
from ctypes import c_int, c_char_p, c_void_p from ctypes import c_char_p
def midi_note_to_freq(note_number: int) -> float: def midi_note_to_freq(note_number: int) -> float:
return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
return _PLUGIN_MANAGER_INSTANCE return _PLUGIN_MANAGER_INSTANCE
def load_soundfont_cached(path: str): def load_soundfont_cached(path: str):
"""Return a cached FluidSynth instance for path, incrementing refcount.""" """Return a cached low-level FluidSynth instance for path, incrementing refcount.
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
do not exist in this binding, so they are never used here.
"""
global _FLUID_CACHE global _FLUID_CACHE
if not HAS_PYFLUIDSYNTH: if not HAS_PYFLUIDSYNTH:
return None return None
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
_FLUID_CACHE[path] = (fl, ref + 1) _FLUID_CACHE[path] = (fl, ref + 1)
return fl return fl
try: try:
import fluidsynth import fluidsynth as _fs
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5) _settings = _fs.new_fluid_settings()
font_id = fl.sfload(path) _fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
fl.program_select(0, font_id, 0, 0) fl = _fs.new_fluid_synth(_settings)
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
if font_id < 0:
_fs.delete_fluid_synth(fl)
return None
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
_FLUID_CACHE[path] = (fl, 1) _FLUID_CACHE[path] = (fl, 1)
return fl return fl
except Exception: except Exception:
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
fl, ref = _FLUID_CACHE[path] fl, ref = _FLUID_CACHE[path]
if ref <= 1: if ref <= 1:
try: try:
fl.delete() import fluidsynth as _fs
_fs.delete_fluid_synth(fl)
except Exception: except Exception:
pass pass
del _FLUID_CACHE[path] del _FLUID_CACHE[path]
@@ -245,38 +256,47 @@ class PluginManager:
if base == sf_id or base == sf_id.replace("sf_", ""): if base == sf_id or base == sf_id.replace("sf_", ""):
path = os.path.join(d, f) path = os.path.join(d, f)
try: try:
import fluidsynth import fluidsynth as _fs
fl = fluidsynth.Synth() # Low-level CFFI API (same as render_engine); never use
fid = fl.sfload(path) # the high-level Synth() class that this binding lacks.
if fid < 0: _settings = _fs.new_fluid_settings()
fl.delete() _synth = _fs.new_fluid_synth(_settings)
continue try:
presets = [] fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
_fl = fluidsynth._fl if fid < 0:
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p continue
_fl.fluid_preset_get_name.restype = c_char_p sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
_fl.fluid_sfont_get_preset.restype = c_void_p presets = []
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid)) if sfont:
if sfont_ptr: for bank in range(0, 2):
for bank in range(0, 2): for prog_num in range(0, 128):
for prog_num in range(0, 128): try:
try: preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num)) except Exception:
except Exception: break
break if preset:
if preset: try:
name_ptr = fluidsynth.fluid_preset_get_name(preset) name_ptr = _fs.fluid_preset_get_name(preset)
if name_ptr: if name_ptr:
name_val = c_char_p(name_ptr).value if hasattr(_fs, "ffi"):
if name_val: raw = _fs.ffi.string(name_ptr)
presets.append({ else:
"bank": bank, raw = c_char_p(name_ptr).value
"program": prog_num, if raw:
"name": name_val.decode("utf-8", errors="replace") presets.append({
}) "bank": bank,
fl.delete() "program": prog_num,
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256] "name": raw.decode("utf-8", errors="replace")
return presets[:256] })
except Exception:
continue
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
return presets[:256]
finally:
try:
_fs.delete_fluid_synth(_synth)
except Exception:
pass
except Exception: except Exception:
import traceback; traceback.print_exc() import traceback; traceback.print_exc()
_SF_INSTRUMENTS_CACHE[sf_id] = [] _SF_INSTRUMENTS_CACHE[sf_id] = []
+30 -23
View File
@@ -1,8 +1,11 @@
import os import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from app.config import settings from app.config import settings
from app.api.v1.audio import router as audio_router from app.api.v1.audio import router as audio_router
from app.api.v1.tasks import router as tasks_router from app.api.v1.tasks import router as tasks_router
@@ -22,16 +25,32 @@ from app.core.soundfont_scanner import SoundFontAutoScanner
os.makedirs(settings.UPLOADS_DIR, exist_ok=True) os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
os.makedirs(settings.PROCESSED_DIR, exist_ok=True) os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
app = FastAPI(title="SonicForge API Engine") _SF_SCANNER_STOP = None
from fastapi.middleware.gzip import GZipMiddleware
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
seed_admin()
scanner = SoundFontAutoScanner()
global _SF_SCANNER_STOP
_SF_SCANNER_STOP = scanner.start_background(interval=30)
yield
# Shutdown
if _SF_SCANNER_STOP is not None:
_SF_SCANNER_STOP.set()
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
app.add_middleware(GZipMiddleware, minimum_size=500) app.add_middleware(GZipMiddleware, minimum_size=500)
# Auth is token/cookie based (no cookies required for CORS), so credentials are
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=False,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
@@ -55,22 +74,6 @@ app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"]) app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
# Seed admin user on startup
@app.on_event("startup")
async def startup_seed_admin():
seed_admin()
@app.on_event("startup")
async def startup_convert_soundfonts():
# SF2 -> SF3 conversion is disabled: the client FluidSynth WASM cannot decode
# Ogg Vorbis (SF3) samples, so converted SF3s would play silence. The download
# endpoint serves SF2 when available and converts SF3 -> SF2 on demand instead.
pass
@app.on_event("startup")
async def startup_sf_scanner():
scanner = SoundFontAutoScanner()
scanner.start_background(interval=30)
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def get_index(): async def get_index():
@@ -78,14 +81,18 @@ async def get_index():
if not os.path.exists(index_path): if not os.path.exists(index_path):
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404) return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
with open(index_path, "r", encoding="utf-8") as file: with open(index_path, "r", encoding="utf-8") as file:
return HTMLResponse(content=file.read(), status_code=200) resp = HTMLResponse(content=file.read(), status_code=200)
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return resp
@app.get("/favicon.svg") @app.get("/favicon.svg")
async def get_favicon(): async def get_favicon():
import os
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg") favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
if os.path.exists(favicon_path): if os.path.exists(favicon_path):
from fastapi.responses import FileResponse
return FileResponse(favicon_path, media_type="image/svg+xml") return FileResponse(favicon_path, media_type="image/svg+xml")
return HTMLResponse(content="", status_code=404) return HTMLResponse(content="", status_code=404)
+1
View File
@@ -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
View File
@@ -5,12 +5,18 @@ import time
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from app.config import settings from app.config import settings
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db") # Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
# dev database is never touched by the test suite.
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
def get_db_connection(): def get_db_connection():
os.makedirs(settings.STORAGE_DIR, exist_ok=True) os.makedirs(settings.STORAGE_DIR, exist_ok=True)
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
# WAL improves concurrent read/write; FK enforcement makes quota/backup
# cleanup consistent when users are deleted.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn return conn
def init_db(): def init_db():
@@ -89,6 +95,16 @@ def init_db():
); );
""") """)
# Placeholder user for anonymous autosave: projects are saved with
# user_id='anonymous' when no token is present, so the FK must resolve.
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
if not cursor.fetchone():
import secrets as _secrets
cursor.execute("""
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
""", (_secrets.token_hex(32), time.time()))
conn.commit() conn.commit()
conn.close() conn.close()
+5973 -689
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+56 -1
View File
@@ -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,
+183 -22
View File
@@ -22,6 +22,10 @@
let _loadedFonts = {}; let _loadedFonts = {};
let _activeOscillators = {}; let _activeOscillators = {};
let _gainNode = null; let _gainNode = null;
let _pendingOutputDestination = null;
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
let _scheduledNotes = []; let _scheduledNotes = [];
let _loadPromises = {}; let _loadPromises = {};
let _sfloadSeq = 0; let _sfloadSeq = 0;
@@ -31,7 +35,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = _audioCtx.createGain(); _gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_outputDestination);
} }
return _audioCtx; return _audioCtx;
} }
@@ -40,7 +45,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = ctx.createGain(); _gainNode = ctx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
_gainNode.connect(_outputDestination);
} }
return ctx; return ctx;
} }
@@ -53,7 +59,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = window.__sharedAudioCtx.createGain(); _gainNode = window.__sharedAudioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.__sharedAudioCtx.destination); _outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
_gainNode.connect(_outputDestination);
} }
return window.__sharedAudioCtx; return window.__sharedAudioCtx;
}; };
@@ -61,6 +68,41 @@
const SonicSF = { const SonicSF = {
loadedFonts: _loadedFonts, loadedFonts: _loadedFonts,
// Route the shared FluidSynth output through a per-track node (e.g. the
// track's gainNode) so the track's FX chain / fader / pan affect the
// soundfont instrument. Pass null to restore the default master-bus route.
setOutputDestination: function (node) {
try {
if (_gainNode) {
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
// làm ScriptProcessor xuất buffer uninitialized → NaN →
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
// ngay sau setOutputDestination lần 2).
if (dest === _outputDestination) return;
_gainNode.disconnect();
_gainNode.connect(dest);
// Reconnect VU analyser tap (app.jsx VU tick gắn __vuAnalyser
// trên _gainNode): disconnect() không đối số ngắt MỌI kết
// nối kể cả analyser → sfAudioPeak = 0 mãi → track VU MIDI
// không nhảy khi có âm (user bug 06:35).
if (_gainNode.__vuAnalyser) _gainNode.connect(_gainNode.__vuAnalyser);
_outputDestination = dest;
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
} else {
_pendingOutputDestination = node || null;
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
}
} catch (e) {
console.warn('[SonicSF] setOutputDestination error:', e);
}
},
getOutputNode: function () {
return _gainNode;
},
init: async function (audioContext) { init: async function (audioContext) {
if (_initialized && _fluidModule) return; if (_initialized && _fluidModule) return;
if (_initPromise) return _initPromise; if (_initPromise) return _initPromise;
@@ -81,7 +123,8 @@
if (!_gainNode) { if (!_gainNode) {
_gainNode = _audioCtx.createGain(); _gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3; _gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination); _outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_outputDestination);
} }
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate); console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
@@ -117,7 +160,7 @@
// expected notice when a soundfont simply has no // expected notice when a soundfont simply has no
// preset for a bank (e.g. bank 128 on a melodic-only // preset for a bank (e.g. bank 128 on a melodic-only
// font) — the note is just silent, not an error. // font) — the note is just silent, not an error.
if (msg && msg.indexOf('No preset found on channel') !== -1) return; if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
console.warn('[FluidSynth:err]', msg); console.warn('[FluidSynth:err]', msg);
} }
}); });
@@ -168,17 +211,31 @@
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2); var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
var lp = _fluidModule._malloc(spBufSz * 4); var lp = _fluidModule._malloc(spBufSz * 4);
var rp = _fluidModule._malloc(spBufSz * 4); var rp = _fluidModule._malloc(spBufSz * 4);
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
// → master chain "state is bad" → CÂM + stuck. Theo dõi
// buffer + re-malloc khi đổi.
var _heapBufRef = _fluidModule.HEAPU8.buffer;
spn.onaudioprocess = function (e) { spn.onaudioprocess = function (e) {
var left = e.outputBuffer.getChannelData(0); var left = e.outputBuffer.getChannelData(0);
var right = e.outputBuffer.getChannelData(1); var right = e.outputBuffer.getChannelData(1);
var sz = left.length; var sz = left.length;
try { try {
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
lp = _fluidModule._malloc(sz * 4);
rp = _fluidModule._malloc(sz * 4);
_heapBufRef = _fluidModule.HEAPU8.buffer;
}
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1); _fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
var hf = _fluidModule.HEAPF32; var hf = _fluidModule.HEAPF32;
var lpb = lp >> 2, rpb = rp >> 2; var lpb = lp >> 2, rpb = rp >> 2;
for (var si = 0; si < sz; si++) { for (var si = 0; si < sz; si++) {
left[si] = hf[lpb + si]; // NaN sweep: mẫu NaN/Inf → 0 (chain biquad
right[si] = hf[rpb + si]; // KHÔNG BAO GIỜ được nhận NaN → không state-bad).
var L = hf[lpb + si], R = hf[rpb + si];
left[si] = isFinite(L) ? L : 0;
right[si] = isFinite(R) ? R : 0;
} }
} catch (er) {} } catch (er) {}
}; };
@@ -249,6 +306,12 @@
_doLoadSoundFont: async function (sfId) { _doLoadSoundFont: async function (sfId) {
try { try {
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
// thành rác trong khi channel state vẫn trỏ tới → program_select
// bị skip (progAlreadySet) → noteon trên handle đã unload →
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
var cache = window.SonicSFStorage; var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null; var buf = cache ? await cache.getBuffer(sfId) : null;
if (buf) { if (buf) {
@@ -269,8 +332,15 @@
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now(); var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url); var resp = await fetch(url);
if (!resp.ok) { if (!resp.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId); // Fallback: font bundled theo deployment (static/soundfonts —
return false; // serve qua /soundfonts/{f} — catalog default-soundfonts).
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
var resp2 = await fetch(url2);
if (!resp2.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId);
return false;
}
resp = resp2;
} }
buf = await resp.arrayBuffer(); buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf); if (cache) await cache.saveBuffer(sfId, buf);
@@ -429,10 +499,11 @@
}, },
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) { _playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60)); var parsedPitch = parseInt(note);
var midiVel = Math.min(127, Math.max(1, Math.floor( var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100 var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
))); if (isNaN(rawVel)) rawVel = 100;
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
var _origChannel = channel; var _origChannel = channel;
var usedBank = 0, usedProg = 0; var usedBank = 0, usedProg = 0;
if (synthEngine) { if (synthEngine) {
@@ -465,8 +536,10 @@
var self = this; var self = this;
var doNote = function () { var doNote = function () {
try { try {
var finalBank = usedBank; var finalBank = parseInt(usedBank);
var finalProg = usedProg; if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
var finalProg = parseInt(usedProg);
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined; var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
var cachedCh = _channels[ch]; var cachedCh = _channels[ch];
// The note's own synth engine (track instrument) is // The note's own synth engine (track instrument) is
@@ -475,16 +548,32 @@
// re-picked instrument plays the wrong soundfont. Without an // re-picked instrument plays the wrong soundfont. Without an
// engine, fall back to the soundfont configured on the channel. // engine, fall back to the soundfont configured on the channel.
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) { if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
finalBank = cachedCh.bank; finalBank = parseInt(cachedCh.bank) || 0;
finalProg = cachedCh.program; finalProg = parseInt(cachedCh.program) || 0;
finalSfId = cachedCh.sfId; finalSfId = cachedCh.sfId;
} }
// Ensure the soundfont is actually loaded before the note plays. // Ensure the soundfont is actually loaded before the note plays.
// Quick instrument pick on a track does not pre-load it, so load // Quick instrument pick on a track does not pre-load it, so load
// lazily here and retry the note once the font is ready. // lazily here and retry the note once the font is ready.
if (finalSfId && !_sfHandleMap.has(finalSfId)) { if (finalSfId && !_sfHandleMap.has(finalSfId)) {
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
// — note chạy thẳng fallback để CÓ ÂM.
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
if (Date.now() - _lastFail < 10000) {
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
return;
}
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
self.loadSoundFont(finalSfId).then(function (ok) { self.loadSoundFont(finalSfId).then(function (ok) {
if (ok) doNote(); console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
if (ok) {
doNote();
} else {
// Font KHÔNG tải được (404/format) → KHÔNG drop note
// câm lặng ("bỏ qua WASM") — fallback oscillator.
_sfLoadFailAt[finalSfId] = Date.now();
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
}
}); });
return; return;
} }
@@ -492,12 +581,43 @@
// instrument for each item regardless of processing order. // instrument for each item regardless of processing order.
// Skip if the channel already has this exact instrument (avoids // Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches). // per-note soundfont reloads that cause audible crackle/glitches).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId; // ⚠️ Chỉ skip khi handle SF vẫn CÒN HỢP LỆ trong map — nếu
// không → vẫn program_select lại (tránh dùng handle đã unload).
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId
&& (finalSfId ? _sfHandleMap.has(finalSfId) : true);
if ((synthEngine || program !== undefined) && !progAlreadySet) { if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined; var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
if (sfHandle !== undefined) { if (sfHandle !== undefined) {
try { try {
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg); // Percussion (bank 128): tìm preset HỢP LỆ trong
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
// cache theo sfId. Trước đây chỉ thử 4 preset cố
// định → font không có → cache channel = (128,0)
// INVALID → note sau skip re-select (progAlreadySet)
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
if (finalBank === 128) {
var _vKey = finalSfId || ('h' + sfHandle);
if (_validPercCache[_vKey] === undefined) {
var _found = null;
for (var _b = 0; _b < 2 && !_found; _b++) {
var _bk = _b === 0 ? 128 : 0;
for (var _p = 0; _p < 128 && !_found; _p++) {
try {
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
_found = [_bk, _p];
}
} catch (e) {}
}
}
_validPercCache[_vKey] = _found;
}
if (_validPercCache[_vKey]) {
finalBank = _validPercCache[_vKey][0];
finalProg = _validPercCache[_vKey][1];
}
}
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
} catch (e) {} } catch (e) {}
} else { } else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {} try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
@@ -508,6 +628,7 @@
_channels[ch].program = finalProg; _channels[ch].program = finalProg;
_channels[ch].sfId = finalSfId; _channels[ch].sfId = finalSfId;
} }
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel); _fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch; var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = []; if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
@@ -538,6 +659,31 @@
} }
}, },
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
// tự động → ngân vô hạn → VU master nhảy dù không play).
panic: function () {
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
_scheduledNotes = [];
if (_initialized && _fluidModule) {
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
// thể không có trong WASM exports (catch nuốt → notes kẹt).
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
try {
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
} catch (e) {}
}
_activeNotes = {};
},
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) { _playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
var ctx = getCtx(); var ctx = getCtx();
var freq = 440 * Math.pow(2, (note - 69) / 12); var freq = 440 * Math.pow(2, (note - 69) / 12);
@@ -554,7 +700,11 @@
var oscType = 'triangle'; var oscType = 'triangle';
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25; var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
var prog = program !== undefined ? parseInt(program) : 0; var prog = program !== undefined ? parseInt(program) : 0;
if (channel !== undefined && channel >= 0 && channel < 16) { // CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
// truyền — trước đây override program của track bằng cache channel
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
// nhạc cụ của track TRƯỚC).
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
prog = _channels[channel].program || prog; prog = _channels[channel].program || prog;
} }
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; } if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
@@ -572,7 +722,8 @@
osc.frequency.setValueAtTime(freq, 0); osc.frequency.setValueAtTime(freq, 0);
var startAt = startTime !== undefined ? startTime : ctx.currentTime; var startAt = startTime !== undefined ? startTime : ctx.currentTime;
var durSec = durationMs / 1000; var durSec = durationMs / 1000;
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
if (isNaN(vel)) vel = 0.8;
var targetGain = vel * volFactor; var targetGain = vel * volFactor;
noteGain.gain.setValueAtTime(0, startAt); noteGain.gain.setValueAtTime(0, startAt);
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime); noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
@@ -593,6 +744,15 @@
stopAll: function () { stopAll: function () {
if (_initialized && _fluidModule) { if (_initialized && _fluidModule) {
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
// phòng all_notes_off không có trong WASM exports.
Object.keys(_activeNotes).forEach(function (key) {
var parts = key.split(':');
var pitch = parseInt(parts[1], 10);
(_activeNotes[key] || []).forEach(function (ch) {
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
});
});
for (var ch = 0; ch < 16; ch++) { for (var ch = 0; ch < 16; ch++) {
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {} try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
} }
@@ -611,6 +771,7 @@
} catch (e) {} } catch (e) {}
}); });
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; }); Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
_activeNotes = {};
}, },
saveToIndexedDB: async function (name, arrayBuffer) { saveToIndexedDB: async function (name, arrayBuffer) {
+20 -2
View File
@@ -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
}; };
})(); })();
+1
View File
@@ -0,0 +1 @@
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
+4 -4
View File
@@ -14,17 +14,17 @@
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script> <script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/api.js?v=202607271016"></script> <script src="/static/js/services/api.js?v=202607271016"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script> <script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202607271016"></script> <script src="/static/js/services/storage.js?v=202608038200"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script> <script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script> <script src="/static/js/services/soundfontPlayer.js?v=202608070635"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script> <script src="/static/js/services/aiGateway.js?v=202608037200"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script> <script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script> <script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script> <script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608031415" defer></script> <script src="/static/js/app.precompiled.js?v=202608070950" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {
+20
View File
@@ -0,0 +1,20 @@
import os
import sys
sys.path.insert(0, "/app")
try:
import fluidsynth
print("fluidsynth import successful.")
fl = fluidsynth.Synth()
# Try to load a pre-existing system SF3
sf3_path = "/opt/daw_engine/soundfonts/Equinox_Grand_Pianos.sf3"
print(f"Checking if {sf3_path} exists: {os.path.exists(sf3_path)}")
if os.path.exists(sf3_path):
h = fl.sfload(sf3_path)
print(f"Loaded {sf3_path}, handle: {h}")
else:
print("Equinox_Grand_Pianos.sf3 not found.")
except Exception as e:
print(f"Failed: {e}")
+66
View File
@@ -0,0 +1,66 @@
import os
import sys
import logging
# Ensure app is in path
sys.path.insert(0, "/app")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("test_sf_convert")
from app.core.soundfont_converter import SoundFontConverter
def test():
sf2_dir = "/app/app/storage/soundfonts"
sf2_files = [os.path.join(sf2_dir, f) for f in os.listdir(sf2_dir) if f.endswith(".sf2") and "_decomp" not in f]
if not sf2_files:
logger.error("No SF2 files found in /app/app/storage/soundfonts")
return
sf2_path = sf2_files[0]
logger.info(f"Testing with SF2 file: {sf2_path}")
converter = SoundFontConverter()
# Check ffmpeg encoder support
has_ogg = converter._check_ffmpeg_ogg()
logger.info(f"ffmpeg with libvorbis available: {has_ogg}")
# Convert SF2 -> SF3
sf3_path = sf2_path.replace(".sf2", ".sf3")
if os.path.exists(sf3_path):
os.remove(sf3_path)
logger.info("Converting SF2 -> SF3...")
result_path = converter.convert_sf2_to_sf3(sf2_path)
logger.info(f"Result path from convert_sf2_to_sf3: {result_path}")
if result_path.endswith(".sf3"):
logger.info(f"SF3 file exists: {os.path.exists(sf3_path)}")
if os.path.exists(sf3_path):
logger.info(f"SF3 size: {os.path.getsize(sf3_path)} bytes")
# Verify if it plays audio
plays = converter._sf3_plays_audio(sf3_path)
logger.info(f"SF3 plays audio (pyfluidsynth verify): {plays}")
# Now test decompression back to SF2
decomp_sf2 = sf3_path.replace(".sf3", "_decomp.sf2")
if os.path.exists(decomp_sf2):
os.remove(decomp_sf2)
logger.info("Decompressing SF3 -> SF2...")
try:
decomp_result = converter.sf3_to_sf2(sf3_path, decomp_sf2)
logger.info(f"Decompress result path: {decomp_result}")
if os.path.exists(decomp_sf2):
logger.info(f"Decompressed SF2 size: {os.path.getsize(decomp_sf2)} bytes")
# Check if it plays
decomp_plays = converter._sf3_plays_audio(decomp_sf2)
logger.info(f"Decompressed SF2 plays audio: {decomp_plays}")
except Exception as e:
logger.error(f"Decompression failed: {e}", exc_info=True)
else:
logger.warning("Conversion did not produce an SF3 path.")
if __name__ == "__main__":
test()
+14
View File
@@ -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');
+14
View File
@@ -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"),
)
+11 -1
View File
@@ -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
+68
View File
@@ -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
+234
View File
@@ -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
+993
View File
@@ -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
}
]
+62
View File
@@ -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 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))
+1651
View File
File diff suppressed because it is too large Load Diff