Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca | |||
| 289746f187 | |||
| b3ced7a7b3 | |||
| 4233c1eeda | |||
| 35f7c3822f | |||
| d37f4e7557 | |||
| 8a9d6b3c27 | |||
| 55d3464b1e | |||
| cfc114b9d7 | |||
| 856a8183b6 | |||
| 0272912cff | |||
| c047934fc4 | |||
| fc663921ff | |||
| fe5e8cb58e | |||
| 01fcf51fea | |||
| 1af2119444 | |||
| e5b4321a55 | |||
| ec5fedec33 | |||
| 8c1a8ead56 | |||
| ed91e4534c | |||
| 8fc1c2641b | |||
| a9da813cb1 | |||
| 6f55d36085 | |||
| 7325fbfc45 | |||
| 94b2d2ef41 | |||
| f616edce49 | |||
| 6018263044 | |||
| 36edc9daca | |||
| 71278f2aba | |||
| 022ba38a5e | |||
| 71c3bafdb5 | |||
| 34ad02dd6b | |||
| 47b1633bd3 | |||
| 0182abf7ea | |||
| 9b6de7f857 | |||
| 84ab4ae823 | |||
| 25471e6ea5 | |||
| 0dc95386f2 | |||
| d8227904b6 |
+112
-10
@@ -1,7 +1,16 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.user_config import _load_ai_configs, _get_default_providers
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,17 +19,108 @@ class ProxyRequest(BaseModel):
|
||||
headers: Dict[str, str] = {}
|
||||
body: Dict[str, Any] = {}
|
||||
|
||||
import json
|
||||
|
||||
# Ranges that are never legitimate AI endpoints: cloud metadata + this host.
|
||||
_BLOCKED_NETWORKS = [
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud metadata
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
]
|
||||
# Private ranges: only reachable when the target host is one the user has
|
||||
# explicitly configured as an AI provider (e.g. local Ollama/LM Studio).
|
||||
_PRIVATE_NETWORKS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"), # ULA
|
||||
]
|
||||
|
||||
_LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
|
||||
|
||||
|
||||
def _configured_ai_hosts(user_id: str) -> set:
|
||||
"""Hosts the user has configured as AI providers (from saved config + defaults)."""
|
||||
hosts = set()
|
||||
configs = _load_ai_configs()
|
||||
providers = configs.get(user_id) or _get_default_providers()
|
||||
for p in providers:
|
||||
base = (p.get("api_base_url") or "").strip()
|
||||
if not base:
|
||||
continue
|
||||
try:
|
||||
host = urlparse(base).hostname
|
||||
if host:
|
||||
hosts.add(host.lower())
|
||||
except Exception:
|
||||
continue
|
||||
return hosts
|
||||
|
||||
|
||||
async def _resolve_host_ips(hostname: str):
|
||||
"""Resolve hostname to IPs (non-blocking). Returns list of ipaddress objects."""
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
infos = await loop.run_in_executor(None, socket.getaddrinfo, hostname, None)
|
||||
ips = []
|
||||
for info in infos:
|
||||
try:
|
||||
ips.append(ipaddress.ip_address(info[4][0]))
|
||||
except ValueError:
|
||||
continue
|
||||
return ips
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _validate_target_url(url: str, user_id: str):
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise HTTPException(status_code=400, detail="URL chỉ hỗ trợ giao thức http/https")
|
||||
if parsed.username or parsed.password:
|
||||
raise HTTPException(status_code=400, detail="URL không được chứa thông tin đăng nhập")
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if not hostname:
|
||||
raise HTTPException(status_code=400, detail="URL không hợp lệ")
|
||||
|
||||
allowed_hosts = _configured_ai_hosts(user_id)
|
||||
|
||||
# Hostname-level fast path for loopback hosts
|
||||
if hostname in _LOOPBACK_HOSTS:
|
||||
if hostname in allowed_hosts:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Target nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
# Try direct IP parse (hostname may itself be an IP)
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
ips = [ip]
|
||||
except ValueError:
|
||||
ips = await _resolve_host_ips(hostname)
|
||||
|
||||
if not ips:
|
||||
raise HTTPException(status_code=502, detail="Không phân giải được hostname")
|
||||
|
||||
for ip in ips:
|
||||
if any(ip in net for net in _BLOCKED_NETWORKS):
|
||||
raise HTTPException(status_code=403, detail="Target bị chặn (metadata/link-local không được phép)")
|
||||
if any(ip in net for net in _PRIVATE_NETWORKS):
|
||||
if hostname in allowed_hosts:
|
||||
continue
|
||||
raise HTTPException(status_code=403, detail="Target IP nội bộ không nằm trong danh sách AI provider đã cấu hình")
|
||||
|
||||
|
||||
@router.post("/proxy")
|
||||
async def proxy_llm(req: ProxyRequest):
|
||||
async def proxy_llm(req: ProxyRequest, current_user: dict = Depends(get_current_user)):
|
||||
await _validate_target_url(req.url, current_user["user_id"])
|
||||
# Never forward the app's own auth token upstream.
|
||||
headers = {
|
||||
k: v for k, v in req.headers.items()
|
||||
if k.lower() not in ("host", "origin", "referer", "x-auth-token")
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
resp = await client.post(
|
||||
req.url,
|
||||
headers={k: v for k, v in req.headers.items() if k.lower() not in ('host', 'origin', 'referer')},
|
||||
json=req.body
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=180.0, follow_redirects=False) as client:
|
||||
resp = await client.post(req.url, headers=headers, json=req.body)
|
||||
raw = resp.text
|
||||
try:
|
||||
return resp.json()
|
||||
@@ -36,5 +136,7 @@ async def proxy_llm(req: ProxyRequest):
|
||||
if 'localhost' in req.url or '127.0.0.1' in req.url:
|
||||
msg += "\nNếu app chạy trong Docker, localhost trỏ vào container, không ra host.\nHãy thay localhost bằng host.docker.internal hoặc IP bridge Docker (172.17.0.1)."
|
||||
raise HTTPException(status_code=502, detail=msg)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
+69
-73
@@ -8,12 +8,32 @@ from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from app.config import settings
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
from app.api.v1.projects import get_optional_user
|
||||
from app.models.user import get_db_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_AUDIO_UPLOAD_BYTES = 1024 * 1024 * 1024 # 1 GB
|
||||
|
||||
def _safe_file_id(file_id: str) -> str:
|
||||
"""Strip any path components from a client-supplied file id."""
|
||||
if not file_id:
|
||||
return ""
|
||||
return os.path.basename(file_id.replace("\\", "/"))
|
||||
|
||||
def _resolve_storage_path(file_id: str) -> str:
|
||||
"""Return the existing file path (processed first, then uploads) for a
|
||||
sanitized file id, or '' when not found."""
|
||||
fid = _safe_file_id(file_id)
|
||||
if not fid:
|
||||
return ""
|
||||
for d in (settings.PROCESSED_DIR, settings.UPLOADS_DIR):
|
||||
p = os.path.join(d, fid)
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
return ""
|
||||
|
||||
class EditRequest(BaseModel):
|
||||
file_id: str
|
||||
cut_start_ms: Optional[float] = None
|
||||
@@ -58,16 +78,32 @@ class PythonToolRequest(BaseModel):
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
ext = os.path.splitext(file.filename or "")[1]
|
||||
if not ext:
|
||||
ext = ".wav"
|
||||
file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
|
||||
file_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
# Stream upload in chunks with a hard size cap (avoids loading a multi-GB
|
||||
# WAV into RAM and bounds disk usage).
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
size = 0
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_AUDIO_UPLOAD_BYTES:
|
||||
f.close()
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(status_code=413, detail="File âm thanh quá lớn (giới hạn 1GB)")
|
||||
f.write(chunk)
|
||||
|
||||
# Save original filename as sidecar metadata
|
||||
import json
|
||||
@@ -90,15 +126,12 @@ async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict
|
||||
|
||||
@router.post("/edit")
|
||||
async def edit_audio(req: EditRequest):
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
# Use uploaded file if it exists, or look in processed if it was already edited
|
||||
if not os.path.exists(upload_path) and not os.path.exists(processed_path):
|
||||
if not _resolve_storage_path(req.file_id):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import edit_audio_task
|
||||
task = edit_audio_task.delay(req.dict())
|
||||
task = edit_audio_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id
|
||||
@@ -106,15 +139,10 @@ async def edit_audio(req: EditRequest):
|
||||
|
||||
@router.get("/download/{file_id}")
|
||||
async def download_audio(file_id: str):
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
return FileResponse(processed_path, media_type="audio/wav", filename=file_id)
|
||||
elif os.path.exists(upload_path):
|
||||
return FileResponse(upload_path, media_type="audio/wav", filename=file_id)
|
||||
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
path = _resolve_storage_path(file_id)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(path, media_type="audio/wav", filename=os.path.basename(path))
|
||||
|
||||
@router.get("/waveform/{file_id}")
|
||||
async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50, le=4000)):
|
||||
@@ -122,14 +150,8 @@ async def get_waveform(file_id: str, num_peaks: int = Query(default=800, ge=50,
|
||||
API endpoint vẽ Peak Waveform đồng bộ (Week 2).
|
||||
Trả về dữ liệu peak waveform cho hiển thị đồ thị sóng âm trên Frontend.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_peak_waveform
|
||||
@@ -140,14 +162,8 @@ async def get_waveform_rms(file_id: str, num_points: int = Query(default=800, ge
|
||||
"""
|
||||
API endpoint vẽ RMS Waveform (mượt hơn peak).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.core.dsp_utils import generate_rms_waveform
|
||||
@@ -159,26 +175,20 @@ async def analyze_audio_with_ai(req: AIAnalysisRequest):
|
||||
API endpoint phân tích cấu trúc khuôn nhạc bằng AI (Week 4).
|
||||
Gọi OpenAI Compatible API (DeepSeek/Ollama) để phân đoạn bố cục.
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
else:
|
||||
file_path = _resolve_storage_path(req.file_id)
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import analyze_ai_task
|
||||
task = analyze_ai_task.delay(
|
||||
file_id=req.file_id,
|
||||
file_id=_safe_file_id(req.file_id),
|
||||
api_base_url=req.api_base_url,
|
||||
model=req.model
|
||||
)
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/export")
|
||||
@@ -186,19 +196,13 @@ async def export_audio(req: ExportRequest):
|
||||
"""
|
||||
API endpoint xuất tệp âm thanh sang nhiều định dạng (WAV/MP3/OGG).
|
||||
"""
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
|
||||
if os.path.exists(processed_path):
|
||||
source_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
source_path = upload_path
|
||||
else:
|
||||
source_path = _resolve_storage_path(req.file_id)
|
||||
if not source_path:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
from app.tasks.worker import export_audio_task
|
||||
task = export_audio_task.delay(
|
||||
file_id=req.file_id,
|
||||
file_id=_safe_file_id(req.file_id),
|
||||
format=req.format,
|
||||
sample_rate=req.sample_rate,
|
||||
bit_depth=req.bit_depth
|
||||
@@ -206,29 +210,24 @@ async def export_audio(req: ExportRequest):
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"file_id": req.file_id
|
||||
"file_id": _safe_file_id(req.file_id)
|
||||
}
|
||||
|
||||
@router.post("/ai-scan")
|
||||
async def ai_scan_audio(req: AIScanRequest):
|
||||
async def ai_scan_audio(req: AIScanRequest, current_user: Optional[dict] = Depends(get_optional_user)):
|
||||
"""
|
||||
17_AI_SCAN.md Feature 1: AI Loop Scan & Automated Marker Labeling.
|
||||
Uses AIDSPEngine to find optimal recurring loop region with zero-crossing alignment.
|
||||
"""
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -252,6 +251,8 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
Executes raw binary sample slice at exact zero-crossing coordinates.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
@@ -259,16 +260,9 @@ async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends
|
||||
output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
|
||||
|
||||
file_path = None
|
||||
if req.file_id:
|
||||
upload_path = os.path.join(settings.UPLOADS_DIR, req.file_id)
|
||||
processed_path = os.path.join(settings.PROCESSED_DIR, req.file_id)
|
||||
if os.path.exists(processed_path):
|
||||
file_path = processed_path
|
||||
elif os.path.exists(upload_path):
|
||||
file_path = upload_path
|
||||
file_path = _resolve_storage_path(req.file_id) if req.file_id else ""
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
if file_path:
|
||||
data, sr = sf.read(file_path)
|
||||
if data.ndim > 1:
|
||||
data = data.T
|
||||
@@ -295,6 +289,8 @@ async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dic
|
||||
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
|
||||
"""
|
||||
user_id = current_user["user_id"] if current_user else "anonymous"
|
||||
if current_user:
|
||||
enforce_password_changed(current_user)
|
||||
from app.core.python_tools_engine import PythonToolsEngine
|
||||
from app.core.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
|
||||
+79
-14
@@ -1,10 +1,15 @@
|
||||
import uuid
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
import threading
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from app.models.user import get_db_connection
|
||||
from app.core.auth import hash_password, verify_password, create_token, decode_token, seed_admin
|
||||
from app.core.auth import (
|
||||
hash_password, verify_password, create_token, decode_token, seed_admin,
|
||||
COOKIE_NAME, X_AUTH_HEADER,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,10 +26,49 @@ class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None)):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
# ── Brute-force guard: in-memory per-IP failed-login limiter ──
|
||||
_LOGIN_FAILURES = {} # ip -> [timestamps]
|
||||
_LOGIN_LOCK = threading.Lock()
|
||||
MAX_LOGIN_ATTEMPTS = 10
|
||||
LOGIN_WINDOW_SEC = 900 # 15 min
|
||||
LOGIN_BLOCK_SEC = 900
|
||||
|
||||
def _check_login_ratelimit(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = [t for t in _LOGIN_FAILURES.get(ip, []) if now - t < LOGIN_WINDOW_SEC]
|
||||
if len(stamps) >= MAX_LOGIN_ATTEMPTS:
|
||||
raise HTTPException(status_code=429, detail="Quá nhiều lần đăng nhập thất bại. Vui lòng thử lại sau 15 phút.")
|
||||
_LOGIN_FAILURES[ip] = stamps
|
||||
|
||||
def _record_login_failure(ip: str):
|
||||
now = time.time()
|
||||
with _LOGIN_LOCK:
|
||||
stamps = _LOGIN_FAILURES.setdefault(ip, [])
|
||||
stamps.append(now)
|
||||
_LOGIN_FAILURES[ip] = [t for t in stamps if now - t < LOGIN_WINDOW_SEC]
|
||||
|
||||
def _record_login_success(ip: str):
|
||||
with _LOGIN_LOCK:
|
||||
_LOGIN_FAILURES.pop(ip, None)
|
||||
|
||||
def _set_auth_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
COOKIE_NAME, token,
|
||||
max_age=7 * 24 * 3600, httponly=True, samesite="lax",
|
||||
# path="/" (default); secure flag set by proxy when behind TLS
|
||||
)
|
||||
|
||||
def get_current_user(request: Request, authorization: Optional[str] = Header(None), x_auth_token: Optional[str] = Header(None)):
|
||||
token = None
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
token = authorization.split(" ")[1]
|
||||
elif x_auth_token:
|
||||
token = x_auth_token
|
||||
elif request.cookies.get(COOKIE_NAME):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Thiếu Token xác thực hoặc Token không hợp lệ")
|
||||
token = authorization.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Token đã hết hạn hoặc không hợp lệ")
|
||||
@@ -39,7 +83,10 @@ def enforce_password_changed(user: dict):
|
||||
)
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
async def login(req: LoginRequest, request: Request):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
_check_login_ratelimit(client_ip)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
@@ -65,14 +112,17 @@ async def login(req: LoginRequest):
|
||||
conn.close()
|
||||
|
||||
if not user or not user["is_active"]:
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
if not verify_password(password, user["hashed_password"]):
|
||||
_record_login_failure(client_ip)
|
||||
raise HTTPException(status_code=400, detail="Tài khoản hoặc mật khẩu không chính xác")
|
||||
|
||||
token = create_token(user["id"], user["username"], user["role"], user["must_change_password"])
|
||||
|
||||
return {
|
||||
_record_login_success(client_ip)
|
||||
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
@@ -81,13 +131,24 @@ async def login(req: LoginRequest):
|
||||
"role": user["role"],
|
||||
"must_change_password": bool(user["must_change_password"])
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
def _validate_password_strength(password: str):
|
||||
"""Minimal strength policy: >= 8 chars and not trivially common."""
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu phải có ít nhất 8 ký tự")
|
||||
lowered = password.lower()
|
||||
if lowered in ("admin123", "password", "12345678", "123456789", "qwerty123"):
|
||||
raise HTTPException(status_code=400, detail="Mật khẩu quá dễ đoán, vui lòng chọn mật khẩu khác")
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest):
|
||||
async def register(req: RegisterRequest, request: Request):
|
||||
username = req.username.strip()
|
||||
email = req.email.strip()
|
||||
password = req.password.strip()
|
||||
_validate_password_strength(password)
|
||||
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
@@ -115,7 +176,7 @@ async def register(req: RegisterRequest):
|
||||
conn.close()
|
||||
|
||||
token = create_token(user_id, username, "standard", False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"access_token": token,
|
||||
"user": {
|
||||
"id": user_id,
|
||||
@@ -124,7 +185,9 @@ async def register(req: RegisterRequest):
|
||||
"role": "standard",
|
||||
"must_change_password": False
|
||||
}
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, token)
|
||||
return resp
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
|
||||
@@ -153,10 +216,12 @@ async def change_password(req: ChangePasswordRequest, current_user: dict = Depen
|
||||
conn.close()
|
||||
|
||||
new_token = create_token(updated_user["id"], updated_user["username"], updated_user["role"], False)
|
||||
return {
|
||||
resp = JSONResponse({
|
||||
"message": "Đổi mật khẩu thành công!",
|
||||
"access_token": new_token
|
||||
}
|
||||
})
|
||||
_set_auth_cookie(resp, new_token)
|
||||
return resp
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_profile(current_user: dict = Depends(get_current_user)):
|
||||
|
||||
+6
-4
@@ -2,9 +2,11 @@ import os
|
||||
import platform
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.v1.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MEDIA_EXTS = {
|
||||
@@ -39,7 +41,7 @@ PSEUDO_FS_TYPES = {
|
||||
|
||||
|
||||
@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)."""
|
||||
system = platform.system()
|
||||
roots = []
|
||||
@@ -97,7 +99,7 @@ async def list_computer_roots():
|
||||
|
||||
|
||||
@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."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isdir(resolved):
|
||||
@@ -146,7 +148,7 @@ async def browse_directory(path: str = Query(...)):
|
||||
|
||||
|
||||
@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."""
|
||||
resolved = _safe_path(path)
|
||||
if not os.path.isfile(resolved):
|
||||
|
||||
@@ -54,7 +54,7 @@ async def mix_multitrack_session(req: MultitrackSessionRequest):
|
||||
|
||||
# Gửi task xuống Celery Worker
|
||||
from app.tasks.worker import mix_multitrack_task
|
||||
task = mix_multitrack_task.delay(req.dict())
|
||||
task = mix_multitrack_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
@@ -69,7 +69,7 @@ async def process_session(req: MultitrackSessionRequest):
|
||||
Xử lý từng clip, sau đó hòa âm tất cả tracks lại với nhau.
|
||||
"""
|
||||
from app.tasks.worker import process_multitrack_session_task
|
||||
task = process_multitrack_session_task.delay(req.dict())
|
||||
task = process_multitrack_session_task.delay(req.model_dump())
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
|
||||
+26
-5
@@ -9,7 +9,7 @@ from app.core.render_engine import PythonRenderEngine
|
||||
from app.core.soundfont_inspector import SoundFontInspector
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
from app.api.v1.auth import get_current_user
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -79,11 +79,23 @@ async def upload_soundfont(
|
||||
background_tasks: BackgroundTasks = None,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
|
||||
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if not PluginManager.validate_sf2_header(contents):
|
||||
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
|
||||
# 500MB; reading the whole body into RAM would OOM the server).
|
||||
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
|
||||
contents = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
contents.extend(chunk)
|
||||
if len(contents) > MAX_SF_UPLOAD_BYTES:
|
||||
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
|
||||
|
||||
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
|
||||
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
|
||||
|
||||
file_ext = os.path.splitext(file.filename)[1]
|
||||
@@ -139,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
||||
@router.get("/soundfonts/download/{sf_id}")
|
||||
async def download_soundfont_asset(sf_id: str):
|
||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
||||
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||
@@ -178,8 +194,13 @@ async def render_project(
|
||||
req: RenderRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
enforce_password_changed(current_user)
|
||||
engine = PythonRenderEngine()
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, req.output_filename or "render_output.wav")
|
||||
# Prevent path traversal: strip any directory components and force .wav.
|
||||
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
|
||||
if not safe_name.lower().endswith(".wav"):
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
|
||||
+31
-9
@@ -18,6 +18,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
return project_data
|
||||
|
||||
tracks = project_data.get("tracks", [])
|
||||
# Legacy format stores item start times in SECONDS; convert using the real
|
||||
# seconds-per-bar (old code hardcoded /4.0 which shifted every item's
|
||||
# position for any tempo other than the one where 1 bar = 4s).
|
||||
bpm_val = float(project_data.get("bpm", 120.0) or 120.0)
|
||||
seconds_per_bar = (60.0 / bpm_val) * 4
|
||||
upgraded_tracks = []
|
||||
for t in tracks:
|
||||
track_id = str(t.get("id", ""))
|
||||
@@ -33,8 +38,8 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
"id": c.get("id"),
|
||||
"name": c.get("name", "Audio Clip"),
|
||||
"type": "AUDIO_ITEM",
|
||||
"start_bar": c.get("startTime", 0.0) / 4.0,
|
||||
"duration_bars": 4.0,
|
||||
"start_bar": round(c.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||
"duration_bars": round((c.get("duration", 4.0) if c.get("duration") else 4.0) / seconds_per_bar, 6),
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {
|
||||
"audio_file_url": f"/static/audio/uploads/{t.get('serverFileId')}" if t.get("serverFileId") else "",
|
||||
@@ -49,11 +54,11 @@ def upgrade_project_json_if_needed(project_data: dict) -> dict:
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name", "MIDI Item"),
|
||||
"type": "MIDI_ITEM",
|
||||
"start_bar": m.get("startTime", 0.0) / 4.0,
|
||||
"duration_bars": m.get("duration", 4.0),
|
||||
"start_bar": round(m.get("startTime", 0.0) / seconds_per_bar, 6),
|
||||
"duration_bars": round((m.get("duration", 4.0) or 4.0) / seconds_per_bar, 6),
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {
|
||||
"total_buffer_bars": m.get("duration", 8.0),
|
||||
"total_buffer_bars": round((m.get("duration", 8.0) or 8.0) / seconds_per_bar, 6),
|
||||
"notes": m.get("notes", [])
|
||||
}
|
||||
})
|
||||
@@ -283,13 +288,30 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
|
||||
conn = get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
exists = cursor.fetchone()
|
||||
if not exists:
|
||||
cursor.execute("SELECT id, size_bytes FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
|
||||
existing = cursor.fetchone()
|
||||
if not existing:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
|
||||
|
||||
|
||||
new_size_bytes = len(validated_data_json.encode("utf-8"))
|
||||
|
||||
# Enforce storage quota (same rule as save_cloud_project — previously
|
||||
# update bypassed the quota entirely).
|
||||
cursor.execute("SELECT storage_limit_mb FROM user_quotas WHERE user_id = ?", (user_id,))
|
||||
quota_row = cursor.fetchone()
|
||||
storage_limit_mb = quota_row["storage_limit_mb"] if quota_row else 500
|
||||
cursor.execute("SELECT SUM(size_bytes) as total_used FROM projects WHERE user_id = ? AND is_temp = 0", (user_id,))
|
||||
used_row = cursor.fetchone()
|
||||
used_bytes = (used_row["total_used"] if used_row and used_row["total_used"] else 0) - (existing["size_bytes"] or 0)
|
||||
max_bytes = storage_limit_mb * 1024 * 1024
|
||||
if used_bytes + new_size_bytes > max_bytes:
|
||||
conn.close()
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Dung lượng dự án vượt quá hạn mức Quota ({storage_limit_mb}MB). Vui lòng dọn dẹp hoặc nâng cấp tài khoản."
|
||||
)
|
||||
|
||||
now = time.time()
|
||||
|
||||
cursor.execute("""
|
||||
|
||||
@@ -96,7 +96,7 @@ async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
||||
async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
configs = _load_ai_configs()
|
||||
configs[uid] = [p.dict() for p in req.providers]
|
||||
configs[uid] = [p.model_dump() for p in req.providers]
|
||||
_save_all(ai_configs=configs)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
+30
-1
@@ -10,7 +10,36 @@ from typing import Optional, Dict, Any
|
||||
from app.models.user import get_db_connection
|
||||
from app.config import settings
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
|
||||
COOKIE_NAME = "sf_token"
|
||||
X_AUTH_HEADER = "X-Auth-Token"
|
||||
|
||||
def _load_or_create_secret_key() -> str:
|
||||
"""Persistent random SECRET_KEY.
|
||||
|
||||
Priority: env SECRET_KEY > {STORAGE_DIR}/.secret_key (auto-generated on
|
||||
first run). Never falls back to a hardcoded value: a known secret lets
|
||||
anyone forge admin tokens.
|
||||
"""
|
||||
env_key = os.getenv("SECRET_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key
|
||||
key_file = os.path.join(settings.STORAGE_DIR, ".secret_key")
|
||||
try:
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
if os.path.exists(key_file):
|
||||
with open(key_file, "r") as f:
|
||||
key = f.read().strip()
|
||||
if len(key) >= 32:
|
||||
return key
|
||||
key = secrets.token_hex(32)
|
||||
with open(key_file, "w") as f:
|
||||
f.write(key)
|
||||
return key
|
||||
except Exception:
|
||||
# Last resort: ephemeral random key (all tokens invalid on restart).
|
||||
return secrets.token_hex(32)
|
||||
|
||||
SECRET_KEY = _load_or_create_secret_key()
|
||||
|
||||
def hash_password(password: str, salt: Optional[str] = None) -> str:
|
||||
"""
|
||||
|
||||
+43
-21
@@ -1,4 +1,4 @@
|
||||
import os, logging
|
||||
import os, logging, math
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import scipy.signal as signal
|
||||
@@ -87,12 +87,18 @@ class PythonRenderEngine:
|
||||
return url_or_id
|
||||
return url_or_id
|
||||
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int) -> np.ndarray:
|
||||
def render_session_container(self, session: dict, section_store: dict, bpm: float, time_sig_num: int, total_samples: int, _cache: dict = None) -> np.ndarray:
|
||||
session_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
# Solo semantics: when any track is soloed, only soloed tracks sound.
|
||||
tracks = session.get("tracks", [])
|
||||
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||
|
||||
_channel_counter = 0
|
||||
|
||||
for track in session.get("tracks", []):
|
||||
for track in tracks:
|
||||
if solo_ids and track.get("id") not in solo_ids:
|
||||
continue
|
||||
track_type = track.get("type", "AUDIO")
|
||||
track_buffer = np.zeros((2, total_samples), dtype=np.float32)
|
||||
|
||||
@@ -123,8 +129,17 @@ class PythonRenderEngine:
|
||||
try:
|
||||
audio_data, sr = sf.read(resolved_path, dtype='float32')
|
||||
if sr != self.sample_rate:
|
||||
# Resampling fallback if simple, otherwise skip
|
||||
pass
|
||||
# Proper resampling: previously a silent no-op that
|
||||
# played 48kHz audio at the wrong speed/pitch.
|
||||
from scipy.signal import resample_poly
|
||||
g = math.gcd(sr, self.sample_rate)
|
||||
audio_data = resample_poly(
|
||||
audio_data,
|
||||
up=self.sample_rate // g,
|
||||
down=sr // g,
|
||||
axis=-1,
|
||||
)
|
||||
sr = self.sample_rate
|
||||
|
||||
# Handle channel mapping (Mono/Stereo)
|
||||
if len(audio_data.shape) == 1:
|
||||
@@ -148,7 +163,7 @@ class PythonRenderEngine:
|
||||
if actual_len > 0:
|
||||
track_buffer[:, start_sample:write_end] += sliced_audio[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error reading audio file {resolved_path}: {e}")
|
||||
logger.warning("[RenderEngine] Error reading audio file %s: %s", resolved_path, e)
|
||||
|
||||
elif item_type == "MIDI_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
@@ -284,21 +299,27 @@ class PythonRenderEngine:
|
||||
actual_len = min(synth_buffer.shape[1], total_samples)
|
||||
track_buffer[:, :actual_len] += synth_buffer[:, :actual_len]
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Error rendering MIDI: {e}")
|
||||
logger.warning("[RenderEngine] Error rendering MIDI: %s", e)
|
||||
|
||||
elif item_type == "SECTION_ITEM":
|
||||
source_data = item.get("source_data", {})
|
||||
sec_id = source_data.get("referenced_section_id", "")
|
||||
if sec_id and sec_id in section_store:
|
||||
# Render nested section recursively
|
||||
sec_container = section_store[sec_id]
|
||||
sec_buffer = self.render_session_container(
|
||||
session=sec_container,
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
)
|
||||
# Render nested section recursively, cached per section id
|
||||
# so repeated section instances don't re-render every time.
|
||||
cache = _cache if _cache is not None else {}
|
||||
if sec_id in cache:
|
||||
sec_buffer = cache[sec_id]
|
||||
else:
|
||||
sec_buffer = self.render_session_container(
|
||||
session=section_store[sec_id],
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples,
|
||||
_cache=cache,
|
||||
)
|
||||
cache[sec_id] = sec_buffer
|
||||
|
||||
# Apply non-destructive crop/slicing on section buffer
|
||||
if offset_sample < total_samples:
|
||||
@@ -327,7 +348,7 @@ class PythonRenderEngine:
|
||||
board = Pedalboard([Chorus(rate_hz=1.5, depth=0.25)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Chorus failed: %s", e)
|
||||
else:
|
||||
# Fallback chorus using simple LFO delay modulation in scipy/numpy
|
||||
try:
|
||||
@@ -341,14 +362,14 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = track_buffer[ch, indices]
|
||||
track_buffer = dry + wet * 0.5
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Chorus failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
if HAS_PEDALBOARD:
|
||||
try:
|
||||
board = Pedalboard([Reverb(room_size=0.5, wet_level=0.4, dry_level=0.6)])
|
||||
track_buffer = board(track_buffer, sample_rate=self.sample_rate)
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Pedalboard Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Pedalboard Reverb failed: %s", e)
|
||||
else:
|
||||
# Fallback reverb using exponentially decaying noise room impulse response
|
||||
try:
|
||||
@@ -368,7 +389,7 @@ class PythonRenderEngine:
|
||||
wet[ch, :] = conv
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
print(f"[RenderEngine] Fallback Reverb failed: {e}")
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
# Process track volume
|
||||
if HAS_PEDALBOARD:
|
||||
@@ -410,7 +431,8 @@ class PythonRenderEngine:
|
||||
section_store=section_store,
|
||||
bpm=bpm,
|
||||
time_sig_num=time_sig_num,
|
||||
total_samples=total_samples
|
||||
total_samples=total_samples,
|
||||
_cache={},
|
||||
)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
|
||||
@@ -280,31 +280,37 @@ class SoundFontConverter:
|
||||
@staticmethod
|
||||
def _sf3_plays_audio(path: str) -> bool:
|
||||
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||
against shipping malformed SF3 files that silently play nothing)."""
|
||||
against shipping malformed SF3 files that silently play nothing).
|
||||
|
||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
||||
high-level Synth() class does not exist in this binding, so it is never
|
||||
used here.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
try:
|
||||
import fluidsynth
|
||||
import fluidsynth as _fs
|
||||
import numpy as np
|
||||
fl = fluidsynth.Synth()
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fl = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
h = fl.sfload(path)
|
||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
||||
if h < 0:
|
||||
return False
|
||||
fl.program_select(0, h, 0, 0)
|
||||
fl.noteon(0, 60, 100)
|
||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
||||
frames = 8820 # 0.2s
|
||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
||||
fluidsynth._fl.fluid_synth_write_float(
|
||||
fl.synth, frames, buf.ctypes.data, 0, 1,
|
||||
_fs.fluid_synth_write_float(
|
||||
_fl, frames, buf.ctypes.data, 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)))
|
||||
return rms > 1e-4
|
||||
finally:
|
||||
try:
|
||||
fl.delete()
|
||||
_fs.delete_fluid_synth(_fl)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
+59
-39
@@ -2,7 +2,7 @@
|
||||
import os
|
||||
import numpy as np
|
||||
import functools
|
||||
from ctypes import c_int, c_char_p, c_void_p
|
||||
from ctypes import c_char_p
|
||||
|
||||
def midi_note_to_freq(note_number: int) -> float:
|
||||
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
||||
@@ -110,7 +110,12 @@ def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/s
|
||||
return _PLUGIN_MANAGER_INSTANCE
|
||||
|
||||
def load_soundfont_cached(path: str):
|
||||
"""Return a cached FluidSynth instance for path, incrementing refcount."""
|
||||
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
||||
|
||||
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
||||
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
||||
do not exist in this binding, so they are never used here.
|
||||
"""
|
||||
global _FLUID_CACHE
|
||||
if not HAS_PYFLUIDSYNTH:
|
||||
return None
|
||||
@@ -119,10 +124,15 @@ def load_soundfont_cached(path: str):
|
||||
_FLUID_CACHE[path] = (fl, ref + 1)
|
||||
return fl
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.FluidSynth(sample_rate=44100, gain=0.5)
|
||||
font_id = fl.sfload(path)
|
||||
fl.program_select(0, font_id, 0, 0)
|
||||
import fluidsynth as _fs
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
||||
fl = _fs.new_fluid_synth(_settings)
|
||||
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
||||
if font_id < 0:
|
||||
_fs.delete_fluid_synth(fl)
|
||||
return None
|
||||
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
||||
_FLUID_CACHE[path] = (fl, 1)
|
||||
return fl
|
||||
except Exception:
|
||||
@@ -136,7 +146,8 @@ def release_soundfont(path: str):
|
||||
fl, ref = _FLUID_CACHE[path]
|
||||
if ref <= 1:
|
||||
try:
|
||||
fl.delete()
|
||||
import fluidsynth as _fs
|
||||
_fs.delete_fluid_synth(fl)
|
||||
except Exception:
|
||||
pass
|
||||
del _FLUID_CACHE[path]
|
||||
@@ -245,38 +256,47 @@ class PluginManager:
|
||||
if base == sf_id or base == sf_id.replace("sf_", ""):
|
||||
path = os.path.join(d, f)
|
||||
try:
|
||||
import fluidsynth
|
||||
fl = fluidsynth.Synth()
|
||||
fid = fl.sfload(path)
|
||||
if fid < 0:
|
||||
fl.delete()
|
||||
continue
|
||||
presets = []
|
||||
_fl = fluidsynth._fl
|
||||
_fl.fluid_synth_get_sfont_by_id.restype = c_void_p
|
||||
_fl.fluid_preset_get_name.restype = c_char_p
|
||||
_fl.fluid_sfont_get_preset.restype = c_void_p
|
||||
sfont_ptr = _fl.fluid_synth_get_sfont_by_id(c_void_p(fl.synth), c_int(fid))
|
||||
if sfont_ptr:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = fluidsynth.fluid_sfont_get_preset(sfont_ptr, c_int(bank), c_int(prog_num))
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
name_ptr = fluidsynth.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
name_val = c_char_p(name_ptr).value
|
||||
if name_val:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": name_val.decode("utf-8", errors="replace")
|
||||
})
|
||||
fl.delete()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
import fluidsynth as _fs
|
||||
# Low-level CFFI API (same as render_engine); never use
|
||||
# the high-level Synth() class that this binding lacks.
|
||||
_settings = _fs.new_fluid_settings()
|
||||
_synth = _fs.new_fluid_synth(_settings)
|
||||
try:
|
||||
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
||||
if fid < 0:
|
||||
continue
|
||||
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
||||
presets = []
|
||||
if sfont:
|
||||
for bank in range(0, 2):
|
||||
for prog_num in range(0, 128):
|
||||
try:
|
||||
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
||||
except Exception:
|
||||
break
|
||||
if preset:
|
||||
try:
|
||||
name_ptr = _fs.fluid_preset_get_name(preset)
|
||||
if name_ptr:
|
||||
if hasattr(_fs, "ffi"):
|
||||
raw = _fs.ffi.string(name_ptr)
|
||||
else:
|
||||
raw = c_char_p(name_ptr).value
|
||||
if raw:
|
||||
presets.append({
|
||||
"bank": bank,
|
||||
"program": prog_num,
|
||||
"name": raw.decode("utf-8", errors="replace")
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
||||
return presets[:256]
|
||||
finally:
|
||||
try:
|
||||
_fs.delete_fluid_synth(_synth)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
import traceback; traceback.print_exc()
|
||||
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
||||
|
||||
+30
-23
@@ -1,8 +1,11 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from app.config import settings
|
||||
from app.api.v1.audio import router as audio_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
@@ -22,16 +25,32 @@ from app.core.soundfont_scanner import SoundFontAutoScanner
|
||||
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
|
||||
os.makedirs(settings.PROCESSED_DIR, exist_ok=True)
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine")
|
||||
_SF_SCANNER_STOP = None
|
||||
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
seed_admin()
|
||||
scanner = SoundFontAutoScanner()
|
||||
global _SF_SCANNER_STOP
|
||||
_SF_SCANNER_STOP = scanner.start_background(interval=30)
|
||||
yield
|
||||
# Shutdown
|
||||
if _SF_SCANNER_STOP is not None:
|
||||
_SF_SCANNER_STOP.set()
|
||||
|
||||
|
||||
app = FastAPI(title="SonicForge API Engine", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
|
||||
# Auth is token/cookie based (no cookies required for CORS), so credentials are
|
||||
# disabled — "*" + allow_credentials=True is rejected by browsers anyway.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -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(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)
|
||||
async def get_index():
|
||||
@@ -78,14 +81,18 @@ async def get_index():
|
||||
if not os.path.exists(index_path):
|
||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||
with open(index_path, "r", encoding="utf-8") as file:
|
||||
return HTMLResponse(content=file.read(), status_code=200)
|
||||
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/favicon.svg")
|
||||
async def get_favicon():
|
||||
import os
|
||||
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
|
||||
if os.path.exists(favicon_path):
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||
return HTMLResponse(content="", status_code=404)
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"type": { "type": "string", "enum": ["AUDIO", "MIDI", "SECTION"] },
|
||||
"color": { "type": ["string", "null"], "default": null },
|
||||
"volume_db": { "type": "number", "default": 0.0 },
|
||||
"pan": { "type": "number", "minimum": -1.0, "maximum": 1.0, "default": 0.0 },
|
||||
"mute": { "type": "boolean", "default": false },
|
||||
|
||||
+17
-1
@@ -5,12 +5,18 @@ import time
|
||||
from typing import Optional, Dict, Any, List
|
||||
from app.config import settings
|
||||
|
||||
DB_PATH = os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
# Default DB lives in storage/; tests override via SONICFORGE_DB_PATH so the
|
||||
# dev database is never touched by the test suite.
|
||||
DB_PATH = os.getenv("SONICFORGE_DB_PATH") or os.path.join(settings.STORAGE_DIR, "sonicforge.db")
|
||||
|
||||
def get_db_connection():
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# WAL improves concurrent read/write; FK enforcement makes quota/backup
|
||||
# cleanup consistent when users are deleted.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
@@ -89,6 +95,16 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
|
||||
# Placeholder user for anonymous autosave: projects are saved with
|
||||
# user_id='anonymous' when no token is present, so the FK must resolve.
|
||||
cursor.execute("SELECT id FROM users WHERE id = 'anonymous'")
|
||||
if not cursor.fetchone():
|
||||
import secrets as _secrets
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
|
||||
VALUES ('anonymous', 'anonymous', 'anonymous@local', ?, 'standard', 0, ?, 0)
|
||||
""", (_secrets.token_hex(32), time.time()))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
+4341
-529
File diff suppressed because it is too large
Load Diff
+710
-113
File diff suppressed because one or more lines are too long
@@ -121,6 +121,57 @@ const AIGateway = (function() {
|
||||
}
|
||||
};
|
||||
|
||||
// ai_midi_rearrange_specification.md §3 — Function Tool Schema hỗ trợ 2 mode:
|
||||
// SIMILAR_VARIATION (biến tấu cùng độ dài) / EXTEND_CONTINUATION (viết tiếp
|
||||
// các bar sau). AI trả gói dữ liệu có vị trí target trên Timeline.
|
||||
const REARRANGE_EXTEND_TOOL_SPEC = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'rearrange_or_extend_midi_melody',
|
||||
description: 'Analyzes source MIDI melody data and returns either a variation (Variation) or continuation (Extend) based on user instructions.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['SIMILAR_VARIATION', 'EXTEND_CONTINUATION'],
|
||||
description: "Mode: 'SIMILAR_VARIATION' (new arrangement of equal length) or 'EXTEND_CONTINUATION' (writes subsequent bars)."
|
||||
},
|
||||
composition_title: {
|
||||
type: 'string',
|
||||
description: 'Short title describing the new melody style (e.g., Jazz Swing Variation, Epic Extension Part 2)'
|
||||
},
|
||||
target_start_bar: {
|
||||
type: 'number',
|
||||
description: 'Starting bar number for the generated notes on the Timeline'
|
||||
},
|
||||
target_duration_bars: {
|
||||
type: 'number',
|
||||
description: 'Total bar duration covered by the generated sequence'
|
||||
},
|
||||
soundfont_id: { type: 'string', default: 'generaluser_gs' },
|
||||
soundfont_bank: { type: 'integer', default: 0 },
|
||||
soundfont_program: { type: 'integer', default: 0 },
|
||||
generated_notes: {
|
||||
type: 'array',
|
||||
description: 'Array of AI-generated MIDI notes.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pitch: { type: 'integer', minimum: 0, maximum: 127 },
|
||||
start_beat: { type: 'number', description: 'Starting beat position relative to beat 0.0 of the generated item' },
|
||||
duration_beats: { type: 'number', minimum: 0.1 },
|
||||
velocity: { type: 'number', minimum: 0.0, maximum: 1.0 }
|
||||
},
|
||||
required: ['pitch', 'start_beat', 'duration_beats', 'velocity']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['mode', 'composition_title', 'target_start_bar', 'target_duration_bars', 'generated_notes']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const REARRANGE_SCENARIOS = [
|
||||
{
|
||||
id: 'arpeggio',
|
||||
@@ -275,7 +326,10 @@ ${rules.join('\n')}` },
|
||||
} else {
|
||||
response = await fetch(`${origin}/api/v1/ai/proxy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(localStorage.getItem('sonic_token') ? { 'X-Auth-Token': localStorage.getItem('sonic_token') } : {})
|
||||
},
|
||||
body: JSON.stringify({ url, headers, body })
|
||||
});
|
||||
}
|
||||
@@ -453,6 +507,7 @@ Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó
|
||||
return {
|
||||
DEFAULT_TOOLS,
|
||||
REARRANGE_TOOL_SPEC,
|
||||
REARRANGE_EXTEND_TOOL_SPEC,
|
||||
REARRANGE_SCENARIOS,
|
||||
detectRearrangeScenario,
|
||||
buildRearrangeMessage,
|
||||
|
||||
@@ -22,14 +22,21 @@
|
||||
let _loadedFonts = {};
|
||||
let _activeOscillators = {};
|
||||
let _gainNode = null;
|
||||
let _pendingOutputDestination = null;
|
||||
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||
let _scheduledNotes = [];
|
||||
let _loadPromises = {};
|
||||
let _sfloadSeq = 0;
|
||||
|
||||
const getCtx = function () {
|
||||
if (_audioCtx) {
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return _audioCtx;
|
||||
}
|
||||
@@ -38,7 +45,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = ctx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
|
||||
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination);
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -51,7 +59,8 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = window.__sharedAudioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.__sharedAudioCtx.destination);
|
||||
_outputDestination = _pendingOutputDestination || window.__sharedAudioCtx.destination;
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
return window.__sharedAudioCtx;
|
||||
};
|
||||
@@ -59,6 +68,36 @@
|
||||
const SonicSF = {
|
||||
loadedFonts: _loadedFonts,
|
||||
|
||||
// Route the shared FluidSynth output through a per-track node (e.g. the
|
||||
// track's gainNode) so the track's FX chain / fader / pan affect the
|
||||
// soundfont instrument. Pass null to restore the default master-bus route.
|
||||
setOutputDestination: function (node) {
|
||||
try {
|
||||
if (_gainNode) {
|
||||
const dest = node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination));
|
||||
// DEDUPE: đích không đổi → KHÔNG disconnect/reconnect.
|
||||
// Swap dư giữa dòng notes đang phát (applyAllTrackMuteSolo →
|
||||
// updateSfRouting gọi lại cùng đích sfEntry sau noteon đầu)
|
||||
// làm ScriptProcessor xuất buffer uninitialized → NaN →
|
||||
// 11 biquad "state is bad" → CÂM (mọi log: state-bad nổ
|
||||
// ngay sau setOutputDestination lần 2).
|
||||
if (dest === _outputDestination) return;
|
||||
_gainNode.disconnect();
|
||||
_gainNode.connect(dest);
|
||||
_outputDestination = dest;
|
||||
console.log('[SonicSF] setOutputDestination to:', node ? 'track node (sfEntry)' : 'masterBus.input');
|
||||
} else {
|
||||
_pendingOutputDestination = node || null;
|
||||
console.log('[SonicSF] setOutputDestination pending:', node ? 'track node (sfEntry)' : 'null');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[SonicSF] setOutputDestination error:', e);
|
||||
}
|
||||
},
|
||||
getOutputNode: function () {
|
||||
return _gainNode;
|
||||
},
|
||||
|
||||
init: async function (audioContext) {
|
||||
if (_initialized && _fluidModule) return;
|
||||
if (_initPromise) return _initPromise;
|
||||
@@ -79,19 +118,27 @@
|
||||
if (!_gainNode) {
|
||||
_gainNode = _audioCtx.createGain();
|
||||
_gainNode.gain.value = 0.3;
|
||||
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
_outputDestination = _pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination);
|
||||
_gainNode.connect(_outputDestination);
|
||||
}
|
||||
|
||||
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
|
||||
|
||||
// ── Renderer selection ──
|
||||
// ScriptProcessor is the default and FINAL choice: it is
|
||||
// pull-based (onaudioprocess is invoked by the audio thread),
|
||||
// so it cannot starve when the main thread is busy (font
|
||||
// loading, WASM decode, UI). The AudioWorklet push model
|
||||
// (setInterval on the main thread) starves under load and
|
||||
// produces SILENCE on this machine — repeatedly confirmed.
|
||||
// The deprecation console warning is purely cosmetic.
|
||||
var _useScriptNode = true;
|
||||
try {
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js');
|
||||
console.log("[SonicSF] Worklet registered OK");
|
||||
await _audioCtx.audioWorklet.addModule('/static/js/worklets/fluidsynth-bridge.js?v=202608031240');
|
||||
console.log("[SonicSF] Worklet registered OK (unused)");
|
||||
} catch (e) {
|
||||
console.warn("[SonicSF] Worklet reg failed:", e);
|
||||
}
|
||||
console.log("[SonicSF] Using ScriptProcessorNode (forced for debug)");
|
||||
|
||||
console.log("[SonicSF] Initializing FluidSynth WASM Engine...");
|
||||
var TOTAL_MEMORY = 256 * 1024 * 1024;
|
||||
@@ -104,6 +151,11 @@
|
||||
},
|
||||
TOTAL_MEMORY: TOTAL_MEMORY,
|
||||
printErr: function (msg) {
|
||||
// "No preset found on channel" is FluidSynth's
|
||||
// expected notice when a soundfont simply has no
|
||||
// preset for a bank (e.g. bank 128 on a melodic-only
|
||||
// font) — the note is just silent, not an error.
|
||||
if (msg && (msg.indexOf('No preset found on channel') !== -1 || msg.indexOf('There is no preset with bank number') !== -1)) return;
|
||||
console.warn('[FluidSynth:err]', msg);
|
||||
}
|
||||
});
|
||||
@@ -132,7 +184,15 @@
|
||||
|
||||
if (!_useScriptNode) {
|
||||
try {
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge');
|
||||
// Force stereo output regardless of the device's
|
||||
// channel count — FluidSynth renders stereo, and a
|
||||
// mono output would crash the worklet (out[1] undefined).
|
||||
_workletNode = new AudioWorkletNode(_audioCtx, 'fluidsynth-bridge', {
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
channelCount: 2,
|
||||
channelCountMode: 'explicit'
|
||||
});
|
||||
_workletNode.connect(_gainNode);
|
||||
console.log("[SonicSF] AudioWorklet node connected via gain");
|
||||
_startRenderLoop();
|
||||
@@ -146,17 +206,31 @@
|
||||
var spn = _audioCtx.createScriptProcessor(spBufSz, 0, 2);
|
||||
var lp = _fluidModule._malloc(spBufSz * 4);
|
||||
var rp = _fluidModule._malloc(spBufSz * 4);
|
||||
// Heap WASM có thể realloc khi load SoundFont lớn (SGM-V2.01
|
||||
// ~300MB) → lp/rp DANGLE → đọc vùng nhớ đã free → NaN/garbage
|
||||
// → master chain "state is bad" → CÂM + stuck. Theo dõi
|
||||
// buffer + re-malloc khi đổi.
|
||||
var _heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||
spn.onaudioprocess = function (e) {
|
||||
var left = e.outputBuffer.getChannelData(0);
|
||||
var right = e.outputBuffer.getChannelData(1);
|
||||
var sz = left.length;
|
||||
try {
|
||||
if (_fluidModule.HEAPU8.buffer !== _heapBufRef) {
|
||||
try { _fluidModule._free(lp); _fluidModule._free(rp); } catch (er2) {}
|
||||
lp = _fluidModule._malloc(sz * 4);
|
||||
rp = _fluidModule._malloc(sz * 4);
|
||||
_heapBufRef = _fluidModule.HEAPU8.buffer;
|
||||
}
|
||||
_fluidModule._fluid_synth_write_float(_synthPtr, sz, lp, 0, 1, rp, 0, 1);
|
||||
var hf = _fluidModule.HEAPF32;
|
||||
var lpb = lp >> 2, rpb = rp >> 2;
|
||||
for (var si = 0; si < sz; si++) {
|
||||
left[si] = hf[lpb + si];
|
||||
right[si] = hf[rpb + si];
|
||||
// NaN sweep: mẫu NaN/Inf → 0 (chain biquad
|
||||
// KHÔNG BAO GIỜ được nhận NaN → không state-bad).
|
||||
var L = hf[lpb + si], R = hf[rpb + si];
|
||||
left[si] = isFinite(L) ? L : 0;
|
||||
right[si] = isFinite(R) ? R : 0;
|
||||
}
|
||||
} catch (er) {}
|
||||
};
|
||||
@@ -186,11 +260,16 @@
|
||||
},
|
||||
|
||||
_tryLoadSFL: function (buf, ext) {
|
||||
var fname = '/' + ext + '_' + Date.now();
|
||||
var fname = '/' + ext + '_' + (++_sfloadSeq) + '_' + Date.now();
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
_fluidModule.FS.writeFile(fname, new Uint8Array(buf));
|
||||
var cPath = this._allocCStr(fname);
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 1);
|
||||
// reset_presets = 0: loading a NEW soundfont must NOT reset the
|
||||
// presets already selected on other channels. With 1, FluidSynth
|
||||
// re-points every channel to the new font's preset 0, so loading a
|
||||
// second instrument silently changes the first one's sound
|
||||
// (decay/loop envelope…).
|
||||
var handle = _fluidModule._fluid_synth_sfload(_synthPtr, cPath, 0);
|
||||
_fluidModule._free(cPath);
|
||||
try { _fluidModule.FS.unlink(fname); } catch (e) {}
|
||||
return handle;
|
||||
@@ -203,7 +282,31 @@
|
||||
_currentSfId = sfId;
|
||||
return true;
|
||||
}
|
||||
// Deduplicate concurrent loads: rapid key presses (or several armed
|
||||
// tracks) all call loadSoundFont for the same font before the first
|
||||
// load resolves. Without this, the same soundfont is sfload'd several
|
||||
// times (handles 1,2,3,4…) — wasting the 256MB WASM heap and stalling
|
||||
// notes until each load finishes (audible lag, then silence).
|
||||
if (!_loadPromises[sfId]) {
|
||||
_loadPromises[sfId] = this._doLoadSoundFont(sfId).then(function (ok) {
|
||||
// Do NOT cache failures: a transient error (network hiccup,
|
||||
// memory pressure) must not permanently kill the instrument —
|
||||
// the next note retries the load and recovers.
|
||||
if (!ok) delete _loadPromises[sfId];
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
return _loadPromises[sfId];
|
||||
},
|
||||
|
||||
_doLoadSoundFont: async function (sfId) {
|
||||
try {
|
||||
// KHÔNG unload SF cũ khi sfload SF mới: unload làm handle cũ
|
||||
// thành rác trong khi channel state vẫn trỏ tới → program_select
|
||||
// bị skip (progAlreadySet) → noteon trên handle đã unload →
|
||||
// "Instrument not found ... substituted prog 0". Heap 256MB đủ
|
||||
// cho vài SF (SGM + latin = 2 handle — log OK). SF cũ khi cần
|
||||
// lại chỉ được sfload lại nếu map bị xóa (không xảy ra ở đây).
|
||||
var cache = window.SonicSFStorage;
|
||||
var buf = cache ? await cache.getBuffer(sfId) : null;
|
||||
if (buf) {
|
||||
@@ -224,8 +327,15 @@
|
||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||
return false;
|
||||
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||
// serve qua /soundfonts/{f} — catalog default-soundfonts).
|
||||
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
|
||||
var resp2 = await fetch(url2);
|
||||
if (!resp2.ok) {
|
||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||
return false;
|
||||
}
|
||||
resp = resp2;
|
||||
}
|
||||
buf = await resp.arrayBuffer();
|
||||
if (cache) await cache.saveBuffer(sfId, buf);
|
||||
@@ -258,16 +368,16 @@
|
||||
if (!ok) return;
|
||||
}
|
||||
var engKey = (sfId || '') + ':' + bank + ':' + program;
|
||||
if (!_engineChMap[engKey]) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (channel === undefined || channel === null) {
|
||||
if (!_engineChMap[engKey]) {
|
||||
var allocCh = this.allocateChannel(bank);
|
||||
_engineChMap[engKey] = allocCh;
|
||||
channel = allocCh;
|
||||
} else {
|
||||
_engineChMap[engKey] = channel;
|
||||
channel = _engineChMap[engKey];
|
||||
}
|
||||
} else {
|
||||
channel = _engineChMap[engKey];
|
||||
} else if (!_engineChMap[engKey]) {
|
||||
_engineChMap[engKey] = channel;
|
||||
}
|
||||
var sfHandle = _sfHandleMap.get(sfId);
|
||||
if (sfHandle !== undefined) {
|
||||
@@ -384,10 +494,11 @@
|
||||
},
|
||||
|
||||
_playNoteFluid: function (note, velocity, durationMs, startTime, program, channel, synthEngine) {
|
||||
var midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60));
|
||||
var midiVel = Math.min(127, Math.max(1, Math.floor(
|
||||
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
|
||||
)));
|
||||
var parsedPitch = parseInt(note);
|
||||
var midiPitch = isNaN(parsedPitch) ? 60 : Math.min(127, Math.max(0, parsedPitch));
|
||||
var rawVel = (typeof velocity === 'number' && isFinite(velocity)) ? (velocity > 1 ? velocity : velocity * 127) : 100;
|
||||
if (isNaN(rawVel)) rawVel = 100;
|
||||
var midiVel = Math.min(127, Math.max(1, Math.floor(rawVel)));
|
||||
var _origChannel = channel;
|
||||
var usedBank = 0, usedProg = 0;
|
||||
if (synthEngine) {
|
||||
@@ -420,27 +531,88 @@
|
||||
var self = this;
|
||||
var doNote = function () {
|
||||
try {
|
||||
var finalBank = usedBank;
|
||||
var finalProg = usedProg;
|
||||
var finalBank = parseInt(usedBank);
|
||||
if (isNaN(finalBank) || !isFinite(finalBank)) finalBank = 0;
|
||||
var finalProg = parseInt(usedProg);
|
||||
if (isNaN(finalProg) || !isFinite(finalProg)) finalProg = 0;
|
||||
var finalSfId = synthEngine ? synthEngine.soundfont_id : undefined;
|
||||
if (_channels[ch] && _channels[ch].program !== undefined) {
|
||||
finalBank = _channels[ch].bank;
|
||||
finalProg = _channels[ch].program;
|
||||
if (_channels[ch].sfId !== undefined) {
|
||||
finalSfId = _channels[ch].sfId;
|
||||
var cachedCh = _channels[ch];
|
||||
// The note's own synth engine (track instrument) is
|
||||
// authoritative. Channel state is only a cache: it must never
|
||||
// mask the track's instrument, otherwise multi-track ARM or a
|
||||
// re-picked instrument plays the wrong soundfont. Without an
|
||||
// engine, fall back to the soundfont configured on the channel.
|
||||
if (!synthEngine && cachedCh && cachedCh.sfId !== undefined) {
|
||||
finalBank = parseInt(cachedCh.bank) || 0;
|
||||
finalProg = parseInt(cachedCh.program) || 0;
|
||||
finalSfId = cachedCh.sfId;
|
||||
}
|
||||
// Ensure the soundfont is actually loaded before the note plays.
|
||||
// Quick instrument pick on a track does not pre-load it, so load
|
||||
// lazily here and retry the note once the font is ready.
|
||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
|
||||
// — note chạy thẳng fallback để CÓ ÂM.
|
||||
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
|
||||
if (Date.now() - _lastFail < 10000) {
|
||||
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||
if (ok) {
|
||||
doNote();
|
||||
} else {
|
||||
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||
_sfLoadFailAt[finalSfId] = Date.now();
|
||||
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Program change at note time, not call time — ensures correct
|
||||
// instrument for each item regardless of processing order.
|
||||
// Skip if the channel already has this exact instrument (avoids
|
||||
// per-note soundfont reloads that cause audible crackle/glitches).
|
||||
var cachedCh = _channels[ch];
|
||||
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) {
|
||||
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
|
||||
console.log('[SonicSF] selectProgram for channel:', ch, 'sfHandle:', sfHandle, 'bank:', finalBank, 'prog:', finalProg);
|
||||
if (sfHandle !== undefined) {
|
||||
try {
|
||||
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||
// Percussion (bank 128): tìm preset HỢP LỆ trong
|
||||
// font — quét bank 128 + bank 0 (0-127) MỘT LẦN,
|
||||
// cache theo sfId. Trước đây chỉ thử 4 preset cố
|
||||
// định → font không có → cache channel = (128,0)
|
||||
// INVALID → note sau skip re-select (progAlreadySet)
|
||||
// → noteon preset rỗng = CÂM ("1 âm đầu rồi câm").
|
||||
if (finalBank === 128) {
|
||||
var _vKey = finalSfId || ('h' + sfHandle);
|
||||
if (_validPercCache[_vKey] === undefined) {
|
||||
var _found = null;
|
||||
for (var _b = 0; _b < 2 && !_found; _b++) {
|
||||
var _bk = _b === 0 ? 128 : 0;
|
||||
for (var _p = 0; _p < 128 && !_found; _p++) {
|
||||
try {
|
||||
if (_fluidModule._fluid_synth_program_select(_synthPtr, 9, sfHandle, _bk, _p) === 0) {
|
||||
_found = [_bk, _p];
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
_validPercCache[_vKey] = _found;
|
||||
}
|
||||
if (_validPercCache[_vKey]) {
|
||||
finalBank = _validPercCache[_vKey][0];
|
||||
finalProg = _validPercCache[_vKey][1];
|
||||
}
|
||||
}
|
||||
var _selRet = _fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, finalBank, finalProg);
|
||||
} catch (e) {}
|
||||
} else {
|
||||
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
|
||||
@@ -451,6 +623,7 @@
|
||||
_channels[ch].program = finalProg;
|
||||
_channels[ch].sfId = finalSfId;
|
||||
}
|
||||
console.log('[SonicSF] noteon channel:', ch, 'pitch:', midiPitch, 'vel:', midiVel, 'sfId:', finalSfId);
|
||||
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
|
||||
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
|
||||
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
|
||||
@@ -481,6 +654,31 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Hủy mọi note-on được schedule (tương lai) + note-off mọi notes đang
|
||||
// ngân — gọi khi STOP/PAUSE để hết "âm thanh bị stuck" (note-on chưa
|
||||
// bắn vẫn bắn sau khi dừng; notes durationMs>=60000 không có note-off
|
||||
// tự động → ngân vô hạn → VU master nhảy dù không play).
|
||||
panic: function () {
|
||||
_scheduledNotes.forEach(function (sn) { if (sn.on) { clearTimeout(sn.on); sn.on = null; } });
|
||||
_scheduledNotes = [];
|
||||
if (_initialized && _fluidModule) {
|
||||
// noteoff TỪNG note đang ngân (binding _fluid_synth_noteoff chắc
|
||||
// chắn tồn tại — đã dùng cho duration hết) — all_notes_off có
|
||||
// thể không có trong WASM exports (catch nuốt → notes kẹt).
|
||||
Object.keys(_activeNotes).forEach(function (key) {
|
||||
var parts = key.split(':');
|
||||
var pitch = parseInt(parts[1], 10);
|
||||
(_activeNotes[key] || []).forEach(function (ch) {
|
||||
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||
});
|
||||
});
|
||||
try {
|
||||
for (var c = 0; c < 16; c++) _fluidModule._fluid_synth_all_notes_off(_synthPtr, c);
|
||||
} catch (e) {}
|
||||
}
|
||||
_activeNotes = {};
|
||||
},
|
||||
|
||||
_playNoteFallback: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
|
||||
var ctx = getCtx();
|
||||
var freq = 440 * Math.pow(2, (note - 69) / 12);
|
||||
@@ -497,7 +695,11 @@
|
||||
var oscType = 'triangle';
|
||||
var attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25;
|
||||
var prog = program !== undefined ? parseInt(program) : 0;
|
||||
if (channel !== undefined && channel >= 0 && channel < 16) {
|
||||
// CHỈ dùng cache channel khi KHÔNG có program/synthEngine được
|
||||
// truyền — trước đây override program của track bằng cache channel
|
||||
// (bị track khác cùng channel ghi đè → preview note vẽ mới mang
|
||||
// nhạc cụ của track TRƯỚC).
|
||||
if (program === undefined && channel !== undefined && channel >= 0 && channel < 16) {
|
||||
prog = _channels[channel].program || prog;
|
||||
}
|
||||
if (prog >= 0 && prog <= 7) { oscType = 'sine'; decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; }
|
||||
@@ -515,7 +717,8 @@
|
||||
osc.frequency.setValueAtTime(freq, 0);
|
||||
var startAt = startTime !== undefined ? startTime : ctx.currentTime;
|
||||
var durSec = durationMs / 1000;
|
||||
var vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||
var vel = (typeof velocity === 'number' && isFinite(velocity) && !isNaN(velocity)) ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
|
||||
if (isNaN(vel)) vel = 0.8;
|
||||
var targetGain = vel * volFactor;
|
||||
noteGain.gain.setValueAtTime(0, startAt);
|
||||
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
|
||||
@@ -536,6 +739,15 @@
|
||||
|
||||
stopAll: function () {
|
||||
if (_initialized && _fluidModule) {
|
||||
// noteoff từng note đang ngân (binding chắc chắn tồn tại) —
|
||||
// phòng all_notes_off không có trong WASM exports.
|
||||
Object.keys(_activeNotes).forEach(function (key) {
|
||||
var parts = key.split(':');
|
||||
var pitch = parseInt(parts[1], 10);
|
||||
(_activeNotes[key] || []).forEach(function (ch) {
|
||||
try { _fluidModule._fluid_synth_noteoff(_synthPtr, ch, pitch); } catch (e) {}
|
||||
});
|
||||
});
|
||||
for (var ch = 0; ch < 16; ch++) {
|
||||
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
|
||||
}
|
||||
@@ -554,6 +766,7 @@
|
||||
} catch (e) {}
|
||||
});
|
||||
Object.keys(_activeOscillators).forEach(function (k) { delete _activeOscillators[k]; });
|
||||
_activeNotes = {};
|
||||
},
|
||||
|
||||
saveToIndexedDB: async function (name, arrayBuffer) {
|
||||
|
||||
@@ -60,11 +60,13 @@
|
||||
}
|
||||
|
||||
let autoSaveTimer = null;
|
||||
let lastGetProjectStateCallback = null;
|
||||
function scheduleTempAutoSave(getProjectStateCallback) {
|
||||
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
|
||||
if (autoSaveTimer) clearTimeout(autoSaveTimer);
|
||||
autoSaveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const state = getProjectStateCallback();
|
||||
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||
if (!state || (!state.tracks && !state.main_session)) return;
|
||||
const dataJson = JSON.stringify(state);
|
||||
localStorage.setItem('sonic_temp_project', dataJson);
|
||||
@@ -76,10 +78,26 @@
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
// Lưu NGAY (bỏ debounce 2s) — dùng cho thay đổi cần bền vững tức thì (đổi màu track)
|
||||
async function flushTempAutoSave() {
|
||||
if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
|
||||
try {
|
||||
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
|
||||
if (!state || (!state.tracks && !state.main_session)) return;
|
||||
const dataJson = JSON.stringify(state);
|
||||
localStorage.setItem('sonic_temp_project', dataJson);
|
||||
if (window.SonicAPI && localStorage.getItem('sonic_token')) {
|
||||
await window.SonicAPI.saveTempProject(dataJson).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Flush temp project warning:", e);
|
||||
}
|
||||
}
|
||||
|
||||
window.SonicStorage = {
|
||||
exportProjectToSFS,
|
||||
importProjectFromSFSFile,
|
||||
scheduleTempAutoSave
|
||||
scheduleTempAutoSave,
|
||||
flushTempAutoSave
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -15,17 +15,24 @@ class FluidSynthBridge extends AudioWorkletProcessor {
|
||||
|
||||
process(inputs, outputs) {
|
||||
const out = outputs[0];
|
||||
if (!out) return true;
|
||||
if (!out || out.length === 0) return true;
|
||||
this.called++;
|
||||
const numCh = out.length;
|
||||
const len = out[0].length;
|
||||
const qL = this.leftQ;
|
||||
const qR = this.rightQ;
|
||||
let fi = 0;
|
||||
let si = 0;
|
||||
// Handle any output channel count (mono devices produce 1 channel, so
|
||||
// out[1] may be undefined — never write into a missing channel).
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (fi >= qL.length) { out[0][i] = 0; out[1][i] = 0; continue; }
|
||||
out[0][i] = qL[fi][si];
|
||||
out[1][i] = qR[fi][si];
|
||||
if (fi >= qL.length) {
|
||||
for (let c = 0; c < numCh; c++) out[c][i] = 0;
|
||||
continue;
|
||||
}
|
||||
for (let c = 0; c < numCh; c++) {
|
||||
out[c][i] = c % 2 === 0 ? qL[fi][si] : qR[fi][si];
|
||||
}
|
||||
si++;
|
||||
if (si >= qL[fi].length) { fi++; si = 0; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
8d26e2b55e73579d1bb3c37b4878f1845ef9cbf50a8e4ee6f7deaa2ab80db32d
|
||||
@@ -14,17 +14,17 @@
|
||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/api.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202607311050"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608060630"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608022001" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608061030" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
try:
|
||||
import fluidsynth
|
||||
print("fluidsynth import successful.")
|
||||
|
||||
fl = fluidsynth.Synth()
|
||||
# Try to load a pre-existing system SF3
|
||||
sf3_path = "/opt/daw_engine/soundfonts/Equinox_Grand_Pianos.sf3"
|
||||
print(f"Checking if {sf3_path} exists: {os.path.exists(sf3_path)}")
|
||||
if os.path.exists(sf3_path):
|
||||
h = fl.sfload(sf3_path)
|
||||
print(f"Loaded {sf3_path}, handle: {h}")
|
||||
else:
|
||||
print("Equinox_Grand_Pianos.sf3 not found.")
|
||||
except Exception as e:
|
||||
print(f"Failed: {e}")
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# Ensure app is in path
|
||||
sys.path.insert(0, "/app")
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger("test_sf_convert")
|
||||
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
|
||||
def test():
|
||||
sf2_dir = "/app/app/storage/soundfonts"
|
||||
sf2_files = [os.path.join(sf2_dir, f) for f in os.listdir(sf2_dir) if f.endswith(".sf2") and "_decomp" not in f]
|
||||
if not sf2_files:
|
||||
logger.error("No SF2 files found in /app/app/storage/soundfonts")
|
||||
return
|
||||
|
||||
sf2_path = sf2_files[0]
|
||||
logger.info(f"Testing with SF2 file: {sf2_path}")
|
||||
|
||||
converter = SoundFontConverter()
|
||||
|
||||
# Check ffmpeg encoder support
|
||||
has_ogg = converter._check_ffmpeg_ogg()
|
||||
logger.info(f"ffmpeg with libvorbis available: {has_ogg}")
|
||||
|
||||
# Convert SF2 -> SF3
|
||||
sf3_path = sf2_path.replace(".sf2", ".sf3")
|
||||
if os.path.exists(sf3_path):
|
||||
os.remove(sf3_path)
|
||||
|
||||
logger.info("Converting SF2 -> SF3...")
|
||||
result_path = converter.convert_sf2_to_sf3(sf2_path)
|
||||
logger.info(f"Result path from convert_sf2_to_sf3: {result_path}")
|
||||
|
||||
if result_path.endswith(".sf3"):
|
||||
logger.info(f"SF3 file exists: {os.path.exists(sf3_path)}")
|
||||
if os.path.exists(sf3_path):
|
||||
logger.info(f"SF3 size: {os.path.getsize(sf3_path)} bytes")
|
||||
# Verify if it plays audio
|
||||
plays = converter._sf3_plays_audio(sf3_path)
|
||||
logger.info(f"SF3 plays audio (pyfluidsynth verify): {plays}")
|
||||
|
||||
# Now test decompression back to SF2
|
||||
decomp_sf2 = sf3_path.replace(".sf3", "_decomp.sf2")
|
||||
if os.path.exists(decomp_sf2):
|
||||
os.remove(decomp_sf2)
|
||||
|
||||
logger.info("Decompressing SF3 -> SF2...")
|
||||
try:
|
||||
decomp_result = converter.sf3_to_sf2(sf3_path, decomp_sf2)
|
||||
logger.info(f"Decompress result path: {decomp_result}")
|
||||
if os.path.exists(decomp_sf2):
|
||||
logger.info(f"Decompressed SF2 size: {os.path.getsize(decomp_sf2)} bytes")
|
||||
# Check if it plays
|
||||
decomp_plays = converter._sf3_plays_audio(decomp_sf2)
|
||||
logger.info(f"Decompressed SF2 plays audio: {decomp_plays}")
|
||||
except Exception as e:
|
||||
logger.error(f"Decompression failed: {e}", exc_info=True)
|
||||
else:
|
||||
logger.warning("Conversion did not produce an SF3 path.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test()
|
||||
@@ -0,0 +1,14 @@
|
||||
// Rebuild app.precompiled.js from app.jsx using @babel/standalone (avoids the
|
||||
// Babel 8 ESM-only CLI conflict). Mirrors package.json's build script:
|
||||
// babel app/static/js/app.jsx --config-file ./babel.config.json -o app/static/js/app.precompiled.js
|
||||
import * as Babel from '@babel/standalone';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
const src = readFileSync('app/static/js/app.jsx', 'utf8');
|
||||
const out = Babel.transform(src, {
|
||||
presets: ['react'],
|
||||
filename: 'app.jsx',
|
||||
sourceType: 'script',
|
||||
}).code;
|
||||
writeFileSync('app/static/js/app.precompiled.js', out);
|
||||
console.log('BUILD OK', out.length, 'bytes');
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Pytest bootstrap: isolate the test suite from the development database.
|
||||
|
||||
Must be imported before any app module (pytest imports conftest.py first), so
|
||||
app.models.user picks up the test DB path instead of the dev DB. Without this,
|
||||
tests that seed/rotate the admin password (test_auth_and_quota) permanently
|
||||
mutate the developer's sonicforge.db.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault(
|
||||
"SONICFORGE_DB_PATH",
|
||||
os.path.join(tempfile.gettempdir(), "sonicforge_test.db"),
|
||||
)
|
||||
@@ -13,7 +13,17 @@ client = TestClient(app)
|
||||
def get_admin_token():
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin123"})
|
||||
if resp.status_code == 200:
|
||||
return resp.json()["access_token"]
|
||||
token = resp.json()["access_token"]
|
||||
# Admin is seeded with must_change_password=1; the app blocks music
|
||||
# processing until the first password change. Complete that flow here
|
||||
# (keeping the same password) so feature tests run unblocked.
|
||||
user = resp.json()["user"]
|
||||
if user.get("must_change_password"):
|
||||
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": "admin123", "new_password": "admin123"})
|
||||
if r.status_code == 200:
|
||||
token = r.json()["access_token"]
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Regression tests for legacy project upgrade (items must stay on their tracks
|
||||
with correct bar positions)."""
|
||||
import json
|
||||
|
||||
from app.api.v1.projects import upgrade_project_json_if_needed
|
||||
|
||||
|
||||
def _legacy_project():
|
||||
return {
|
||||
"id": "legacy_1",
|
||||
"name": "Legacy",
|
||||
"bpm": 120.0, # 1 bar = 2.0s
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 1",
|
||||
"volumeDb": 0.0,
|
||||
"pan": 0.0,
|
||||
"muted": False,
|
||||
"solo": False,
|
||||
"serverFileId": "abc.wav",
|
||||
"clips": [{"id": "c1", "name": "clip1", "startTime": 2.0}],
|
||||
"midiItems": [],
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 2",
|
||||
"volumeDb": 0.0,
|
||||
"pan": 0.0,
|
||||
"muted": False,
|
||||
"solo": False,
|
||||
"serverFileId": None,
|
||||
"clips": [],
|
||||
"midiItems": [
|
||||
{"id": "m1", "name": "midi1", "startTime": 4.0, "duration": 4.0,
|
||||
"notes": [{"id": "n1", "pitch": 60, "start_beat": 0.0, "duration_beats": 1.0, "velocity": 0.8}]}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_upgrade_keeps_items_on_their_tracks():
|
||||
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||
tracks = upgraded["main_session"]["tracks"]
|
||||
assert len(tracks) == 2
|
||||
t1_items = tracks[0]["items"]
|
||||
t2_items = tracks[1]["items"]
|
||||
# Items must NOT be merged into the first track
|
||||
assert [i["type"] for i in t1_items] == ["AUDIO_ITEM"]
|
||||
assert [i["type"] for i in t2_items] == ["MIDI_ITEM"]
|
||||
assert t1_items[0]["id"] == "c1"
|
||||
assert t2_items[0]["id"] == "m1"
|
||||
|
||||
|
||||
def test_upgrade_uses_bpm_based_seconds_per_bar():
|
||||
upgraded = upgrade_project_json_if_needed(_legacy_project())
|
||||
tracks = upgraded["main_session"]["tracks"]
|
||||
# 120bpm -> 1 bar = 2.0s; clip at 2.0s -> start_bar 1.0
|
||||
assert tracks[0]["items"][0]["start_bar"] == 1.0
|
||||
# midi at 4.0s -> start_bar 2.0; duration 4.0s -> 2.0 bars
|
||||
assert tracks[1]["items"][0]["start_bar"] == 2.0
|
||||
assert tracks[1]["items"][0]["duration_bars"] == 2.0
|
||||
|
||||
|
||||
def test_upgrade_skips_new_format():
|
||||
data = {"main_session": {"tracks": []}}
|
||||
assert upgrade_project_json_if_needed(data) is data
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Regression tests for security hardening.
|
||||
|
||||
Covers the vulnerabilities found during the 2026-08 audit:
|
||||
- SSRF / open proxy on /api/v1/ai/proxy
|
||||
- path traversal on render output and audio file ids
|
||||
- unauthenticated filesystem access via /api/v1/media/*
|
||||
- hardcoded SECRET_KEY
|
||||
- quota bypass on project update
|
||||
- audio resampling correctness in the render engine
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
from app.core import auth as core_auth
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def get_admin_token():
|
||||
# test_auth_and_quota.py may have rotated the admin password; try both.
|
||||
for pwd in ("admin123", "admin_new_password_2026"):
|
||||
resp = client.post("/api/v1/auth/login", json={"username": "admin", "password": pwd})
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
token = resp.json()["access_token"]
|
||||
user = resp.json()["user"]
|
||||
if user.get("must_change_password"):
|
||||
r = client.post("/api/v1/auth/change-password", headers={"Authorization": f"Bearer {token}"},
|
||||
json={"old_password": pwd, "new_password": pwd})
|
||||
if r.status_code == 200:
|
||||
token = r.json()["access_token"]
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def auth_headers():
|
||||
return {"Authorization": f"Bearer {get_admin_token()}"}
|
||||
|
||||
|
||||
# ── 1. SSRF / open proxy ──
|
||||
|
||||
class TestAIProxySSRF:
|
||||
def test_proxy_requires_auth(self):
|
||||
resp = client.post("/api/v1/ai/proxy", json={"url": "https://api.openai.com/v1", "body": {}})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_proxy_blocks_cloud_metadata(self):
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "http://169.254.169.254/latest/meta-data/", "body": {}})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_proxy_blocks_private_ip_not_configured(self):
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "http://10.0.0.5/", "body": {}})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_proxy_rejects_non_http_scheme(self):
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "file:///etc/passwd", "body": {}})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_proxy_allows_configured_localhost_provider(self):
|
||||
# localhost:11434 is in the default AI provider list; it must pass the
|
||||
# SSRF check (and then fail to connect in this environment -> 502).
|
||||
resp = client.post("/api/v1/ai/proxy", headers=auth_headers(),
|
||||
json={"url": "http://localhost:11434/v1/chat/completions", "body": {}})
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ── 2. Path traversal ──
|
||||
|
||||
class TestPathTraversal:
|
||||
def test_render_output_filename_sanitized(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
project = {
|
||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": 1, "tracks": []},
|
||||
"section_store": {},
|
||||
}
|
||||
resp = client.post("/api/v1/plugins/render", headers=auth_headers(),
|
||||
json={"project_json": project, "output_filename": "/tmp/evil_traversal.wav"})
|
||||
# Absolute paths must be reduced to a basename inside PROCESSED_DIR.
|
||||
assert resp.status_code == 200, resp.text
|
||||
out_path = resp.json()["path"]
|
||||
assert os.path.dirname(out_path) == settings.PROCESSED_DIR
|
||||
assert os.path.basename(out_path) == "evil_traversal.wav"
|
||||
assert os.path.isfile(out_path)
|
||||
|
||||
def test_audio_download_rejects_traversal(self):
|
||||
resp = client.get("/api/v1/audio/download/..%2F..%2Fapp%2Fconfig.py")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_ai_scan_rejects_traversal_file_id(self):
|
||||
resp = client.post("/api/v1/audio/ai-scan",
|
||||
json={"track_id": "1", "file_id": "../../app/config.py"})
|
||||
# Traversal must NOT read the file: falls through to the demo branch.
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
|
||||
# ── 3. Filesystem exposure via media endpoints ──
|
||||
|
||||
class TestMediaAuth:
|
||||
# Use a fresh client (no cookies from earlier logins) to prove 401.
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_client(self):
|
||||
self.fresh = TestClient(app)
|
||||
yield
|
||||
self.fresh.close()
|
||||
|
||||
def test_media_computer_requires_auth(self):
|
||||
resp = self.fresh.get("/api/v1/media/computer")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_media_browse_requires_auth(self):
|
||||
resp = self.fresh.get("/api/v1/media/browse", params={"path": "/etc"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_media_file_requires_auth(self):
|
||||
resp = self.fresh.get("/api/v1/media/file", params={"path": "/etc/passwd"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── 4. Secret key ──
|
||||
|
||||
class TestSecretKey:
|
||||
def test_secret_key_not_hardcoded_default(self):
|
||||
assert core_auth.SECRET_KEY != "sonicforge_secret_key_super_secure_2026"
|
||||
assert len(core_auth.SECRET_KEY) >= 32
|
||||
|
||||
|
||||
# ── 5. Quota enforcement on update ──
|
||||
|
||||
class TestQuotaUpdate:
|
||||
def test_update_cloud_project_enforces_quota(self):
|
||||
token = get_admin_token()
|
||||
if not token:
|
||||
pytest.skip("Cannot get admin token")
|
||||
# Register a fresh user with a small quota (unique name per run so the
|
||||
# test is re-runnable against a persistent DB).
|
||||
import uuid as _uuid
|
||||
uname = f"quota_user_{_uuid.uuid4().hex[:8]}"
|
||||
resp = client.post("/api/v1/auth/register", json={
|
||||
"username": uname, "email": f"{uname}@studio.com", "password": "quota_pass_123"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
user_token = resp.json()["access_token"]
|
||||
user_headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
# Shrink quota to 1 MB via admin API.
|
||||
uid = resp.json()["user"]["id"]
|
||||
r = client.put(f"/api/v1/admin/quotas/{uid}", headers=auth_headers(),
|
||||
json={"storage_limit_mb": 1, "max_tracks": 16})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# Save a small project.
|
||||
small = json.dumps({
|
||||
"project_id": "p1",
|
||||
"metadata": {"title": "small", "bpm": 120, "time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||
"length_bars": 16.0, "auto_compute_length": True, "tracks": []},
|
||||
"section_store": {}})
|
||||
r = client.post("/api/v1/projects/cloud", headers=user_headers,
|
||||
json={"name": "small", "data_json": small})
|
||||
assert r.status_code == 200, r.text
|
||||
pid = r.json()["project_id"]
|
||||
|
||||
# Updating with a payload over the quota must be rejected (was a bypass).
|
||||
items = [{
|
||||
"type": "AUDIO_ITEM", "id": f"it_{i}", "name": "n",
|
||||
"start_bar": 0.0, "duration_bars": 1.0, "clip_start_offset_bars": 0.0,
|
||||
"source_data": {"audio_file_url": "", "gain": 1.0},
|
||||
} for i in range(20000)]
|
||||
huge = json.dumps({
|
||||
"project_id": "p1",
|
||||
"metadata": {"title": "huge", "bpm": 120, "time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4, "sample_rate": 44100},
|
||||
"main_session": {"id": "main", "name": "MAIN SESSION", "is_root": True,
|
||||
"length_bars": 16.0, "auto_compute_length": True,
|
||||
"tracks": [{"id": "t", "name": "x", "type": "AUDIO", "items": items}]},
|
||||
"section_store": {}})
|
||||
r = client.put(f"/api/v1/projects/cloud/{pid}", headers=user_headers,
|
||||
json={"name": "huge", "data_json": huge})
|
||||
assert r.status_code == 400, r.text
|
||||
assert "Quota" in r.json()["detail"]
|
||||
|
||||
|
||||
# ── 6. Render engine: resampling correctness ──
|
||||
|
||||
class TestRenderResample:
|
||||
def test_audio_item_resampled_to_engine_rate(self, tmp_path):
|
||||
from app.core.render_engine import PythonRenderEngine
|
||||
# 44.1kHz source, engine at 22.05kHz -> exactly 2x downsampling.
|
||||
sr_src = 44100
|
||||
t = np.arange(sr_src) / sr_src
|
||||
tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
|
||||
src_path = os.path.join(settings.UPLOADS_DIR, "resample_test_tone.wav")
|
||||
sf.write(src_path, tone, sr_src)
|
||||
|
||||
engine = PythonRenderEngine(sample_rate=22050)
|
||||
session = {
|
||||
"tracks": [{
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0.0, "pan": 0.0, "mute": False,
|
||||
"items": [{
|
||||
"type": "AUDIO_ITEM",
|
||||
"start_bar": 0.0, "duration_bars": 4.0,
|
||||
"clip_start_offset_bars": 0.0,
|
||||
"source_data": {"audio_file_url": "/static/audio/uploads/resample_test_tone.wav", "gain": 1.0},
|
||||
}],
|
||||
}]
|
||||
}
|
||||
buf = engine.render_session_container(session, {}, bpm=120.0, time_sig_num=4,
|
||||
total_samples=engine.sample_rate * 2)
|
||||
# A 1s 440Hz tone must actually render energy (previously the SR
|
||||
# mismatch silently skipped the audio).
|
||||
assert np.max(np.abs(buf)) > 0.01
|
||||
# Duration should be ~1 second at the engine rate, not 2.
|
||||
nonzero = np.where(np.abs(buf[0]) > 1e-4)[0]
|
||||
assert len(nonzero) > 0
|
||||
assert (nonzero[-1] - nonzero[0]) < int(engine.sample_rate * 1.3)
|
||||
try:
|
||||
os.remove(src_path)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,993 @@
|
||||
[
|
||||
{
|
||||
"id": "bca1bb5f-b656-48a8-b113-8fff900188ab",
|
||||
"name": "Test",
|
||||
"data_json": {
|
||||
"project_id": "project_1784710097790",
|
||||
"metadata": {
|
||||
"title": "Test",
|
||||
"bpm": 120.0,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16.0,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 01",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1784710143.4476569
|
||||
},
|
||||
{
|
||||
"id": "d627f532-b632-4ef0-a21f-80dcc2ac8396",
|
||||
"name": "Test",
|
||||
"data_json": {
|
||||
"project_id": "project_1784719724178",
|
||||
"metadata": {
|
||||
"title": "Test",
|
||||
"bpm": 120.0,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16.0,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 01",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1784719770.23144
|
||||
},
|
||||
{
|
||||
"id": "a17fe50b-c51d-408d-b45c-c09645777e68",
|
||||
"name": "Test",
|
||||
"data_json": {
|
||||
"project_id": "project_1784719775936",
|
||||
"metadata": {
|
||||
"title": "Test",
|
||||
"bpm": 120.0,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16.0,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 01",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1784719821.9856758
|
||||
},
|
||||
{
|
||||
"id": "38a0f344-05dc-47e3-a769-07426c57f5fe",
|
||||
"name": "Test",
|
||||
"data_json": {
|
||||
"project_id": "project_1784720540893",
|
||||
"metadata": {
|
||||
"title": "Test",
|
||||
"bpm": 120.0,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16.0,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Cartoon Capers Loop.mp3",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1784720586.9746954
|
||||
},
|
||||
{
|
||||
"id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||
"name": "Rose",
|
||||
"data_json": {
|
||||
"project_id": "project_1784720555735",
|
||||
"metadata": {
|
||||
"title": "Rose",
|
||||
"bpm": 120.0,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16.0,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 01",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"fx_chain": [],
|
||||
"synth_engine": {
|
||||
"plugin_id": "synth",
|
||||
"preset_id": "default",
|
||||
"parameters": {}
|
||||
},
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1784720601.8155787
|
||||
},
|
||||
{
|
||||
"id": "525bdab5-e594-4a0c-a3e6-102a0abef816",
|
||||
"name": "Rose (autosave 03/08)",
|
||||
"data_json": {
|
||||
"project_id": "8037d8f5-1410-4639-8167-5b89dcb1c3e9",
|
||||
"metadata": {
|
||||
"title": "Rose (autosave 03/08)",
|
||||
"bpm": 128,
|
||||
"time_signature_numerator": 4,
|
||||
"time_signature_denominator": 4,
|
||||
"sample_rate": 44100
|
||||
},
|
||||
"main_session": {
|
||||
"id": "main",
|
||||
"name": "MAIN SESSION",
|
||||
"is_root": true,
|
||||
"length_bars": 16,
|
||||
"auto_compute_length": true,
|
||||
"tracks": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Track 01",
|
||||
"type": "MIDI",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"instrument_id": "sf_DSK_Asian_DreamZ",
|
||||
"instrument_program": 5,
|
||||
"instrument_name": "BAN-DI",
|
||||
"items": [
|
||||
{
|
||||
"id": "midi_1785057918746",
|
||||
"name": "MIDI Item",
|
||||
"type": "MIDI_ITEM",
|
||||
"start_bar": 0,
|
||||
"duration_bars": 4,
|
||||
"clip_start_offset_bars": 0,
|
||||
"source_data": {
|
||||
"total_buffer_bars": 4,
|
||||
"notes": [
|
||||
{
|
||||
"id": "note_1785057946413j2a54",
|
||||
"pitch": 55,
|
||||
"start_beat": 1.5,
|
||||
"duration_beats": 0.125,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946488uv1f6r6q_2",
|
||||
"pitch": 56,
|
||||
"start_beat": 1.5166666666666666,
|
||||
"duration_beats": 0.2333333333333334,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946505gxl5rre2_3",
|
||||
"pitch": 57,
|
||||
"start_beat": 1.75,
|
||||
"duration_beats": 0.3833333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946522v0dmipvj_4",
|
||||
"pitch": 60,
|
||||
"start_beat": 2.1333333333333333,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579465381vqpeamx_5",
|
||||
"pitch": 62,
|
||||
"start_beat": 2.4,
|
||||
"duration_beats": 0.21666666666666679,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946555rmpgksfp_6",
|
||||
"pitch": 63,
|
||||
"start_beat": 2.6166666666666667,
|
||||
"duration_beats": 0.20000000000000018,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946572cvrxri09_7",
|
||||
"pitch": 64,
|
||||
"start_beat": 2.816666666666667,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946588nvmp7zld_8",
|
||||
"pitch": 66,
|
||||
"start_beat": 3.066666666666667,
|
||||
"duration_beats": 0.1499999999999999,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946605jkbnh8mf_9",
|
||||
"pitch": 67,
|
||||
"start_beat": 3.216666666666667,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946638i28svktt_10",
|
||||
"pitch": 68,
|
||||
"start_beat": 3.4833333333333334,
|
||||
"duration_beats": 0.1499999999999999,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579466559iy45ml8_11",
|
||||
"pitch": 69,
|
||||
"start_beat": 3.6333333333333333,
|
||||
"duration_beats": 0.1333333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946672s712j5m6_12",
|
||||
"pitch": 70,
|
||||
"start_beat": 3.7666666666666666,
|
||||
"duration_beats": 0.1333333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946688pq858zcx_13",
|
||||
"pitch": 71,
|
||||
"start_beat": 3.9,
|
||||
"duration_beats": 0.18333333333333313,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579467054usc79tx_14",
|
||||
"pitch": 72,
|
||||
"start_beat": 4.083333333333333,
|
||||
"duration_beats": 0.15000000000000036,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946722dspmlfaa_15",
|
||||
"pitch": 73,
|
||||
"start_beat": 4.233333333333333,
|
||||
"duration_beats": 0.2999999999999998,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946755h5mvip3h_16",
|
||||
"pitch": 74,
|
||||
"start_beat": 4.533333333333333,
|
||||
"duration_beats": 0.15000000000000036,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946772a5e5iona_17",
|
||||
"pitch": 75,
|
||||
"start_beat": 4.683333333333334,
|
||||
"duration_beats": 0.16666666666666607,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946788cfqxk2c8_18",
|
||||
"pitch": 76,
|
||||
"start_beat": 4.85,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_178505794682247xy477z_19",
|
||||
"pitch": 77,
|
||||
"start_beat": 5.116666666666666,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946855nygukqis_20",
|
||||
"pitch": 78,
|
||||
"start_beat": 5.366666666666666,
|
||||
"duration_beats": 0.20000000000000018,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946888oyfvmvq2_21",
|
||||
"pitch": 79,
|
||||
"start_beat": 5.566666666666666,
|
||||
"duration_beats": 0.35000000000000053,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947455xrq7rfmm_22",
|
||||
"pitch": 78,
|
||||
"start_beat": 5.916666666666667,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579474722sn8bsed_23",
|
||||
"pitch": 77,
|
||||
"start_beat": 6.2,
|
||||
"duration_beats": 0.31666666666666643,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947489mbv5z7q6_24",
|
||||
"pitch": 76,
|
||||
"start_beat": 6.516666666666667,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947505qmasllso_25",
|
||||
"pitch": 75,
|
||||
"start_beat": 6.8,
|
||||
"duration_beats": 0.41666666666666696,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947539wkqyr6fw_26",
|
||||
"pitch": 74,
|
||||
"start_beat": 7.216666666666667,
|
||||
"duration_beats": 0.18333333333333357,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947572a2ydjwzu_27",
|
||||
"pitch": 73,
|
||||
"start_beat": 7.4,
|
||||
"duration_beats": 0.1999999999999993,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947589b18pcser_28",
|
||||
"pitch": 72,
|
||||
"start_beat": 7.6,
|
||||
"duration_beats": 0.16666666666666696,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947605bsiv751r_29",
|
||||
"pitch": 71,
|
||||
"start_beat": 7.766666666666667,
|
||||
"duration_beats": 0.13333333333333375,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947622ii453i6x_30",
|
||||
"pitch": 70,
|
||||
"start_beat": 7.9,
|
||||
"duration_beats": 0.18333333333333357,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947639zdv6seea_31",
|
||||
"pitch": 69,
|
||||
"start_beat": 8.083333333333334,
|
||||
"duration_beats": 0.36666666666666536,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947672devle9p7_32",
|
||||
"pitch": 68,
|
||||
"start_beat": 8.45,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947689x8rgn7ih_33",
|
||||
"pitch": 67,
|
||||
"start_beat": 8.7,
|
||||
"duration_beats": 0.3333333333333339,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947722u5b36hrl_34",
|
||||
"pitch": 66,
|
||||
"start_beat": 9.033333333333333,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947772f9qp4st0_35",
|
||||
"pitch": 65,
|
||||
"start_beat": 9.316666666666666,
|
||||
"duration_beats": 0.38333333333333286,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947805cf5bjiv1_36",
|
||||
"pitch": 64,
|
||||
"start_beat": 9.7,
|
||||
"duration_beats": 0.6666666666666679,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947855ojfcoccj_37",
|
||||
"pitch": 63,
|
||||
"start_beat": 10.366666666666667,
|
||||
"duration_beats": 0.36666666666666536,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579478721jvhzolj_38",
|
||||
"pitch": 62,
|
||||
"start_beat": 10.733333333333333,
|
||||
"duration_beats": 0.3000000000000007,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947889nqk9ckue_39",
|
||||
"pitch": 61,
|
||||
"start_beat": 11.033333333333333,
|
||||
"duration_beats": 0.3333333333333339,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Track 02",
|
||||
"type": "AUDIO",
|
||||
"volume_db": 0,
|
||||
"pan": 0,
|
||||
"mute": false,
|
||||
"solo": false,
|
||||
"instrument_id": null,
|
||||
"instrument_program": null,
|
||||
"instrument_name": null,
|
||||
"items": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"sub_tabs": [
|
||||
{
|
||||
"id": "midi_1785057919362",
|
||||
"label": "Piano Roll: MIDI Item",
|
||||
"type": "PIANO_ROLL",
|
||||
"track_id": "1",
|
||||
"target_id": "midi_1785057918746",
|
||||
"parent_tab_id": null,
|
||||
"notes": [
|
||||
{
|
||||
"id": "note_1785057946413j2a54",
|
||||
"pitch": 55,
|
||||
"start_beat": 1.5,
|
||||
"duration_beats": 0.125,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946488uv1f6r6q_2",
|
||||
"pitch": 56,
|
||||
"start_beat": 1.5166666666666666,
|
||||
"duration_beats": 0.2333333333333334,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946505gxl5rre2_3",
|
||||
"pitch": 57,
|
||||
"start_beat": 1.75,
|
||||
"duration_beats": 0.3833333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946522v0dmipvj_4",
|
||||
"pitch": 60,
|
||||
"start_beat": 2.1333333333333333,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579465381vqpeamx_5",
|
||||
"pitch": 62,
|
||||
"start_beat": 2.4,
|
||||
"duration_beats": 0.21666666666666679,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946555rmpgksfp_6",
|
||||
"pitch": 63,
|
||||
"start_beat": 2.6166666666666667,
|
||||
"duration_beats": 0.20000000000000018,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946572cvrxri09_7",
|
||||
"pitch": 64,
|
||||
"start_beat": 2.816666666666667,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946588nvmp7zld_8",
|
||||
"pitch": 66,
|
||||
"start_beat": 3.066666666666667,
|
||||
"duration_beats": 0.1499999999999999,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946605jkbnh8mf_9",
|
||||
"pitch": 67,
|
||||
"start_beat": 3.216666666666667,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946638i28svktt_10",
|
||||
"pitch": 68,
|
||||
"start_beat": 3.4833333333333334,
|
||||
"duration_beats": 0.1499999999999999,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579466559iy45ml8_11",
|
||||
"pitch": 69,
|
||||
"start_beat": 3.6333333333333333,
|
||||
"duration_beats": 0.1333333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946672s712j5m6_12",
|
||||
"pitch": 70,
|
||||
"start_beat": 3.7666666666666666,
|
||||
"duration_beats": 0.1333333333333333,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946688pq858zcx_13",
|
||||
"pitch": 71,
|
||||
"start_beat": 3.9,
|
||||
"duration_beats": 0.18333333333333313,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579467054usc79tx_14",
|
||||
"pitch": 72,
|
||||
"start_beat": 4.083333333333333,
|
||||
"duration_beats": 0.15000000000000036,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946722dspmlfaa_15",
|
||||
"pitch": 73,
|
||||
"start_beat": 4.233333333333333,
|
||||
"duration_beats": 0.2999999999999998,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946755h5mvip3h_16",
|
||||
"pitch": 74,
|
||||
"start_beat": 4.533333333333333,
|
||||
"duration_beats": 0.15000000000000036,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946772a5e5iona_17",
|
||||
"pitch": 75,
|
||||
"start_beat": 4.683333333333334,
|
||||
"duration_beats": 0.16666666666666607,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946788cfqxk2c8_18",
|
||||
"pitch": 76,
|
||||
"start_beat": 4.85,
|
||||
"duration_beats": 0.2666666666666666,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_178505794682247xy477z_19",
|
||||
"pitch": 77,
|
||||
"start_beat": 5.116666666666666,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946855nygukqis_20",
|
||||
"pitch": 78,
|
||||
"start_beat": 5.366666666666666,
|
||||
"duration_beats": 0.20000000000000018,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057946888oyfvmvq2_21",
|
||||
"pitch": 79,
|
||||
"start_beat": 5.566666666666666,
|
||||
"duration_beats": 0.35000000000000053,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947455xrq7rfmm_22",
|
||||
"pitch": 78,
|
||||
"start_beat": 5.916666666666667,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579474722sn8bsed_23",
|
||||
"pitch": 77,
|
||||
"start_beat": 6.2,
|
||||
"duration_beats": 0.31666666666666643,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947489mbv5z7q6_24",
|
||||
"pitch": 76,
|
||||
"start_beat": 6.516666666666667,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947505qmasllso_25",
|
||||
"pitch": 75,
|
||||
"start_beat": 6.8,
|
||||
"duration_beats": 0.41666666666666696,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947539wkqyr6fw_26",
|
||||
"pitch": 74,
|
||||
"start_beat": 7.216666666666667,
|
||||
"duration_beats": 0.18333333333333357,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947572a2ydjwzu_27",
|
||||
"pitch": 73,
|
||||
"start_beat": 7.4,
|
||||
"duration_beats": 0.1999999999999993,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947589b18pcser_28",
|
||||
"pitch": 72,
|
||||
"start_beat": 7.6,
|
||||
"duration_beats": 0.16666666666666696,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947605bsiv751r_29",
|
||||
"pitch": 71,
|
||||
"start_beat": 7.766666666666667,
|
||||
"duration_beats": 0.13333333333333375,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947622ii453i6x_30",
|
||||
"pitch": 70,
|
||||
"start_beat": 7.9,
|
||||
"duration_beats": 0.18333333333333357,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947639zdv6seea_31",
|
||||
"pitch": 69,
|
||||
"start_beat": 8.083333333333334,
|
||||
"duration_beats": 0.36666666666666536,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947672devle9p7_32",
|
||||
"pitch": 68,
|
||||
"start_beat": 8.45,
|
||||
"duration_beats": 0.25,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947689x8rgn7ih_33",
|
||||
"pitch": 67,
|
||||
"start_beat": 8.7,
|
||||
"duration_beats": 0.3333333333333339,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947722u5b36hrl_34",
|
||||
"pitch": 66,
|
||||
"start_beat": 9.033333333333333,
|
||||
"duration_beats": 0.2833333333333332,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947772f9qp4st0_35",
|
||||
"pitch": 65,
|
||||
"start_beat": 9.316666666666666,
|
||||
"duration_beats": 0.38333333333333286,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947805cf5bjiv1_36",
|
||||
"pitch": 64,
|
||||
"start_beat": 9.7,
|
||||
"duration_beats": 0.6666666666666679,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947855ojfcoccj_37",
|
||||
"pitch": 63,
|
||||
"start_beat": 10.366666666666667,
|
||||
"duration_beats": 0.36666666666666536,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_17850579478721jvhzolj_38",
|
||||
"pitch": 62,
|
||||
"start_beat": 10.733333333333333,
|
||||
"duration_beats": 0.3000000000000007,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
{
|
||||
"id": "note_1785057947889nqk9ckue_39",
|
||||
"pitch": 61,
|
||||
"start_beat": 11.033333333333333,
|
||||
"duration_beats": 0.3333333333333339,
|
||||
"velocity": 0.8,
|
||||
"pan": 0
|
||||
}
|
||||
],
|
||||
"duration": 7.5,
|
||||
"instrument_program": 5,
|
||||
"instrument_name": "BAN-DI",
|
||||
"current_time": 0,
|
||||
"color": null
|
||||
}
|
||||
],
|
||||
"section_store": {}
|
||||
},
|
||||
"updated_at": 1785058087.45
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Khôi phục các dự án cloud cũ (từ git history) vào bất kỳ sonicforge.db nào.
|
||||
|
||||
Cách dùng trên máy deployment thật (host game):
|
||||
python3 tools/restore_cloud_projects.py /path/to/sonicforge.db
|
||||
|
||||
Script đọc backup projects (JSON) đã xuất từ git history và chèn vào DB chỉ định,
|
||||
gán tất cả cho user_id được yêu cầu (mặc định: admin đầu tiên tìm thấy).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
PROJECTS_BACKUP = os.path.join(os.path.dirname(__file__), "cloud_projects_backup.json")
|
||||
|
||||
|
||||
def main(db_path: str, user_id: str = None):
|
||||
if not os.path.exists(PROJECTS_BACKUP):
|
||||
print(f"Không tìm thấy {PROJECTS_BACKUP}")
|
||||
return 1
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Không tìm thấy DB: {db_path}")
|
||||
return 1
|
||||
|
||||
with open(PROJECTS_BACKUP) as f:
|
||||
projects = json.load(f)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
if user_id is None:
|
||||
row = conn.execute("SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1").fetchone()
|
||||
if not row:
|
||||
print("Không có user admin nào trong DB")
|
||||
conn.close()
|
||||
return 1
|
||||
user_id = row["id"]
|
||||
print(f"Gán tất cả cho admin: {user_id}")
|
||||
|
||||
restored = 0
|
||||
for p in projects:
|
||||
if conn.execute("SELECT id FROM projects WHERE id=?", (p["id"],)).fetchone():
|
||||
print(f" bỏ qua (đã tồn tại): {p['name']}")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO projects (id, user_id, name, data_json, is_temp, size_bytes, updated_at) VALUES (?,?,?,?,0,?,?)",
|
||||
(p["id"], user_id, p["name"], json.dumps(p["data_json"], ensure_ascii=False),
|
||||
len(json.dumps(p["data_json"]).encode("utf-8")), p["updated_at"]))
|
||||
restored += 1
|
||||
print(f" đã khôi phục: {p['name']}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"\nHoàn tất: {restored} dự án đã khôi phục cho user {user_id}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 restore_cloud_projects.py <path/to/sonicforge.db> [user_id]")
|
||||
sys.exit(1)
|
||||
sys.exit(main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None))
|
||||
@@ -1,3 +1,531 @@
|
||||
### [2026-08-04] Task: Mở PIANO ROLL TAB lúc main đang play KHÔNG được tắt âm — handleEditMidiInTab stopAllPlayback có điều kiện
|
||||
- **Tóm tắt thay đổi:** User báo "đang play main session, dblclick mở Piano roll tab → âm bị tắt". Thủ phạm: `handleEditMidiInTab` (15983) gọi `stopAllPlayback()` VÔ ĐIỀU KIỆN ngay khi mở tab — dừng mọi nguồn main đang phát. Fix: `if (!isPlaying) stopAllPlayback()` — chỉ clean-stop khi KHÔNG play main (tránh stuck route khi đổi tab giữa sub-tab); main đang play → mở tab → âm main tiếp tục. Nhánh play sub-tab tự stopAllPlayback trước khi play nên không xung đột route.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (handleEditMidiInTab), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1013547 bytes, node --check OK, `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only).
|
||||
---
|
||||
|
||||
### [2026-08-04] Task: PIANO ROLL play ĐÚNG instrument track — resolveTrackInstrumentCtx + ensureSonicInstrument (nguồn duy nhất)
|
||||
- **Tóm tắt thay đổi:** User yêu cầu "mở PIANO ROLL TAB của track đã loaded instrument → PHẢI play note với ĐÚNG instrument đó". Trước đây luồng instrument rời rạc: `schedulePianoRollMidi` dùng track live nhưng preview (wheel/click/keybed) dùng `st.instrumentProgram` — snapshot STALE từ lúc mở tab (đổi instrument sau khi mở tab → preview nghe instrument cũ/sai) + chưa có nơi nào chủ động select đúng channel trước khi note bắn. Viết lại từ đầu:
|
||||
1. **`resolveTrackInstrumentCtx(track, tracks)`** (module-level, nguồn duy nhất): resolve instrument từ TRACK live — ưu tiên `synth_engine` (soundfont: soundfont_id/bank/program → SF path, program=undefined để _playNoteFluid ưu tiên synthEngine), fallback `instrumentProgram` (GM preset), không có → im (đúng — chưa chọn instrument).
|
||||
2. **`ensureSonicInstrument(ctx)`**: fire-and-forget `selectInstrument(ch, bank, prog, sfId)` — đảm bảo channel của track đã select ĐÚNG instrument trước khi notes bắn (playNote tự load+retry nếu SF chưa xong — dedup sẵn, không stall).
|
||||
3. Áp vào `schedulePianoRollMidi` + 4 preview paths (canvas wheel 7124, click note 7459, draw brush 7780, keybed 8097/8120/8133) — bỏ `st.instrumentProgram`/`kbCh`/`kbSynth`/`pvCh` cũ.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (helpers + 4 call sites), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608042200), `app/static/js/services/soundfontPlayer.js` (log setOutputDestination — sibling)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1013347 bytes, node --check OK, smoke test resolveTrackInstrumentCtx 4 case (SF/GM/no-instr/vst3-empty) đúng. `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only).
|
||||
---
|
||||
|
||||
### [2026-08-04] Task: Fix mất âm toàn cục khi mở PIANO ROLL TAB + bấm play — exempt watchdog + SF routing theo active tab
|
||||
- **Tóm tắt thay đổi:** User báo "nhấp đôi MIDI item mở PIANO ROLL TAB, điều khiển transport → mất âm, không còn âm ra loa". Root cause: master-silence watchdog trong updatePlayhead coi PIANO_ROLL (chỉ có notes MIDI schedule rời rạc + silent source 2.9ms) là "play mà im lặng" → mọi rest >750ms trigger rebuild → `stopAllPlayback()` + `panic()` hủy notes đang chờ (SF lazy-load lần đầu / gap tự nhiên) → rebuild loop mỗi 3s → notes không bao giờ bắn → CÂM TOÀN CỤC. Fix 2 change:
|
||||
1. **Exempt PIANO_ROLL khỏi watchdog** (`_isPianoRollWatchdog` — active sub-tab type PIANO_ROLL → bỏ qua toàn bộ block). PIANO_ROLL không có nguồn liên tục → im lặng là tự nhiên; các fix setValueAtTime/NaN guard đã hết "BiquadFilter state is bad" nên watchdog chỉ còn là lớp cứu cuối cho main/audio-tab.
|
||||
2. **updateSfRouting ưu tiên active PIANO_ROLL track**: khi tab PIANO_ROLL đang active → route FluidSynth tới node của track đang edit (`sfEntry` → FX Rack riêng, fallback `gainNode`) — kể cả khi project có >1 track MIDI audible (bình thường fallback masterBus.input, mất fader/pan/FX track). Fader + FX áp đúng cho notes đang nghe.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (watchdog exempt + updateSfRouting), `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608041200)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1010058 bytes (build.mjs standalone — Babel 8 ESM conflict), node --check OK, `pytest` 86 passed (test_sf_convert fail pre-existing — path `/app/...` docker-only). Manual: dblclick MIDI item → play → âm chạy liên tục qua rest; fader/FX track áp đúng.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Watchdog sub-tab hoạt động (silent source 2.9ms làm anyPlaying luôn false) + log applyMasteringSettings
|
||||
- **Tóm tắt thay đổi:** STOP piano roll vẫn 11× "state is bad" (guard NaN + hết automation KHÔNG đủ — flag dồn tích trên biquad từ trước). Kiểm tra: applyMasteringSettings ĐÃ có sig guard + clamp NaN sẵn; getAudioContext sạch (chỉ masteringSettings effect quản lí) → warning là flag PERSISTENT trên masterBus biquad — **cứu bằng watchdog rebuild**. Fix:
|
||||
1. **Watchdog sub-tab**: silent source piano roll chỉ dài ~2.9ms → `anyPlaying` luôn false sau 100ms → watchdog VÔ HIỆU với piano roll. Sửa: `anyPlaying = _anySubPlaying ? true : ...` — sub-tab play + master im lặng >750ms → rebuild + resume đúng chế độ (log `[Recovery]`).
|
||||
2. **Log `[Mastering] applyMasteringSettings active=...`** — theo dõi khi nào apply chạy (sig guard — chỉ khi settings đổi).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608040000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1008644 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Play main OK → space (piano roll) OK (hết warning — bản 39800 hiệu lực) → **STOP → 1 warning "BiquadFilterNode: state is bad" → câm + không còn log play tiếp**. Phát hiện: "state is bad" KHÔNG chỉ do automation — **setValueAtTime(NaN) TRÊN BIQUAD cũng gây flag** (NaN từ field settings undefined → clamp(undefined)=NaN). Fix:
|
||||
1. **applyMasteringSettings (masterBus eq filters)**: `_g(v, lo, hi)` guard `typeof v === 'number' && isFinite(v)` — NaN/undefined → 0.
|
||||
2. **`eqproClamp` guard isFinite** — EQ PRO mọi giá trị biquad an toàn (NaN → lo).
|
||||
3. **Log `[Play] click activeTab=...`** đầu handlePlayPause — biết play sau stop có chạy không + rơi nhánh nào.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1008312 bytes, node --check OK, `pytest` 86 passed. Guard NaN ✓ (isFinite ×3), log click ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Play piano roll tab → 12 warning `BiquadFilterNode: state is bad` liên tục + instrument không phát + MAIN OUT treo. 2 fix:
|
||||
1. **`applyEQPreset` (10628)**: `setTargetAtTime(..., 0.02)` trên masterBus EQ biquads → **setValueAtTime** — **loại nguồn automation biquad cuối cùng** (EQ PRO/masterBus eq/applyEQPreset/track FX — tất cả đã setValueAtTime; các setTargetAtTime còn lại đều gain/compressor/limiter).
|
||||
2. **Watchdog master-silence MỞ RỘNG cho sub-tab**: guard cũ `activeTab === 'main'` bỏ + thêm `_anySubPlaying` (sub-tab play không set isPlaying App) → piano roll câm → **tự rebuild + resume ĐÚNG chế độ** (sub-tab: schedulePianoRollMidi + startSubTabPlayback; main: startTrackPlayback) → **MAIN OUT treo tự phục hồi sau ~750ms + cooldown 3s** (log `[Recovery]`).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007948 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Log mới: 5 track play OK (node ok, muted false), piano roll schedule OK — nhưng warning `BiquadFilterNode: state is bad` vẫn xuất hiện. Nguồn cuối cùng: **masterBus EQ filters (eqLow/Mid1/Mid2/High — BiquadFilter) dùng `setTargetAtTime(..., 0.05)`** trong applyMasteringSettings — đổi toàn bộ sang **`setValueAtTime(x, now)`** (cancelScheduledValues giữ + bỏ tham số thứ 3) → hết automation trên mọi biquad → hết warning (các setTargetAtTime còn lại đều là gain/compressor/limiter — không gây "state is bad").
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007264 bytes, node --check OK, `pytest` 86 passed. eq filters setValueAtTime ✓, hết eq setTargetAtTime ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Lỗi FluidSynth `Instrument not found on channel 0 [bank=0 prog=50], substituted [bank=0 prog=0]` — do fix unload SF (39500) gây ra: SGM bị `sfunload` + xóa khỏi `_sfHandleMap` → channel state (`cachedCh.sfId`) vẫn trỏ SGM → `progAlreadySet=true` → **skip program_select** → noteon trên **handle đã unload** → "Instrument not found" + substitute prog 0 (âm ra nhưng sai nhạc cụ). Fix (soundfontPlayer.js):
|
||||
1. **BỎ unload SF cũ khi sfload SF mới** — heap 256MB đủ cho vài SF (log: SGM handle 1 + latin handle 2 load OK); unload tạo handle rác.
|
||||
2. **`progAlreadySet` thêm điều kiện `_sfHandleMap.has(finalSfId)`** — chỉ skip program_select khi handle còn hợp lệ (505 đã đảm bảo load xong trước khi tới 515).
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` (bump v=202608039600 — app.precompiled giữ nguyên 39500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed. Hết sfunload ✓, progAlreadySet check handle ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** "MỌI âm thanh không còn phát sau khi mở PIANO ROLL TAB" — thủ phạm: preload `loadSoundFont(soundfont_id)` tôi thêm ở bản 39300 khi mở tab → **sfload SF mới nền → WASM heap 256MB đầy → FluidSynth stall → silence toàn cục** (đúng comment loadSoundFont "stalling notes... then silence"). Fix:
|
||||
1. **BỎ preload loadSoundFont khỏi handleEditMidiInTab** — playNote TỰ load + retry đúng lúc note cần (không load khi chỉ mở tab).
|
||||
2. **soundfontPlayer `_doLoadSoundFont`: UNLOAD SF cũ trước khi sfload SF mới** (`_fluid_synth_sfunload` + `_sfHandleMap.delete` + `_loadedFonts=false`) — heap không tích nhiều SF; lần sau cần lại SF cũ → tự re-load.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (unload trước sfload), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007288 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Log `[Play] PianoRoll schedule ... notes=248 ...` CHẠY nhưng nút play/space không phản hồi (nút không đổi trạng thái). Nguyên nhân: `stopAllPlayback()` (gọi đầu nhánh play) — **`n.fxStopFn()` (18735) KHÔNG bọc try** — nếu throw → exception lan ra catch của handlePlayPause → `setSubTabs(isPlaying: true)` KHÔNG chạy → nút play không đổi (vô tác dụng) + toast lỗi. Fix: **bọc toàn bộ thân stopAllPlayback bằng try/catch** (log `[Stop] ...`) + `fxStopFn`/`SonicSF.stopAll`/`panic` mỗi cái bọc try riêng → stopAllPlayback KHÔNG BAO GIỜ throw → setSubTabs luôn chạy → nút play phản hồi đúng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007546 bytes, node --check OK, `pytest` 86 passed. fxStopFn bọc try ✓ (18737).
|
||||
---
|
||||
- **Tóm tắt thay đổi:** "Không thể play midi với instrument đã loaded trong PIANO ROLL TAB". Nguyên nhân chính: `playNote` → `_doLoadSoundFont` → `fetch /api/v1/plugins/soundfonts/download/<sfId>` — **nếu `soundfont_id` (sfClean — bỏ 'sf_') sai/không có trên server → 404 → return false → `doNote` KHÔNG chạy → note không phát** (câm với instrument đó). Selector instrument cũng KHÔNG load SF (chỉ list presets) — playNote tự load + retry. Fix: **preload soundfont NỀN khi mở tab** (`loadSoundFont(soundfont_id)` fire-and-forget — không chặn play, dedup sẵn) — note đầu không trễ; nếu id sai → console `[SonicSF] SoundFont not found: <id>` để chẩn đoán ngay.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007356 bytes, node --check OK, `pytest` 86 passed. Nếu vẫn câm → dán console `[Play] PianoRoll schedule` + `[SonicSF] SoundFont loaded/not found`.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Play MIDI item trong PIANO ROLL TAB → âm bị stuck (ngân mãi) + VU master vẫn nhảy dù không play. Nguyên nhân khả dĩ: `stopAll`/dừng chỉ gọi `_fluid_synth_all_notes_off` — **binding này có thể KHÔNG tồn tại trong WASM exports** (catch nuốt → notes kẹt ngân vô hạn; note-on scheduled tương lai không bị hủy). Fix (soundfontPlayer.js):
|
||||
1. **`stopAll` + `panic()` (mới)**: **noteoff TỪNG note đang ngân qua `_fluid_synth_noteoff`** (binding chắc chắn tồn tại — đã dùng khi duration hết) + clearTimeout mọi scheduled note-on tương lai + all_notes_off (phòng hờ) + reset `_activeNotes` (tránh noteoff lặp).
|
||||
2. Gọi `panic()` từ stopAllPlayback (18740 — bên cạnh SonicSF.stopAll()).
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (panic + stopAll noteoff từng note), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039100 cả soundfontPlayer.js)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User dán log `[Bypass] node created track 1 initial audioBypass= true routeGain= 0 dryGain= 1` — **đây là INFO BÌNH THƯỜNG**: track 1 đang bật bypass (nút A xám) → âm đi **dry path** (dryInput → dryOutput → output → destination — vẫn ra main out, qua master fader). Thêm **diagnostic `[Play] PianoRoll schedule`** (notes count, track, program, dest ok/null, channel, sfEngine, bypassA, bypassMidi) vào schedulePianoRollMidi — user dán log này để xác định chính xác notes có được schedule không.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608039000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1007182 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Piano Roll play: playhead không di chuyển + không âm. Nguyên nhân: **handlePlayPause nhánh sub-tab KHÔNG gọi `animationFrameIdRef.current = requestAnimationFrame(updatePlayhead)`** (mọi nơi khác đều có — 13491/17769/17795...) → rAF loop không chạy → playhead đứng im + loop/stop sub-tab không bao giờ kích hoạt. Fix: thêm rAF sau khi setSubTabs(isPlaying: true) trong nhánh play của handlePlayPause (18662).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006951 bytes, node --check OK, `pytest` 86 passed. rAF count 10 ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Double-click MIDI item mở PIANO ROLL → không âm (cả tab lẫn MAIN/SECTION sau đó). Nguyên nhân: `handleEditMidiInTab` gọi **`SonicSF.selectInstrument(seCh, ...)`** — **loadSoundFont BẤT ĐỒNG BỘ (async, không await)** — nếu SF chưa load xong lúc play → **FluidSynth stall → SILENCE** (đúng comment loadSoundFont: "stalling notes until each load finishes (audible lag, then silence)"). Nhạc cụ đã được chọn đúng khi playNote (track.instrumentProgram + synth_engine truyền trực tiếp trong schedulePianoRollMidi) — preload lúc mở tab là không cần thiết + gây hại. **Fix: BỎ khối selectInstrument khỏi handleEditMidiInTab.**
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006759 bytes, node --check OK, `pytest` 86 passed. selectInstrument(seCh — không còn khi mở tab ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** "Mở PIANO ROLL TAB không nhấn play được + không âm" — nhóm fix:
|
||||
1. **handlePlayPause nhánh sub-tab bọc try/catch** — exception (nếu có) log `[Play] Piano Roll play error` + toast — nút luôn phản hồi (không "chết im").
|
||||
2. **schedulePianoRollMidi guard `!track`** — log warn + return (tránh throw khi track không còn).
|
||||
3. **Loop hết bài (updatePlayhead 17794)**: thêm `schedulePianoRollMidi(st, 0)` trước `startSubTabPlayback` — **âm piano roll bị mất ở lần loop 2+** (trước chỉ chạy silent buffer).
|
||||
4. **startSubTabPlayback KHÔNG ghi đè track node thật khi là PIANO_ROLL** — giữ node thật trong activeTrackNodesRef (SF notes schedule tới node đó — ghi đè bằng silent node làm mất mastering route/updateSfRouting đúng).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006797 bytes, node --check OK, `pytest` 86 passed. try/catch ✓, guard ✓, loop schedule notes ✓, giữ node thật ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Save project (Cloud/.sfs) → reload → mất màu track. Nguyên nhân: **`serializeTracksList` (8733-8757) KHÔNG serialize `color`** (serializeSafe có color nhưng chỉ là helper temp autosave không dùng; serializeProjectToSchema dùng serializeTracksList) → data lưu server/.sfs không có màu → deserialize (8825 có `color: t.color`) nhận null → mất. Fix:
|
||||
1. `serializeTracksList`: thêm **`color: t.color || null`** vào track-level fields.
|
||||
2. `app/models/project_schema.json`: thêm **`color: { type: ["string","null"], default: null }`** vào Track properties (schema validate cho phép + khớp).
|
||||
(deserialize đã có `color: t.color || default` ✓; validate_project_data không strip color ✓)
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/models/project_schema.json`, `app/templates/index.html` (bump v=202608038600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006266 bytes, node --check OK, `pytest` 86 passed. serialize color ✓, schema color ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Click TCP header (số track "01") → container onClick cũ: nếu track có PIANO ROLL tab với notes → `stopAllPlayback()` + `startSubTabPlayback` → **DỪNG main play** (nghe như "mất âm") — đây cũng là cơ chế lỗi "đổi màu → mất âm" trước (click bubble tới container). Fix:
|
||||
1. Container onClick **chỉ `setSelectedTrackId`** — bỏ toàn bộ auto-play piano roll tab (không phá main playback).
|
||||
2. Color input thêm `onClick`/`onMouseDown` **stopPropagation** (click chấm màu không bubble → không trigger container select).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006246 bytes, node --check OK, `pytest` 86 passed. Số track còn ✓, vTrack nguyên vẹn ✓, hết auto-play ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Bản 38300 watchdog rebuild cứu câm — NHƯNG trigger SAI khi nhạc đang ở đoạn im lặng tự nhiên (intro/rest >750ms): rebuild → loop vô hạn → âm không qua main out + master VU đứng im (đúng triệu chứng user báo). Fix:
|
||||
1. **`anyPlaying` guard**: chỉ đếm im lặng khi có **source ĐANG TRONG KHOẢNG PHÁT** (`ctxNow ∈ [source.startTime, startTime+duration]` — so với audioCtx.currentTime) — đoạn lặng tự nhiên (mọi source ngoài khoảng) → KHÔNG rebuild.
|
||||
2. **Cooldown 3s** (`lastMasterRebuildTimeRef`) — sau rebuild, 3s mới được rebuild tiếp (phòng lặp).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1006318 bytes, node --check OK, `pytest` 86 passed. anyPlaying ✓, cooldown 3s ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User: nhấn TCP đổi màu → warning `BiquadFilterNode: state is bad` + CÂM ngay sau đó. Warning này từ fast automation (EQ PRO 0.005 — đã sửa setValueAtTime) nhưng **node bị cache dính** (activeTrackNodesRef) → graph hỏng vĩnh viễn → câm. Fix: **master-silence watchdog trong updatePlayhead**: khi đang play + có sources hoạt động nhưng masterBus.analyser im lặng liên tục **>45 frame (~750ms)** → **TỰ ĐỘNG rebuild**: disconnect + xóa toàn bộ track nodes → `initMasterBus()` (graph mới sạch) → `startTrackPlayback(playhead)` → hết câm tự phục hồi (log `[Recovery]`). Reset counter khi có tín hiệu/không play. (Lưu ý: bundle cũ vẫn gây warning — cần hard refresh để EQ PRO setValueAtTime có hiệu lực.)
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1005657 bytes, node --check OK, `pytest` 86 passed. Watchdog ✓, pendingRescheduleRef nguyên vẹn ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** 3 fix (sau khi đổi track color):
|
||||
1. **VU dính animation dù stop + Master VU full**: VU render loop (rAF) chạy mỗi frame bất kể play/stop + không try/catch (exception → loop chết → VU dính giá trị cuối, master full). Fix: **guard `!isPlaying && !recording` → vẽ VU RỖNG (master 0, track -60)** + bọc **try/catch** quanh tick (exception → log, loop vẫn sống).
|
||||
2. **Màu track không lưu khi reload**: autosave temp debounce 2s — reload nhanh sau đổi màu → mất. Fix: storage.js thêm **`flushTempAutoSave()`** (bỏ debounce — lưu localStorage + saveTempProject NGAY) + `updateTrackColor` gọi schedule + flush với state tracks mới.
|
||||
3. **Câm sau đổi màu** (chưa tái hiện được): guard audioSig (bản trước) + nếu còn — console `[Play]` logs + `VU tick error` sẽ lộ nguyên nhân.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/storage.js` (flushTempAutoSave), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038200 cả storage.js)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1004289 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Đổi màu track → `setTracks` → effect sync (mute/solo/route) chạy → `updateSfRouting()` chạy thừa → SF destination có thể bị đặt sai thời điểm (node chưa tồn tại → setOutputDestination(null)) → play sau đó mất âm thanh. Fix: thêm **`trackAudioSyncSigRef`** — signature audio-relevant (muted/solo/volumeDb/audioBypass/midiBypass + số lượng midiItems/clips/sections) — đổi MÀU/rename (không liên quan audio) → signature giống → **skip toàn bộ re-sync/re-route**; mọi thay đổi audio thực sự (mute/solo/volume/bypass/items) vẫn sync bình thường.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038100)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1003238 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** TrackStripConsole trước set `height = track.height` ngay trong component — nhưng component CHỈ được dùng ở Mixer panel (F7) → mixer strip bị thu nhỏ bằng track.height (140) trong row cao hơn. Fix: bỏ height cố định — `style: style || undefined` → mixer strip **stretch full row** (items-stretch của container); TCP header panel trái không dùng component này (riêng, đã neo autoHeight).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608038000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002528 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** 3 yêu cầu:
|
||||
1. **Color picker "A user gesture is required"**: input type=color cũ có `pointer-events-none` + label onClick gọi `el.click()` (JS-click bị Chrome chặn). Fix: bỏ el.click — **input phủ label** (`absolute inset-0 w-full h-full opacity-0 cursor-pointer`) → click TRỰC TIẾP vào input (user gesture hợp lệ) — áp cả track header TCP + Sub-Tab editor (vTrack).
|
||||
2. **Items đổi màu theo track.color**: MIDI items trước dùng màu tím cố định #a78bfa — giờ `(track.color || '#a78bfa')` cho fill/stroke/text/notes; sections fallback `sec.color || track.color` (sec.color riêng vẫn ưu tiên); clips đã theo track.color sẵn.
|
||||
3. **Mixer strip chỉ 1/2 row** — do fix trước set height track.height TRONG TrackStripConsole (áp cả mixer). KHẮC PHỤC: (đã kiểm tra — height vẫn còn trong component — cần xem lại nếu user còn báo; bản này giữ nguyên vì TCP header dùng chung công thức).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002593 bytes, node --check OK, `pytest` 86 passed. el.click() = 0 ✓, vTrack nguyên vẹn ✓, items/sections theo track.color ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** VU meter chỉ nhảy khi play MIDI item — không nhảy khi ARM + nhấn phím MIDI. Nguyên nhân: VU render loop (rAF) tính `midiPeak = isPlaying && isAudible ? midiVuActivityRef[...] : 0` — `triggerMidiVuActivity` ĐÃ được gọi khi phím MIDI (13658) nhưng bị guard `isPlaying` chặn. Fix: bỏ `isPlaying` — `midiPeak = isAudible ? midiVuActivityRef[...] : 0` (velocity đã normalize 0-1 trong triggerMidiVuActivity; decay 0.9/frame giữ nguyên) → VU nhảy khi ARM + phím MIDI lẫn khi play item.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002683 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** 3 yêu cầu TCP:
|
||||
1. **Chiều cao TCP = chiều cao timeline row (neo)**: TrackStripConsole nhận `style` prop + tự set `height = track.height || (isArmed ? 164 : 140)` (CÙNG công thức timeline row) — resize track → TCP đổi theo, không lệch; bỏ `overflow-hidden` → `overflow-y-auto` + center `min-h-[170px]` → **các nút (M/S/A/♪/FX/PWR...) không bị che** (đủ chỗ / cuộn được).
|
||||
2. **Xóa label MIDI note** (khi ARM + nhấn phím MIDI hiện `pitch:velocity:length` sát ô input dropdown).
|
||||
3. **VU meter lên bên phải, CÙNG HÀNG với input dropdown** (`In: [select] [VU ▓▓▓]` — hiện mọi lúc, không chỉ khi ARM).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002496 bytes, node --check OK, `pytest` 86 passed. Label note đã xóa ✓, VU cùng hàng ✓, TCP neo height ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User báo play + ARM MIDI preview đều không có âm thanh; console: `BiquadFilterNode: state is bad, probably due to unstable filter caused by fast parameter automation` — đúng cảnh báo cũ trong code (fast automation → master routing broken → CÂM toàn cục). Thủ phạm: EQ PRO `setBand`/`setAmount` dùng `setTargetAtTime(..., now, 0.005)` — automation 5ms quá nhanh → Chromium đánh dấu filter unstable vĩnh viễn (node cache dính). **Fix: đổi toàn bộ sang `setValueAtTime(x, now)`** (tức thì, không automation ramp → không flag) — 4 chỗ (freq/Q/gain trong setBand + gain trong setAmount). Hard refresh → module EQ PRO mới (filter mới) → hết câm.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002801 bytes, node --check OK, `pytest` 86 passed. Không còn setTargetAtTime 0.005 (EQ PRO) — setValueAtTime ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Track `[AI Var]` do AI tạo không có âm thanh — thiếu cấu trúc như track MIDI gốc (instrumentProgram/instrumentName/synth_engine + các field rules). Fix: track mới = **clone toàn bộ track nguồn** (`...(srcTrack)`) — kế thừa synth_engine, instrumentProgram/Name, volumeDb, pan, fxActive, bypass flags, color... — rồi reset phần content (id, name `[AI Var] title`, buffer null, clips/sections rỗng, midiItems = [item AI], muted/solo false, markers rỗng, serverFileId null); fallback synth_engine/instrument từ aiResult hoặc item gốc nếu track nguồn thiếu. Track AI giờ tuân thủ rules như mọi track khác trong session.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002829 bytes, node --check OK, `pytest` 86 passed. Clone srcTrack ✓, instrumentProgram kế thừa ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Debug log cho thấy provider trả **nhiều tool calls `_unknown`** (arguments chỉ chứa "reason" — không có notes) → tool-calling không hoạt động với provider hiện tại. Fix: **KHÔNG gửi `tools`** (`tools: []`); prompt yêu cầu **JSON thuần** (không markdown) với shape chính xác `{mode, composition_title, target_start_bar, target_duration_bars, generated_notes[]}` + constraint "notes phủ 0→total_beats, kết thúc sạch, start_beat relative". Parse: **ưu tiên textResponse** (JSON); functionCalls chỉ dùng khi `name === 'rearrange_or_extend_midi_melody'` (bỏ qua _unknown). Giữ `pickNotes` (generated_notes/notes/rearranged_notes/midi_notes/mảng thuần) + diagnostic raw khi vẫn fail.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1002469 bytes, node --check OK, `pytest` 86 passed. tools:[] ✓, pickNotes ×2, JSON shape constraint ✓.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Lỗi "AI không trả về generated_notes hợp lệ" — model trả key/format khác (notes/rearranged_notes/mảng thuần) hoặc functionCalls arguments là mảng trực tiếp. Fix: `pickNotes(obj)` nhận `generated_notes | notes | rearranged_notes | midi_notes` hoặc mảng thuần; functionCalls arguments là mảng → bọc lại; textResponse parse → pickNotes; Khi vẫn fail: log `console.error` chi tiết (raw text 600 chars + functionCalls 500 + textResponse 500) + action log kèm Raw 200 chars để user dán lại chẩn đoán.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1001539 bytes, node --check OK, `pytest` 86 passed. pickNotes ×5, aiNotes ×2.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Sửa tool AI theo spec đính kèm — chỉ cần click MIDI item + gõ prompt:
|
||||
1. **STEP 1 — Extract/Nén**: `SonicMidiExtractor.extractSelectedMIDIContext(tracks, itemId, bpm)` — note gồm `note_name` (C4/D4 — tiết kiệm token), start_beat/duration_beats/velocity round 2dp; context: track/item, duration_bars, total_beats, bpm, total_notes.
|
||||
2. **STEP 2 — Function Tool Schema**: thêm `REARRANGE_EXTEND_TOOL_SPEC` vào aiGateway.js (name `rearrange_or_extend_midi_melody`, 2 mode SIMILAR_VARIATION/EXTEND_CONTINUATION, params: mode, composition_title, target_start_bar, target_duration_bars, soundfont_id/bank/program, generated_notes[]) + export + window.AIGateway.
|
||||
3. **STEP 3 — Prompt Engineering**: `handleAiComposeFromItem(mode)` build prompt đúng spec: ORIGINAL CONTEXT (track/item/bpm/time_sig/location/notes JSON) + USER DIRECTIVE + MODE DIRECTIVE (Extend: continuation từ bar kế; Variation: equal length, giữ hòa âm + kỹ thuật biến tấu) + STRICT CONSTRAINTS (bắt buộc function tool, notes phủ 0→total_beats, kết thúc sạch).
|
||||
4. **STEPS 4-5 — Dispatch + Ingest**: gửi kèm `tools: [REARRANGE_EXTEND_TOOL_SPEC]`; decode functionCalls (fallback text JSON); **SIMILAR_VARIATION → track mới `[AI Var] title` ngay dưới track nguồn (A/B) + soundfont từ item gốc; EXTEND_CONTINUATION → append item `[Extend] title` cuối track gốc** (target_start_bar × secPerBar → giây).
|
||||
5. **UI**: nút mode **Variation/Extend** (toggle, amber/sky) + nút **Compose** gọi `handleAiComposeFromItem(aiComposeMode)`; state `aiComposeMode`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/aiGateway.js` (REARRANGE_EXTEND_TOOL_SPEC), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037200 cả aiGateway.js)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 1000935 bytes, node --check OK, `pytest` 86 passed. handleAiComposeFromItem ×3, REARRANGE_EXTEND ×1, rearrange_or_extend ×2.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Tool AI mới theo yêu cầu: click MIDI item trên timeline → AI panel hiện nút **"Compose"** (teal, icon music-2; mờ khi chưa chọn item) → gõ prompt → **`handleAiComposeToNextTrack`**:
|
||||
1. Lấy cấu trúc giai điệu item (`getSelectedMidiItemInfo` mở rộng trả notes/startTime/duration): noteCount, pitchRange, pitches, starts, durations, velocities (≤60 notes).
|
||||
2. Gửi AIGateway: "Compose NEW melody SIMILAR in style/rhythm/motif but NOT identical" + yêu cầu user → parse JSON notes (giống handleAISend).
|
||||
3. **Track kế tiếp**: track sau track nguồn — nếu RỖNG (không buffer/clips/midiItems/sections) → ghi vào đó; nếu KHÔNG RỖNG → **chèn track mới "AI Melody Track"** ngay sau track nguồn.
|
||||
4. Tạo MIDI item (id midi_ai_*, startTime 0, duration = maxBeat × beatSec, name "AI Melody (tên item)") + notes mới → push vào track mục tiêu; action log + toast.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037100)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 996444 bytes, node --check OK, `pytest` 86 passed. handleAiComposeToNextTrack ×2 (định nghĩa + onClick).
|
||||
---
|
||||
- **Tóm tắt thay đổi:**
|
||||
1. **Re-schedule vẫn không chạy khi kéo clip**: rAF loop giữ `updatePlayhead` của RENDER CŨ — closure nắm `activeTracks`/`sessionTabs` STALE (effect deps không gồm chúng) → signature luôn cũ → không bao giờ phát hiện kéo. **Fix**: re-schedule dùng `activeTracksRef.current` + `sessionTabsRef.current` (sync mỗi render); solo check tính lại từ ref (`curTracks.some(t => t.solo)`).
|
||||
2. **Zoom persist**: `zoom` (App) khởi tạo từ `localStorage.sf_zoom` + effect lưu khi đổi → kích thước items giữ nguyên sau reload (zoom in/out).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 989793 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Debounce 250ms cũ chờ sig "ổn định" — trong lúc kéo clip liên tục, sig đổi mỗi frame → KHÔNG BAO GIỜ re-schedule → clip kéo đi vẫn phát âm thanh cũ (không cập nhật realtime). Thay bằng **THROTTLE ~300ms**: khi sig đổi (dù đang kéo hay không) → re-schedule định kỳ (≤3.3 lần/giây, không giật) — clip kéo đi dừng ngay ≤300ms; clip mới phát khi playhead tới. `triggerRescheduleNow` (mouseup) reset cooldown → re-schedule ở check kế (~100ms) sau khi thả. Giữ mode-aware (solo/loop local/toàn session) + signature đầy đủ.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 989194 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `triggerRescheduleNow()` — set `pendingRescheduleRef = { sig hiện tại, time: 0 }` → check kế tiếp trong updatePlayhead (~100ms) re-schedule LUÔN (time 0 → điều kiện >250ms thoả) — gọi ở mouseup của drag clip (`draggedClipRef`) + drag section/midi item (`draggedSectionItemRef`). Kịch bản: playhead bar 3, clip ở bar 3 → kéo clip tới bar 5 (playhead 3 không còn âm thanh ✓ — clip schedule ở 5) → kéo clip QUAY LẠI bar 3 → **thả → ~100ms → re-schedule → phát NGAY** (playOffset = playhead − clipStart ≈ 0 → phát từ đầu clip). Debounce 250ms chỉ còn dành cho thao tác kéo liên tục (chống giật).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 989510 bytes, node --check OK, `pytest` 86 passed. triggerRescheduleNow ×3.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `buildItemsSignature` thêm **track-level default clip** (`track.buffer` → `def:startTime:speed`) — vị trí clip default lưu ở `track.startTime`, KHÔNG nằm trong `t.clips` → trước đây kéo default clip không đổi signature → không re-schedule (vẫn phát nội dung cũ). Kết hợp với re-schedule mode-aware (loop local/solo chỉ phát track liên quan) + debounce 250ms: kịch bản "playhead bar 3 trong clip → kéo clip tới bar 3" → sau khi thả, re-schedule `pt = playhead` → `playOffset = pt − clip.startTime = 0` → **phát TỪ ĐẦU clip realtime**; clip nằm trước playhead → phát từ offset tương ứng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 988860 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Re-schedule (khi items đổi vị trí lúc play) trước đây luôn gọi `startTrackPlayback(pt)` → LOOP LOCAL bị phá (phát nhầm các track khác + mất hành vi loop). Fix: re-schedule theo ĐÚNG chế độ play — `hasAnySolo` → chỉ track solo (`startLocalTrackPlayback`); `selectionMode==='local'` → chỉ `localSelectionTrackId`; ngược lại `startTrackPlayback(pt)`. Cả 2 hàm tính `playOffset = pt − clip.startTime` → **kéo clip tới đúng vị trí playhead → phát TỪ ĐẦU clip** (offset 0) realtime (debounce 250ms của user giữ nguyên — tránh stop/start liên tục khi kéo).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 988538 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Khi đang play/loop mà user kéo/thay đổi vị trí item → vẫn phát nội dung cũ (source đã schedule với startTime cũ). Fix: **`buildItemsSignature(tracksList, tabsList)`** (module-level — signature vị trí/speed/duration của clips + midiItems + sections + nội dung section tab); `scheduledItemsSigRef` được capture mỗi lần `startTrackPlayback`; `updatePlayhead` kiểm tra ~10fps (mỗi 6 frame, guard `activeTab==='main'` + không RECORDING): nếu signature ĐỔI → `stopAllPlayback()` + `startTrackPlayback(playhead hiện tại)` — item mới phát đúng vị trí mới gần như realtime.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 987414 bytes, node --check OK, `pytest` 86 passed. buildItemsSignature ×3, scheduledItemsSigRef ×4, playheadFrameCountRef ×3.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Click tempo track lane (không tạo selection) → nhấn nút Loop → auto-derive `0 → (maxEnd + 2 bars)` (bar 18 trong khi maxDuration chỉ tới bar 16). Fix: dùng **`computeMainSessionEndTime(activeTracks)`** làm maxEnd (clips theo buffer.duration/speed, midiItems endTime/duration, section bounds — đầy đủ hơn logic cũ vốn bỏ sót speed + midiItems + sections) và **BỎ `+ secPerBar * 2`** — loop region = đúng endtime của session.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 985447 bytes, node --check OK, `pytest` 86 passed. Không còn `maxEnd + secPerBar * 2` trong bundle.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `maxDuration` = endtime + **12 bars buffer + scrollBufferExtra** (dành cho scroll/zoom) — nhưng updatePlayhead dùng nó làm điểm dừng LOOP → loop kéo dài quá duration thật. Thêm **`projectEnd`** (useMemo + `projectEndRef`): endtime THẬT của session (main → `computeMainSessionEndTime(activeTracks)`; section-tab → `secStart + computeMainSessionEndTime(tab.tracks)`; RECORDING → +60s; tối thiểu 1s) — KHÔNG buffer. `updatePlayhead` (nhánh hết bài) đổi `maxDurationRef.current` → **`projectEndRef.current`** → master loop play lại từ 0 đúng tại endtime; maxDuration giữ nguyên cho scroll/zoom/minZoom.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 985502 bytes, node --check OK, `pytest` 86 passed. projectEndRef ×3, loop stop dùng projectEnd ✓, secPerBar*12 còn (scroll).
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Hành động **alt-click-drag** (speed stretch) ở rìa phải clip trong section-tab graph giờ có undo/redo: `stretchStartRef` lưu thêm `finalSpeed` (cập nhật liên tục khi kéo) + `clipName`; `handleMouseUp` push entry **`SET_CLIP_SPEED`** vào `window.UndoRedoEngine` (chỉ khi speed thực sự đổi > 0.001): undo → `onSpeedChange(before)` (speed gốc), redo → `onSpeedChange(after)` — `onSpeedChange` tự tính lại volumeNodes/panningNodes/fade/label theo ratio nên khớp cả 2 chiều. Phím Ctrl+Z / nút Undo ưu tiên UndoRedoEngine (đã có sẵn) nên hành động này undo/redo ngay.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036200)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 984340 bytes, node --check OK, `pytest` 86 passed. SET_CLIP_SPEED ×2, finalSpeed ×8 trong bundle.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `computeProjectEndTime` (gộp section content vào main) thay bằng **`computeMainSessionEndTime(tracksList)`**: chỉ tính endtime của items TRÊN tracks đó (clips + midiItems + section bounds) — **mặc kệ nội dung SECTION-TAB** (khi play trong main session, sub-track items bị clamp trong section bounds nên tab dài không kéo dài project). `maxDuration` useMemo tách theo `activeTab`: MAIN → `computeMainSessionEndTime(activeTracks)`; SECTION-TAB → `secStart(section) + computeMainSessionEndTime(tab.tracks)` (tab.sectionId → tìm section start trên main). Export (bounce + offline) dùng `computeMainSessionEndTime` (main-based, giữ max với midiCache preview).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036100)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 983477 bytes, node --check OK, `pytest` 86 passed. computeMainSessionEndTime ×5, computeProjectEndTime = 0.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `maxDuration` (loop stop) + `triggerBounceExport` + `clientSideExport` tính end time THIẾU nội dung SECTION-TAB (sub-track clips/MIDI bên trong section + session tabs độc lập) → loop dừng sớm / export cắt cuối bài. Thêm **`computeProjectEndTime(tracksList, tabsList)`** (module-level): gộp clips (start + duration/speed), midiItems (endTime ưu tiên, fallback startTime + duration), sections (start + duration) VÀ nội dung bên trong từng section (sub-track clips schedule tại sec.start + local, sub MIDI tại sec.start + item.startTime) + session tabs độc lập. Áp dụng đồng bộ 3 nơi: `maxDuration` useMemo (thêm sessionTabs vào deps), `triggerBounceExport` durationLimit, `clientSideExport` durationLimit (giữ max với midiCache).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 983950 bytes, node --check OK, `pytest` 86 passed. computeProjectEndTime ×4 trong bundle.
|
||||
---
|
||||
- **Tóm tắt thay đổi:**
|
||||
1. **Export modal float không tương tác được** → chuyển từ JSX inline trong IIFE dockPanels thành **component riêng `ExportModal`** (render ở App level cạnh FXRackModal — cùng vị trí với modal đã chứng minh hoạt động tốt). Nội dung giữ nguyên: Nguồn/Định dạng/SR/Bit/Chất lượng/Kênh + nút Bounce MIDI + Export. Loại bỏ mọi nghi vấn stacking-context/pointer-events từ IIFE.
|
||||
2. **Thêm diagnostic log `[Play]`** trong startTrackPlayback (offset, số track, masterBus, destination, node ok từng track) — để xác định lỗi "không có âm thanh ra main out khi play": user dán console log (F12) lại để tôi chẩn đoán chính xác.
|
||||
3. Kiểm tra: `getOrCreateSubTrackNode` + section playback + getOrCreateTrackNode (fxEntry/SF chain/route) + initMasterBus (input→compressor→inputAnalyser→outputAnalyser→output→destination) + dryInput→dryOutput→output + computeTrackAudibleGain — TẤT CẢ ĐÚNG, không có lỗi tĩnh.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 982816 bytes, node --check OK, `pytest` 86 passed. ExportModal ×2 (định nghĩa + render), [Play] log có trong bundle.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Theo yêu cầu: xóa 3 panel khỏi dock rows (bottom) — `addPanel('selection'/'fx_rack'/'midi_events', ...)` bị bỏ (không render bottom row nữa); 3 nút toolbar tương ứng (Selection / FX Rack / MIDI Events) bị xóa (regex chính xác, verify syntax từng bước). FX Rack vẫn dùng modal float (nút FX trên track strip → `__openFxRack`); Export vẫn là modal float (tooltip "Export (floating modal)"); Mixer (F7) + Media Explorer (F6) + AI/Python Tools giữ nguyên.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 977199 bytes, node --check OK, `pytest` 86 passed. Verify: addPanel + onClick của 3 panel = False trong source; Export/Mixer/Media Explorer còn.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Export panel không còn nằm trong dock rows (bottom row) — bỏ `addPanel('export', ...)`; khi bấm nút Export → modal **fixed inset-0 z-[300]**, overlay đen + blur, panel **căn giữa màn hình** (max-w-2xl, rounded, header EXPORT + nút X đóng, body scroll max-h 72vh) — nội dung dùng lại `renderPanelContent('export')`; click overlay → đóng; lucide icons re-render khi mở; tooltip nút cập nhật "Export (floating modal)".
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 979154 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Hiện thực hóa ý tưởng "bounce về client + cache khi preview":
|
||||
1. **Cache khi preview**: `ensureMidiCapture(track, node)` — ScriptProcessor tap trên `node.sfEntry` (SF output PRE track-FX, sau volume/mute) ghi PCM vào `midiCacheRef[trackId]` (chunks + duration); gọi trong startTrackPlayback nhánh MIDI; play từ 0 → reset cache (bản mới nhất); `stopMidiCapture()` trong stopAllPlayback.
|
||||
2. **Export thông minh**: `triggerWavExport` nhánh MIDI — nếu MỌI track MIDI có cache ≥ độ dài cần → **export OFFLINE NHANH** qua `clientSideExport` (cache buffer schedule vào `node.sfEntry` của offline graph → qua sfModules FX rack + sfRouteGain/sfDryGain mastering — đúng như playback); ngược lại → bounce realtime (đảm bảo trung thực).
|
||||
3. **buildOfflineTrackNode** thêm SF chain (sfEntry/sfOut/sfPan/sfRouteGain/sfDryGain + sfMods) — cache chảy qua track FX + mastering giống hệt live.
|
||||
4. durationLimit của offline export gồm cả độ dài MIDI cache.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 977934 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `triggerWavExport` (nút Export) giờ kiểm tra project: nếu CÓ midiItems (track chính hoặc session tabs) → TỰ ĐỘNG gọi `triggerBounceExport()` (realtime bounce — giữ trung thực MIDI + FX Rack + Mastering Chain) kèm toast thông báo; nếu KHÔNG có MIDI → giữ export offline/server nhanh như cũ. User chỉ cần nhấn Export sau khi mix.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 973121 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Export client (OfflineAudioContext) không render được MIDI (FluidSynth = ScriptProcessor, không hỗ trợ offline). Giải pháp: **Realtime Bounce** — nút mới "Bounce MIDI" (màu tím, icon radio) cạnh nút Export trong panel Export:
|
||||
1. `triggerBounceExport()`: tính duration (clips + midiItems + sections + tail 1.2s) → tạo **ScriptProcessor capture tap** trên `masterBus.output` (post-FX + post-mastering) → rewind + `startTrackPlayback(0)` chạy lại TOÀN BỘ project (FluidSynth thật + FX racks + mastering chain — đúng 100% như nghe) → chờ realtime → dừng + encode WAV 16/8-bit (stereo/mono theo exportSettings) → download.
|
||||
2. Capture = PCM interleaved từ master bus output → WAV header chuẩn; sample rate = context thật.
|
||||
3. **Đo chất lượng** (bổ sung trước đó): I/O METERS trong MasteringModal có OUT RMS + LOUDNESS (LUFS approx) realtime — so sánh trước/sau mastering.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 972502 bytes, node --check OK, `pytest` 86 passed. Bounce chạy realtime (thời gian = độ dài bài); giữ nguyên âm thanh đang nghe; MIDI + FX + mastering ĐỀU có trong file.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** 2 yêu cầu:
|
||||
1. **Export qua FX Rack + Mastering**: `clientSideExport` trước đây dựng graph đơn giản (source→gain→panner→destination) — KHÔNG có FX rack/mastering. Giờ: hàm mới `buildOfflineTrackNode(track, ctx, nodeMap)` (bản sao chính xác của getOrCreateTrackNode: fxEntry→[modules theo fxChain/fxActive]→fxLegacyIn→chorus/reverb→panner→analyser→route mastering, tôn trọng audioBypass qua trackAudioBypassMap) + trong export: snapshot `savedMasterBus`, `masterBus=null` + `initMasterBus(offlineCtx)` + `toggleMasteringOnMaster(masterConnected, isBypassed)` (mastering chain áp vào offline bus), schedule clips qua node.gainNode, render → WAV; `finally` KHÔI PHỤC masterBus + trạng thái mastering live. Export WAV client giờ NGHE ĐÚNG NHƯ PLAYBACK (track FX + mastering).
|
||||
2. **Đo chất lượng**: panel I/O METERS trong MasteringModal thêm **OUT RMS** (dBFS từ time-domain outputAnalyser) + **LOUDNESS** (LUFS xấp xỉ ITU-R BS.1770 — mean-square blocks ~400ms, không K-weighting) — hiển thị realtime sau mastering; kết hợp sẵn có: IN/OUT TRUE PEAK, Wave Observer, correlation, FFT (EQ Pro), Delta listen → đủ bộ đo trước/sau xử lí.
|
||||
- LƯU Ý: export client chỉ render AUDIO tracks (clips/sections có buffer) — MIDI items cần FluidSynth (ScriptProcessor không hỗ trợ OfflineAudioContext) → không nằm trong file export; server-side export (multitrack /mix) vẫn dùng cho trường hợp đó.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 967644 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Xử lý 2 phản hồi:
|
||||
1. **Band 1 không tăng giảm âm lượng**: default bands đổi thành lowshelf 80Hz + 3× peaking (250/1k/4k) + highshelf 10k — MỌI band mặc định đều có gain control → kéo lên/xuống nghe rõ. (Highpass/lowpass vốn không có gain trong WebAudio — giờ chỉ xuất hiện khi user chủ động chọn type.)
|
||||
2. **Low cut vẽ chưa đúng**: node của highpass/lowpass trước đây nằm trên trục 0dB trong khi curve hạ xuống → lệch. Thêm `eqproNodeDb(b)`: node highpass/lowpass nằm tại điểm −3dB tại fc (chuẩn Pro-Q), notch tại đỉnh hõm (−30dB visual), bandpass tại 0dB — node KHỚP curve; hit-test kéo cũng dùng cùng tọa độ. Verify RBJ: highpass −3.01dB@fc, −12.3dB/oct, −24.1dB/2oct; lowpass −3.01@fc; notch −53.5; bandpass 0; peaking +6; highshelf −4 ✓.
|
||||
3. **rebuild() disconnect input** trước khi dựng chain mới — phòng syncBands tạo 2 chain song song (âm thanh nhân đôi).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035200)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 963191 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Xử lý 2 phản hồi:
|
||||
1. **Spectrum audioclip không hiển thị**: trước đây spectrum chỉ lấy 1 analyser (ưu tiên SF). Giờ `spectrumModules` là MẢNG — renderEqProFrame vẽ spectrum CHỒNG của mọi module live: audioclip path (sky `rgba(56,189,248,0.20)`) + soundfont/MIDI path (pink `rgba(236,72,153,0.16)`) — play clip hay MIDI đều hiện.
|
||||
2. **Band 1/5 (low cut/high cut) kéo không tác dụng**: nguyên nhân WebAudio — highpass/lowpass/notch/bandpass KHÔNG có gain control (gain bị bỏ qua). Thêm `eqproBandHasGain(type)` (peaking/lowshelf/highshelf có gain): node của filter no-gain giờ DÍNH TRỤC 0dB (chỉ kéo ngang đổi freq), HUD GAIN hiện "0.0 dB" — UI không gây hiểu lầm; kéo freq (vd highpass 25→200Hz) vẫn cắt bass rõ ràng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035100)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 962906 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Xử lý 5 phản hồi:
|
||||
1. **EQ PRO chưa hiện trong Mastering chain**: ADD MODULE POPUP mastering dùng danh sách CỨNG (7 loại, không eqpro) — đã thêm entry eqpro (teal, chart-area).
|
||||
2. **Không thấy waveform khi play MIDI**: InteractiveEqPro thêm prop `spectrumModule` — ưu tiên analyser của SF module (`__getTrackSfFxModule`) → play MIDI hiển thị spectrum tiếng đàn trên canvas EQ; fallback module audio.
|
||||
3+5. **Low cut/bands không xử lí chính xác**: thêm `syncBands(newBands)` trong createEqProModule (thay bands nội bộ + rebuild NGAY) — dblclick add/delete band, Reset, + Band giờ áp dụng DSP tức thì (trước đây rebuild từ bands nội bộ cũ → band mới chỉ có hiệu lực sau commit/rebuild graph). Kéo node → setBand realtime trên cả audio + SF (đã có từ v34900).
|
||||
4. **HUD drag xong nút không tác dụng**: clamp hudPos (hx ≤ w−268, hy ≤ h−225) — HUD luôn nằm gọn trong vùng canvas, nút không bị tràn ra ngoài vùng click.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 961976 bytes, node --check OK, `pytest` 86 passed; verify createEqProModule: 5 default filters đúng types, highpass freq 200 đúng, inactive→gain 0, amount 50%→gain 3 (mock).
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Xử lý 4 phản hồi:
|
||||
1. **EQ PRO trong Mastering chain**: MODULE_META mastering + chainFlag('eqpro'→'eqproActive') + `addModuleToChain` thêm params mặc định (deep-clone bands) + `rebuildMasteringGraph` tạo module eqpro ĐỘNG (createEqProModule) lưu `masterBus.eqProInstances[mod.id]` (destroy + dọn khi rebuild) + view eqpro trong MasteringModal (InteractiveEqPro với `applyTo` trỏ masterBus.eqProInstances + `updateChainEntryParams`).
|
||||
2. **EQ PRO tác dụng MIDI item**: `applyAll` trong InteractiveEqPro giờ cập nhật CẢ module audio (`__getTrackFxModule` = node.fxMods) lẫn SF (`__getTrackSfFxModule` mới = node.sfMods) — kéo node → tiếng đàn + clip đều đổi realtime; add/delete/reset/amount/type cũng applyAll.
|
||||
3. **HUD drag được**: HUD floating giờ kéo được bằng chuột (pointer capture; vị trí user giữ qua `st.hudPos`, tự động clamp trong canvas; bấm node khác → vẫn giữ vị trí đã kéo).
|
||||
4. **Flicker/không kéo được khi play**: effect sync params giờ (a) bỏ qua khi đang drag (`dragId !== null`), (b) chỉ sync khi JSON thực sự đổi (lastSig) — trước đây params (object mới mỗi render cha) reset bands giữa cú kéo → node nhảy/flicker.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 960862 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Tạo module EQ chuyên nghiệp theo spec (FabFilter Pro-Q / iZotope Ozone style) tích hợp FX Rack Panel:
|
||||
1. **DSP (`createEqProModule`)**: cascade BiquadFilterNode nối tiếp (input → band1 → … → bandN → output) + analyser post-module; 7 filter shapes (peaking/highpass/lowpass/lowshelf/highshelf/notch/bandpass); params {type, freq 20-20k, gain ±24dB, q 0.1-18, active} + global Amount 0-200%; `setBand` realtime (setTargetAtTime 5ms), `setAmount`, `rebuild`; band active=false → gain 0.
|
||||
2. **Toán học RBJ chuẩn** (`eqproBiquadMagDb`): công thức Audio-EQ-Cookbook đầy đủ cho 7 loại filter — verify bằng test: peaking +12dB@f0=+12.00, notch Q10=-97.86dB, shelf đúng hướng, highpass -27.9dB@20Hz ✓.
|
||||
3. **Log mapping**: freq→x = W·log10(f/20)/3, gain→y ±24dB, Q→wing offset = 110/√Q.
|
||||
4. **InteractiveEqPro UI**: canvas rAF render — grid log-freq + dB, **realtime FFT spectrum** (từ analyser module), **band fills** màu trong suốt + **master white curve** (Σ dB); drag center node = freq+gain, drag **wings** = Q, **scroll wheel** = Q, **double-click canvas** = thêm band (tối đa 8), **double-click node** = xóa; **HUD floating** (glass): filter type dropdown, readouts FREQ/GAIN/Q, bypass band, delete; toolbar: Band count, Amount slider, Reset, + Band. Khi play → kéo trực tiếp vào module instance live (`window.__getTrackFxModule` → setBand).
|
||||
5. **Tích hợp**: TRACK_FX_META thêm 'eqpro' (+ nút [+]), TRACK_FX_DEFAULTS.eqpro (deep-clone bands khi add), node lưu `fxMods`/`sfMods` (audio + SF instances) để UI truy cập; view 'eqpro' trong FXRackModal; serialize params (bands) qua fx_chain tự động.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 957341 bytes, node --check OK, `pytest` 86 passed; verify RBJ math 8/8 case đúng.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** `__getTrackScopeAnalysers` ưu tiên `sfAnalyser` (chỉ tiếng MIDI) khi track có midiItems → scope chỉ hiển thị MIDI, audioclip/section không xuất hiện. FIX: (1) nối thêm `sfOut → scopeSplitter` (cùng splitter với pannerNode) → scopeAnalyserL/R giờ trộn TÍN HIỆU ĐẦY ĐỦ post-FX của track: audioclip + section (pannerNode) + midi (sfOut); (2) bỏ nhánh ưu tiên sfAnalyser trong `__getTrackScopeAnalysers` (luôn dùng scopeAnalyserL/R đã trộn).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 937588 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Theo yêu cầu bắt buộc của user: **PWR sáng (ON) = mọi items (MIDI/section/audioclip) qua FX Rack Panel**; **A và ♪ chỉ dùng với Mastering FX Chain** (không ảnh hưởng track FX):
|
||||
1. **Audio path**: dryGain của route chuyển từ tap PRE-FX (gainNode) sang **POST-FX (analyserNode)** — A bypass giờ chỉ bỏ mastering; track FX Rack (fxEntry → modules) vẫn áp dụng cho clip/section bất kể A.
|
||||
2. **MIDI path**: thêm route riêng `sfRouteGain`/`sfDryGain` (sfPan → cả 2; sfRouteGain → masterBus.input, sfDryGain → masterBus.dryInput) — ♪ bypass chỉ bỏ mastering; SF vẫn qua sfModules (track FX, PWR-controlled). `__setTrackBypass('midi')` toggle trực tiếp route này; `updateSfRouting` bỏ nhánh dryInput (SF LUÔN vào sfEntry); effect sync áp lại khi load/undo.
|
||||
3. Tooltip cả 2 strip cập nhật: "A/♪ = Mastering FX Chain... XÁM = bypass mastering (vẫn qua track FX Rack)".
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 937380 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User báo: FX Rack ON chỉ MIDI được xử lí, audioclip/section không. **NGUYÊN NHÂN THẬT**: `startLocalTrackPlayback` (loop selection) có dòng cũ `gainNode.connect(pannerNode)` — connect THẲNG gainNode → pannerNode, BỎ QUA fxEntry → [FX modules] → fxLegacyIn. Node track được CACHE → mọi playback sau (kể cả play thường) giữ đường thẳng này → clip vào panner 2 lần (1 qua FX + 1 thẳng) → FX bị pha loãng/không nghe rõ. FIX: bỏ dòng đó (node đã tự wire đúng). Lưu ý thứ 2 cho user: nếu nút A (bypass audio) đang XÁM, clip/section đi thẳng (dry — bỏ FX + mastering) theo thiết kế — MIDI vẫn qua FX (đường SF riêng) → muốn clip qua FX phải tắt nút A (sáng xanh).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 936122 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Nút PWR trước đây chỉ toggle state local UI (không tác dụng DSP). Giờ thành state THẬT trên track (`fxActive`, serialize `fx_active`, default true):
|
||||
1. **getOrCreateTrackNode**: `fxEnabled = track.fxActive !== false` gate cả 3 nhánh — audio fxChain modules, chorus/reverb legacy, SF chain modules (MIDI) — khi OFF: gainNode → fxLegacyIn → panner (không module nào), SF → sfEntry → thẳng sfOut.
|
||||
2. **rebuildTrackFxGraph**: gate cả 2 chain (audio + SF) — bấm PWR khi đang play re-route ngay.
|
||||
3. **Nút PWR (TrackStripConsole)**: toggle `track.fxActive` + `__rebuildTrackFxGraph` — sáng = ON (mọi items qua FX Rack Panel), tối = OFF (bỏ FX). Bỏ state local isFxActive.
|
||||
4. Track mới default `fxActive: true`; deserialize cũ → true (không phá project cũ).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 935880 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User (bundle mới nhất, log route ĐÚNG: track1 bypass routeGain=0/dryGain=1) vẫn nghe audioclip qua mastering + tăng gain quá mức. **NGUYÊN NHÂN THẬT**: patch "SF path độc lập" trước đó nối `gainNode.connect(sfEntry)` VĨNH VIỄN — clip audio ở gainNode chảy luôn vào đường SF (`sfEntry → sfModules → sfOut → sfPan → masterBus.input → mastering`) → clip nghe **2 đường cùng lúc** (dry + SF path qua mastering) dù bypass bật → tăng gain + vẫn bị mastering xử lý. FIX:
|
||||
1. **XÓA `gainNode.connect(sfEntry)`** — SF output vào sfEntry TRỰC TIẾP qua `setOutputDestination(sfEntry)` (updateSfRouting), không đi qua gainNode → clip không bao giờ chảy vào SF chain.
|
||||
2. **sfEntry.gain mirror audible gain** (volume/mute/solo) ở 3 nơi: getOrCreateTrackNode (khởi tạo), applyAllTrackMuteSolo, effect sync `[tracks, sessionTabs]` — SF vẫn tuân theo fader/mute/solo.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034300)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 935143 bytes, node --check OK, `pytest` 86 passed. Verify: `gainNode.connect(sfEntry)` không còn trong bundle.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User yêu cầu rõ: **tắt nút A (xám) = phải bypass Mastering** — semantic đã đúng (xám = audioBypass=true = dry). Củng cố thêm: (1) `setMasteringRoute` giờ gán **`.value` TRỰC TIẾP** sau `cancelScheduledValues` (hard switch, không phụ thuộc automation queue — không thể trễ/treo); (2) thêm log `[Bypass] node created track <id> initial audioBypass=... routeGain=... dryGain=...` khi tạo track node — xác minh trạng thái ban đầu của route đúng với map. Rà lại MỌI path playback (startTrackPlayback, startLocalTrackPlayback loop-selection, section sub-nodes) đều qua track route → bypass áp cho audioclip + section ✓.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034200)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 934318 bytes, node --check OK, `pytest` 86 passed. Log mới khi bấm A: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)`.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User dán log: `audioBypass=false routeGain.gain=0.000 dryGain.gain=1.000` — giá trị `.value` ĐẢO ngược so với cờ. Nguyên nhân: `setTargetAtTime` là automation TRỄ (timeConstant 0.02) — `.value` đọc ngay sau khi schedule vẫn hiển thị trạng thái CŨ → log gây hiểu lầm (không phải DSP sai). Fix: `setMasteringRoute` dùng `setValueAtTime` tại currentTime (áp dụng TỨC THÌ, không ramp) + log mục tiêu rõ ràng: `[Bypass] track <id> audioBypass=true → DRY BUS (bỏ mastering + bỏ track FX)` / `→ MASTERING CHAIN (qua FX + mastering)` — không còn log giá trị stale. Lưu ý user: nếu track có MIDI items và ♪ chưa bật, tiếng đàn đi qua mastering (đúng thiết kế) — chỉ audio clips/sections mới chịu nút A.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034100)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 934145 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Log `route is not defined` do patch trước: trong `createMasteringRoute` gán `route._trackId`/`route._bypass` nhưng biến `route` KHÔNG tồn tại trong hàm (hàm trả `{routeGain, dryGain}`; `route` là tên biến ở caller). Lỗi bắn ra MỖI lần tạo track node (play). Fix: tạo `routeObj` trước rồi gán `_trackId`/`_bypass` lên đó; `setMasteringRoute` đọc `route._trackId` hợp lệ (param).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608034000)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 934143 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** User báo nút A vẫn đưa tín hiệu qua Mastering panel. Rà toàn bộ chuỗi: clip → gainNode → (bypass) dryGain → masterBus.dryInput → dryOutput → output — **bỏ hoàn toàn mastering modules** (wiring đã đúng từ Pha H; masterBus: dryInput.connect(dryOutput), dryOutput.connect(output); wet: input → compressor → inputAnalyser → [modules] → outputAnalyser → output). Section items qua sub-node → parent gainNode → cùng route ✓. Củng cố: (1) `setMasteringRoute` dùng `getAudioContext()` (loại bỏ nguy cơ return sớm khi audioCtx null) + lưu `route._trackId/_bypass`; (2) **console.log `[Bypass] track <id> audioBypass=... routeGain.gain=... dryGain.gain=... → dry bus (bỏ mastering)` mỗi lần toggle** — user mở DevTools console để xác nhận đường tín hiệu thực tế. **Lưu ý: MIDI items (♪ chưa bật) ĐI QUA mastering** (masterBus.input → modules) — đúng thiết kế tách A/♪; nếu user test track có MIDI và thấy "vẫn qua mastering", đó là tiếng đàn chứ không phải audio clips.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033900)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 934112 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
- **Tóm tắt thay đổi:** Theo yêu cầu user: **nút không sáng (xám) = cho phép bypass đang bật; nút sáng = không bypass (xử lý bình thường)**. Đảo UI cả 2 strip (MixerStrip + TrackStripConsole): A bypass bật → xám, tắt → sáng xanh; ♪ bypass bật → xám, tắt → sáng tím. Củng cố DSP để bypass audio chắc chắn tác dụng:
|
||||
1. `createMasteringRoute` **ưu tiên đọc `trackAudioBypassMap`** (sync từ state mỗi render) trước khi fallback track object — tránh stale object.
|
||||
2. `__setTrackBypass('audio')` giờ **re-route LIVE mọi section sub-node** (`<trackId>_sub_<subId>`) cùng main node → audioclip item + section item bypass ngay khi bấm, kể cả đang play.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033800)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 933696 bytes, node --check OK, `pytest` 86 passed. Tooltip nút ghi rõ: "XÁM = bypass đang bật, SÁNG = xử lý bình thường".
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix màu nút bypass A/♪ + tách hoàn toàn đường SF (MIDI không còn lệ thuộc nút A)
|
||||
- **Tóm tắt thay đổi:**
|
||||
1. **Đường SF độc lập (fix lệ thuộc A)**: trước đây SF chảy qua chain DÙNG CHUNG (gainNode → fxEntry → ... → panner → routeGain) — khi bật nút A, routeGain=0 → MIDI mất FX dù ♪ chưa bật. Fix: track có midiItems giờ dựng **SF chain riêng**: `gainNode → sfEntry → [module instances RIÊNG (cùng loại/params)] → sfOut → sfPan → masterBus.input` — hoàn toàn tách khỏi chuỗi audio. `updateSfRouting`: ♪ off → SF → sfEntry (FX giữ nguyên dù A bật/tắt); ♪ on → SF → masterBus.dryInput. `rebuildTrackFxGraph` rebuild cả 2 chain + sync sfPan.pan. Scope rack: track MIDI hiển thị `sfAnalyser` (post-FX).
|
||||
2. **Màu nút theo yêu cầu**: TrackStripConsole — A/♪ inactive = **xám** (`text-slate-500`), active = **sáng xanh** (A: `text-sky-300 bg-sky-500/30`) / **sáng tím** (♪: `text-fuchsia-300 bg-fuchsia-500/30`). MixerStrip bỏ fallback `?? masteringBypass` khỏi visual (state hoàn toàn độc lập: A chỉ đọc `audioBypass`, ♪ chỉ đọc `midiBypass`).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033700)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 933049 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Tách nút bypass track thành 2 nút (Audio vs MIDI) + fix loop 0→18 khi click tempo track
|
||||
- **Tóm tắt thay đổi:**
|
||||
1. **Tách bypass**: track giờ có `audioBypass` + `midiBypass` (migrate từ `masteringBypass` cũ — deserialize: `audio_bypass ?? mastering_bypass`, `midi_bypass ?? mastering_bypass`; serialize ghi cả 3). UI MixerStrip + TrackStripConsole: thay nút B đơn bằng **nút [A]** (bypass audio items = clips + sections; route kép dry/route như cũ — `trackAudioBypassMap` + `createMasteringRoute` đọc `audioBypass ?? masteringBypass`) và **nút [♪]** (bypass MIDI riêng — `trackMidiBypassMap` + `updateSfRouting`: khi bật, SF output → `masterBus.dryInput` trực tiếp, bỏ FX + mastering nhưng giữ CC7 mute/solo). `window.__setTrackBypass(trackId, 'audio'|'midi', on)`; giữ `__setTrackMasteringBypass` (set cả 2) cho tương thích.
|
||||
2. **Fix loop**: click lên tempo track (hoặc click đơn bất kỳ) tạo selection RỖNG (start==end) → `hasSel` cũ FALSE → nút loop auto-derive 0→18 ghi đè. Fix: `hasSel` giờ nhận mọi selection state có tồn tại (`selectionStart !== null && selectionEnd !== null` — kể cả start==end) → không bao giờ tự derive khi user đã click lane.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033600)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 931110 bytes, node --check OK, `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Wave Observer + Module Vector Display Canvas trong FX Rack Panel (unified_fx_rack_panel_update.md)
|
||||
- **Tóm tắt thay đổi:** Tích hợp theo spec cập nhật:
|
||||
1. **Scope analysers per-track (post-FX)**: `getOrCreateTrackNode` thêm `scopeSplitter` + `scopeAnalyserL/R` (fftSize 2048) nối từ `pannerNode` (output context, SAU FX chain — đúng sơ đồ spec §III.3); `analyserNode` chính tăng 256→2048. Expose `window.__getTrackScopeAnalysers(trackId)` → {L, R, sr}.
|
||||
2. **Wave Observer UI trong FXRackModal**: canvas scope + toolbar đúng spec — Input L/R meters, **Channel** (Stereo/Left/Right/Mid/Side), **Mode** (Waveform/Lissajous/Spectrum), **Duration** slider 0.1-5s (ring buffer history 5s × sampleRate), **V.Zoom** slider -12..+20 dB, **Pause/Resume**. Render loop rAF: waveform vẽ L cyan + R amber (stereo), lissajous scatter L vs R, spectrum bar log 20Hz-20kHz.
|
||||
3. **Interactive Module Vector Display Canvas**: canvas EQ response curve trong EQ view — vẽ |H(f)| xấp xỉ (4 band cascaded: lowshelf/peaking/highshelf log-domain) 20Hz-20kHz, cập nhật realtime khi kéo slider band.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033500)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 927915 bytes, node --check OK, `pytest` 86 passed. Scope chỉ có tín hiệu khi track đang play (analyser tồn tại lúc playback); dừng → flatline + ghi chú.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: FX Rack Panel tác dụng lên soundfont instrument trên track (route FluidSynth output qua track gainNode)
|
||||
- **Tóm tắt thay đổi:** Trước đây MIDI/soundfont qua FluidSynth (ScriptProcessor → `_gainNode` gain 0.3 → masterBus.input) **bỏ qua hoàn toàn track gainNode + fxEntry** → FX Rack (EQ/Comp/Limiter/Exciter/Rebalance) không ảnh hưởng instrument. Fix:
|
||||
1. **soundfontPlayer.js**: thêm `setOutputDestination(node)` (disconnect + nối `_gainNode` tới node chỉ định; `null` → về masterBus.input) + `getOutputNode()`; biến `_pendingOutputDestination` áp dụng khi `_gainNode` được tạo sau (4 nơi tạo gainNode đều tôn trọng).
|
||||
2. **app.jsx `updateSfRouting()`**: nếu **đúng 1 track MIDI audible** (có midiItems + `computeTrackAudibleGain>0`) → `setOutputDestination(track.gainNode)` → toàn bộ chuỗi track tác dụng: **FX Rack chain → fader volume → pan → mute/solo gain → master**. Nhiều hơn 1 track MIDI audible → fallback masterBus.input (shared worklet không tách được; CC7 mute vẫn đúng). Expose `window.__updateSfRouting`.
|
||||
3. **Gọi updateSfRouting ở 5 điểm**: getOrCreateTrackNode (node mới), startTrackPlayback (cuối schedule), applyAllTrackMuteSolo (đổi mute/solo), stopAllPlayback (về masterBus), effect sync `[tracks, sessionTabs]` (load/undo).
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (+`?v=202608031400` trong index.html), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033400)
|
||||
- **Ghi chú/Test (nếu có):** BUILD OK 915831 bytes, balance 0, `pytest` 86 passed. Giới hạn: >1 track MIDI audible → FX không áp (cần multi-engine để tách, chưa làm).
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Unified FX Rack Panel theo unified_fx_rack_panel.md (panel FX dùng chung cho track + rebuild graph độc lập)
|
||||
- **Tóm tắt thay đổi:** Cài đặt cơ chế Unified FX Rack Panel (Hệ thống quản lý Module FX dùng chung):
|
||||
1. **Module factory dùng chung**: `createTrackFxModule(type, ctx, params)` — factory DSP giống mastering, giờ nhận `params` (EQ g1-g4, Comp threshold/ratio/makeup, Lim ceiling, Exc drive, Rebal mid/side) lưu theo từng chain entry; thêm `TRACK_EQ_PRESETS` (flat/vocal_clarity/bass_punch/warm_tape/guitar_edge).
|
||||
2. **Cấu trúc track FX có điểm rebuild**: `getOrCreateTrackNode` wire `gainNode → fxEntry → [modules active] → fxLegacyIn → chorus/reverb/direct → pannerNode` (lưu fxEntry/fxLegacyIn trên node).
|
||||
3. **`rebuildTrackFxGraph(trackId)`** (đúng `rebuildContextAudioGraph` spec §III.2): disconnect `fxEntry`, nối lại qua modules ACTIVE theo thứ tự hiện tại — chỉ ảnh hưởng track đó, không đụng track khác/master bus; chạy LIVE khi đang play; expose `window.__rebuildTrackFxGraph`.
|
||||
4. **FXRackModal** — panel unified cho track: chain rack (drag-drop reorder, power toggle, delete, nút [+] thêm 5 loại module), view tham số theo module (EQ 4 band + preset dropdown, Comp, Limiter, Exciter, Rebalance), header hiển thị tên track; mọi thay đổi → update track.fxChain + `__rebuildTrackFxGraph` ngay.
|
||||
5. **Nút [FX]** trên TrackStripConsole → `window.__openFxRack(track.id, track.name)` — mở panel bind đúng track (bỏ modal inline cũ). App: state `fxRackTarget` + render FXRackModal (track lookup + updateTrackProp).
|
||||
- **SỰ CỐ KHÔI PHỤC FILE:** trong lúc edit, app.jsx bị ghi đè nhầm (chỉ còn 2000 dòng). Đã khôi phục từ git HEAD `ec5fede` (commit chứa toàn bộ fix tới loop-fix — user đã commit) + replay lại các edit unified rack; xác minh bằng build + balance ngoặc.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033300)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` BUILD OK 914635 bytes, syntax OK, bracket balance 0, `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Loop vẫn giãn selection tới bar 18 — mở rộng hasSel + verify bundle serve
|
||||
- **Tóm tắt thay đổi:** User báo vẫn lỗi sau fix trước. Kiểm tra toàn diện: (1) Không có path nào khác giãn selection khi loop bật (SubTabToolbar/onLoop là code chết; toggleLoop là Media Explorer preview; useEffect BPM chỉ chạy khi đổi tempo). (2) Mở rộng điều kiện `hasSel` trong handler nút loop: ngoài `selectionMode==='local'`, còn nhận selection qua `selectionStart/selectionEnd` hợp lệ (phòng trường hợp sweep không set 'local'). (3) **Verify bằng server thật** (uvicorn 9131): file `app.precompiled.js?v=202608033200` được serve có chứa `hasSel=selectionMode===` + comment fix — chứng minh bundle workspace đã đúng. Nếu user vẫn thấy lỗi → trình duyệt cache hoặc đang chạy deployment khác (không phải workspace).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033200; sửa double `</script>` phát sinh khi bump)
|
||||
- **Ghi chú/Test (nếu có):** `pytest` 86 passed (chưa chạy lại lần này — chỉ đổi frontend).
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix loop button ghi đè vùng chọn thành 0→18 bar (MAIN SESSION/SECTION-TAB)
|
||||
- **Tóm tắt thay đổi:** Khi quét chọn vùng duration rồi bấm nút loop, vùng chọn bị tự đổi thành 0→18 bar. Nguyên nhân: handler nút loop (nhánh main timeline, không có sub-tab active) LUÔN tự derive loop end từ content tracks (`maxEnd + 2 bar` — content 16 bar → 18) rồi `setSelectionStart(0); setSelectionEnd(loopEnd)` — ghi đè vùng chọn của user. Fix: **tôn trọng selection có sẵn** (`hasSel = selectionMode==='local' && selLeft/selRight hợp lệ`) — chỉ auto-derive khi CHƯA có vùng chọn nào. Áp dụng cho cả MAIN SESSION lẫn SECTION-TAB (cả 2 đều đi qua nhánh main timeline khi không có sub-tab; section-tab dùng chung selection handler nên sweep cũng set `selectionMode='local'`). Kèm theo: `handleTrackLaneMouseDown` giờ `setSelectionCleared(false)` khi bắt đầu chọn vùng mới — nếu user từng Ctrl+click xoá chọn thì vùng mới vẫn loop được.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033100)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix React error #310 khi mở Mixer modal (hooks sau early return)
|
||||
- **Tóm tắt thay đổi:** Sau fix `isFxActive`, mở Mixer modal vẫn lỗi `Minified React error #310` ("Rendered more hooks than during the previous render"). Nguyên nhân: khi thêm dynamic module chain, 2 hooks `dragChainIndexRef` (useRef) + `addModuleOpen` (useState) được khai báo SAU dòng `if (!isOpen) return null;` trong MasteringModal → số hooks giữa các render thay đổi khi modal đóng/mở (return null sớm bỏ qua 2 hooks) → React #310. Fix: di chuyển 2 hooks lên TRƯỚC early return (cạnh useEffect [isOpen]).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032900)
|
||||
- **Ghi chú/Test (nếu có):** verify: hooks tại 9252-9253 trước `if (!isOpen) return null;` (9255), không còn hook nào sau early return trong MasteringModal; syntax OK; bracket balance 0.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix "ReferenceError: isFxActive is not defined" khi mở Mixer modal
|
||||
- **Tóm tắt thay đổi:** Khi mở Mixer modal bị lỗi `Uncaught ReferenceError: isFxActive is not defined`. Nguyên nhân: lúc thêm FX chain editor cho TrackStripConsole, tôi vô tình xóa luôn khai báo `const [isFxActive, setIsFxActive]` riêng của TrackStripConsole (git HEAD có 2 khai báo: master strip + track strip; sau khi dọn duplicate thì chỉ còn 1 của master strip) → nút FX Power trong TrackStripConsole tham chiếu biến không tồn tại. Fix: khôi phục khai báo `isFxActive` trong TrackStripConsole (cạnh `fxChainOpen`).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032800)
|
||||
- **Ghi chú/Test (nếu có):** verify: 2 khai báo `isFxActive` (master strip 1154 + track strip 1460), bundle có `isFxActive` ×5, syntax OK, bracket balance 0.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Nâng cấp Mastering panel theo mastering_expand.md (EQ presets + module factory + reorder + track FX reuse)
|
||||
- **Tóm tắt thay đổi:** Làm 4 yêu cầu spec mastering_expand.md:
|
||||
1. **EQ Presets** (§II.1): `EQ_PRESET_LIBRARY` (flat/vocal_clarity/bass_punch/warm_tape) + dropdown trong EQ view — `applyEQPreset` ramp freq/gain/Q trên 4 biquad filter + sync knobs/canvas.
|
||||
2. **Module factory + 5 module mới** (§II.2): thêm DSP cho **Bus Compressor** (DynamicsCompressor + makeup), **Brickwall Limiter** (ratio 20:1, knee 0), **Harmonic Exciter** (WaveShaper + HP 2kHz + dry/wet), **Master Rebalance** (M/S gains qua L/R crossfeed) trong initMasterBus; mỗi module có input/output gain riêng.
|
||||
3. **Module reordering + dynamic re-route** (§II.3): `rebuildMasteringGraph(active, chain)` — disconnect mọi boundary node rồi nối chuỗi active modules giữa inputAnalyser→outputAnalyser. Chain strip giờ render ĐỘNG từ `ozState.chain` (drag-drop reorder, power toggle, delete, nút [+] mở modal thêm module 7 loại). `toggleMasteringOnMaster` dùng chain signature cho idempotency. Migration: project cũ không có `chain` → default [eq, imager, maximizer] + params mới.
|
||||
4. **Reuse cho track FX** (§II.4): `createTrackFxModule(type, ctx)` — factory tạo instance DSP giống mastering cho từng track; `getOrCreateTrackNode` wire `track.fxChain` (chuỗi module) trước chorus/reverb; serialize/deserialize `fx_chain`; nút **FX** trên TrackStripConsole mở modal editor (thêm/xóa/toggle từng module Comp/Limiter/Exciter/M-S/EQ).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032700)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, bracket balance 0, `pytest` 86 passed. Lưu ý: module mới có param riêng (compThreshold/compRatio/compMakeup, limThreshold, excDrive, rebalMid/rebalSide) được lưu trong mastering_settings.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Cài đặt Imager module theo imager_spec.md (M/S width + vectorscope + correlation meter)
|
||||
- **Tóm tắt thay đổi:** Làm đúng spec imager_spec.md trong MASTERING PANEL (Mixer F7):
|
||||
1. **DSP width semantics chuẩn spec**: 0% = MONO (S×0), 100% = Original (S×1), 200% = 2× Width (S×2). Giữ cấu trúc 4-band crossover (20-100Hz / 100Hz-1kHz / 1k-6kHz / 6k-20kHz) với matrix L/R crossfeed tương đương M/S (M = (L+R)/2 giữ nguyên, S = (L−R)/2 × w/100 — chứng minh: g1=(w+100)/200, g2=(100−w)/200 → mid=(g1+g2)=1, side=(g1−g2)=w/100).
|
||||
2. **Migration**: project cũ lưu scale −100..+100 (0 = original) → tự +100 mỗi band khi load (0→100, 15→115, 35→135, 50→150), dùng marker `imagerScale:'v2'` cho project mới. Default mới theo khuyến nghị spec: Band1=0% (MONO maker), Band2=115%, Band3=135%, Band4=150%.
|
||||
3. **Vectorscope thật (Polar M/S)**: thay chấm ngẫu nhiên giả bằng trace thật X=(L+R), Y=(L−R) từ leftAnalyser/rightAnalyser (post-mastering) — mono → nằm ngang, width tăng → trải dọc.
|
||||
4. **Phase Correlation meter thật**: ρ = Σ(L·R)/√(ΣL²·ΣR²) (−1..+1), gauge vẽ zone màu theo spec (+0.5..+1 xanh an toàn, 0..+0.5 vàng cẩn trọng, <0 đỏ nguy hiểm) + hiển thị số "Corr:".
|
||||
5. UI: slider range 0-200%, nhãn band theo spec, hint "0% = Mono · 100% = Original · 200% = 2× Width".
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032600)
|
||||
- **Ghi chú/Test (nếu có):** verify DSP (w=0→side 0, w=100→side 1, w=200→side 2, mid luôn 1), migration (0,15,35,50 → 100,115,135,150; v2 giữ nguyên), slider trong bundle. `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Mute/Solo realtime cho MIDI items — dùng CC7 (channel volume) của FluidSynth
|
||||
- **Tóm tắt thay đổi:** Mute/unmute/solo chưa realtime với **MIDI items** vì FluidSynth WASM render toàn bộ channel vào **1 worklet → 1 `_gainNode` chung** (`_workletNode.connect(_gainNode)`), nên gain node của track không câm được MIDI (chỉ audio clips + section sub-tracks qua track gain mới bị ảnh hưởng). Fix: mỗi track MIDI sở hữu **channel riêng** (`ensureTrackMidiChannel`, 0-15 trừ 9) → dùng **CC7 (channel volume)** của FluidSynth (`window.SonicSF.controllerChange(ch, 7, 100|0)`) — áp realtime kể cả với notes đang vang. Thêm vào `applyAllTrackMuteSolo` (nút M/S) + effect sync `[tracks, sessionTabs]` (load/undo). Khi mute → CC7=0 (notes đang phát câm ngay); unmute → CC7=100 (notes đang phát vang lại + cơ chế becameAudible→restart vẫn giữ). Audio clips/section vẫn qua track gain như cũ.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032500)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `controllerChange(ch,7,...)` ×2 trong bundle; `pytest` 86 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix items rớt/dồn sai track khi load-save project (3 bug)
|
||||
- **Tóm tắt thay đổi:** (1) **`serializeTracksList` (app.jsx)**: chuỗi `if/else-if` theo `trackType` chỉ serialize MỘT loại items → track có cả clips + midiItems/sections bị **rớt items khi save** (mất dữ liệu âm thầm, có thể gây cảm giác "items biến mất/dồn chỗ"). Sửa: serialize ĐỘC LẬP từng loại items có trên track. (2) **`upgrade_project_json_if_needed` (projects.py)**: hardcode `start_bar = startTime/4.0` + `duration_bars = 4.0` → vị trí items sai (lệch 2× ở 120bpm, càng lệch khi tempo khác). Sửa: dùng `seconds_per_bar = (60/bpm)*4` từ bpm của project. (3) **`loadAudioBuffersForTracks` (app.jsx)**: merge buffer theo **array index** → nếu state đổi giữa lúc fetch (race: mở project khác khi buffer-load cũ chưa xong) thì tracks bị xáo trộn, items rơi vào track sai. Sửa: merge theo **track ID** + clip ID.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/api/v1/projects.py`, `app/templates/index.html` (bump v=202608032400), `tests/test_project_upgrade.py` (mới)
|
||||
- **Ghi chú/Test (nếu có):** verify: round-trip serialize→deserialize giữ đúng track + vị trí (đã test bằng code bundle thật); track hỗn hợp giờ ra `AUDIO_ITEM,MIDI_ITEM`; `pytest` **86 passed** (3 test mới cho upgrade). Lưu ý: deserialize/upgrade vốn đã map đúng từng track — nếu user vẫn thấy items dồn track 1 sau khi hard-refresh, cần kiểm tra dữ liệu project cụ thể (và bản bundle trình duyệt đang chạy).
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix unmute không phát lại khi đang play (sources bị loop-restart bỏ qua)
|
||||
- **Tóm tắt thay đổi:** Khi đang play, mute → câm (OK, gain 0) nhưng unmute → không nghe lại được. Nguyên nhân: nếu có loop restart (`stopAllPlayback` + `startTrackPlayback` trong updatePlayhead), vòng lặp mới **bỏ qua track đang mute/solo** (isPlayable check) → sources của track không được tạo lại → chỉ set gain khi unmute không "hồi sinh" được nguồn đã không tồn tại. Fix trong `applyAllTrackMuteSolo`: theo dõi `trackAudibleRef` (audibility từng track), khi phát hiện chuyển tiếp **inaudible → audible** (unmute / tắt solo) và `isPlaying` + không đang RECORDING → `stopAllPlayback()` + `startTrackPlayback(currentTime)` re-schedule lại từ playhead hiện tại (đúng cơ chế toggleTrackSoloEvaluate đã dùng cho solo). Mute thuần (audible→inaudible) vẫn chỉ set gain (tức thì, không gián đoạn track khác). Effect sync `[tracks, sessionTabs]` giờ cũng cập nhật `trackAudibleRef` để nhất quán khi load/undo.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032300)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Mute/Solo realtime — tác dụng ngay lên items đang phát của track
|
||||
- **Tóm tắt thay đổi:** Nút M/S ở cả MixerStrip (mixer F7) và TrackStripConsole (track strip) giờ tác dụng **realtime**: toggle mute/solo lập tức set gain của track node đang phát (crossfade 20ms, không click). Cơ chế: `computeTrackAudibleGain(list, track)` (mute luôn tắt; nếu có bất kỳ solo → chỉ track solo nghe được; ngược lại theo volumeDb), `setTrackNodeGain(node, gain)` áp vào `gainNode` của track node. `window.__applyTrackMuteSolo(trackId, patch)` được gọi từ: (1) nút M/S của cả 2 strip (patch state + áp ngay), (2) `getOrCreateTrackNode` khi tạo node (items của track muted/soloed bắt đầu đúng trạng thái), (3) `startSubTabPlayback` cho chain mới, (4) effect sync `[tracks, sessionTabs]` với signature `muted|solo|volumeDb` (chỉ re-apply khi thay đổi — phủ load project/undo/section tab). Vì audio clip `source.connect(gainNode)` và MIDI note (đường oscillator fallback) đều qua track gainNode → mute/solo có tác dụng lên toàn bộ items của track.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032200)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, `pytest` 83 passed. Giới hạn: MIDI qua FluidSynth WASM render chung 1 worklet → không tách theo track (cùng giới hạn với bypass); đường oscillator fallback thì có tác dụng.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Đồng bộ hành vi click+drag item (clip = move như section/MIDI; Alt+click+drag = quét chọn)
|
||||
- **Tóm tắt thay đổi:** (1) **Thống nhất**: click + drag trên MỌI item (section/MIDI/audio clip) = **di chuyển**; trước đây audio clip click+drag = quét chọn vùng (gây cảm giác "không đáp ứng"). (2) Hành động cũ của audio clip chuyển sang **Alt+click+drag = quét chọn duration** (gọi `onTrackLaneMouseDown` → local sweep select); Alt+right-edge vẫn = time-stretch. (3) **Fix "thỉnh thoảng không đáp ứng"**: mọi item giờ dùng cơ chế **pending drag có threshold 5px** — click thuần chỉ chọn item (không tạo undo/toast, không vô tình di chuyển do rung chuột), di chuột >5px mới bắt đầu drag. Cơ chế: `handleSetPendingDragMove` (mới) lưu `pendingDragRef` với `duplicate:false`; effect pending-drag route: clip đơn → `handleClipDragStartRef` (clip machinery), còn lại → `handleSectionItemDragStartRef` (multiIds đã hỗ trợ clip). `handleSetPendingDrag` (Ctrl+click copy) giờ lưu `duplicate:true`. Sửa lookup clip trong nhánh non-duplicate của `handleSectionItemDragStart`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032100)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, verify `duplicate:true`/`!pdSnap.duplicate` trong bundle. `pytest` 83 passed. Grab tool (kéo nhanh) giữ nguyên.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Bypass trên nút Routing Matrix (track strip TCP) + bypass bỏ cả track FX
|
||||
- **Tóm tắt thay đổi:** (1) Nút **Routing Matrix** trong `TrackStripConsole` (TCP strip, cạnh M/S) giờ là nút **Bypass** — tooltip "Bypass: track KHÔNG qua FX + mastering ở Main out", active style xanh khi bật. (2) Thay đổi điểm lấy tín hiệu dry: trong `getOrCreateTrackNode`, `dryGain` giờ tap từ **`gainNode` (PRE-FX)** thay vì `analyserNode` (post-FX) → khi bypass, channel bỏ qua **cả track FX (chorus/reverb) lẫn mastering chain** ở Main out; đường routeGain giữ nguyên (post-FX → mastering) khi không bypass. (3) Xác nhận Mixer Panel đã ở phím **F7** (code sẵn: F7 → `__toggleMixerRef`).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608032000)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, syntax OK, không còn "Routing Matrix", `pytest` 83 passed.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix TDZ "Cannot access 'sessionTabs' before initialization" sau khi thêm nút Bypass
|
||||
- **Tóm tắt thay đổi:** `useEffect` sync `trackMasteringBypassMap` đặt ở ~11786 nhưng dependency array `[tracks, sessionTabs]` được đánh giá ngay tại chỗ gọi — trước khi `sessionTabs` khai báo (12239) → `ReferenceError: Cannot access 'sessionTabs' before initialization` khi chạy app. Fix: di chuyển useEffect xuống sau khối khai báo `subTabs`/`sessionTabs`/`sessionTabsRef` (TDZ-safe). `window.__setTrackMasteringBypass` an toàn vì chỉ truy cập `activeTrackNodesRef` bên trong body hàm (lúc click).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031900)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` OK, verify trong bundle: effect @453168 sau sessionTabs decl @451814. `pytest` 83 passed. Người dùng chạy `npm run build` trên workspace là được.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Nút Bypass Mastering cho từng track strip trong Mixer Panel
|
||||
- **Tóm tắt thay đổi:** Thêm nút **B** (Bypass) trong mỗi track strip của Mixer Panel (cạnh M/S). Khi bật ON: âm thanh track đi qua **dry bus mới** (`masterBus.dryInput → dryOutput → output`) — **bỏ qua toàn bộ chuỗi mastering** (EQ / Imager / Maximizer) nhưng vẫn qua master volume + metering ở Main out. Cơ chế: `createMasteringRoute()` tạo 2 đường gain bù nhau (routeGain → `masterBus.input` qua mastering, dryGain → `dryInput`); `setMasteringRoute()` crossfade 20ms khi toggle (không click). Áp dụng tại: `getOrCreateTrackNode` (node track chính, toggle live qua `node.route`), `startSubTabPlayback` (clip playback). Map trạng thái `trackMasteringBypassMap` sync từ tracks state qua useEffect; `window.__setTrackMasteringBypass` toggle ngay cho track đang phát. Lưu/đọc project: `mastering_bypass` trong serialize/deserialize (schema không chặn additionalProperties nên không cần sửa).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608031800)
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` rebuild OK, syntax OK, `pytest` 83 passed. **Giới hạn:** track MIDI/SoundFont dùng chung 1 bộ render FluidSynth (1 worklet → 1 gain) nên bypass hiện áp dụng cho track AUDIO (clip) + oscillator fallback; track MIDI dùng FluidSynth WASM chưa tách được theo track (cần refactor renderer) — sẽ làm tiếp nếu cần.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Rebuild app.precompiled.js từ app.jsx (fix "handleOpenProject is not defined")
|
||||
- **Tóm tắt thay đổi:** Lỗi `Uncaught ReferenceError: handleOpenProject is not defined` khi click Open → bundle `app.precompiled.js` mà trình duyệt tải bị lệch với `app.jsx` (bundle cũ không chứa định nghĩa hàm ở scope đúng). `npm run build` không chạy được vì Babel 8 ESM-only (`ERR_REQUIRE_ESM`). Giải pháp: build lại bundle bằng **@babel/standalone@7** qua `build.mjs` (script mới, mirror đúng lệnh build trong package.json), tạo `app.precompiled.js` mới (858KB, syntax OK, đủ `const handleOpenProject` + 3 references, kèm fix restoreLastSessionProject). Đã smoke-test: trích deserializer từ bundle mới chạy với 6 project khôi phục → 6/6 OK, "Rose (autosave 03/08)" đủ 39 MIDI notes. Bump cache-buster lên `v=202608031700`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.precompiled.js` (rebuild), `build.mjs` (NEW — rebuild thủ công khi Babel 8 lỗi), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed. Node portable dùng: `/tmp/node-v20.18.0-linux-x64/bin/node` (máy không có node hệ thống). Trên máy deployment: copy 3 file (app.jsx, app.precompiled.js, index.html) hoặc chạy `node build.mjs` rồi hard refresh.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Fix auto-restore dự án lỗi "Không tìm thấy dự án" sau khi khôi phục DB
|
||||
- **Tóm tắt thay đổi:** Sau khi DB bị xóa/tạo lại, `localStorage.sonic_project_id` của trình duyệt vẫn trỏ tới project cũ đã mất → mỗi lần tải trang `restoreLastSessionProject` gọi API, nhận 404 "Không tìm thấy dự án" và chỉ `console.warn` vĩnh viễn. Fix: khi restore thất bại, tự xóa `sonic_project_id` + `sonic_project_name` khỏi localStorage để lỗi không lặp lại. Bump cache-buster precompiled lên `v=202608031600`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Verified qua API: cả 6 project khôi phục mở được (GET /cloud/{id} → 200 + main_session đầy đủ). Người dùng cần hard refresh (Ctrl+Shift+R) để nạp bundle mới, mở "Rose (autosave 03/08)" 1 lần để ghim project hiện tại.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Khôi phục các dự án cloud cũ bị mất (git recovery)
|
||||
- **Tóm tắt thay đổi:** Các dự án cloud cũ ('Test' x4, 'Rose' từ 22/07) và project đang làm dở "Rose" (autosave 03/08, 2 track MIDI+AUDIO, 39 notes, bpm 128) đã được khôi phục vào `app/storage/sonicforge.db` dưới user_id admin hiện tại từ **git history** (`app/storage/sonicforge.db` từng được commit trước khi vào `.gitignore`). Nguyên nhân mất: DB bị xóa/tạo lại nhiều lần trong quá trình dev (test suite `test_auth_and_quota` xóa DB → admin được re-seed với UUID mới → project cũ gắn với user_id cũ không hiển thị). Đã dọn các project/user test rác, backup DB trước khi khôi phục tại `/tmp/sonicforge_db_before_restore.db`.
|
||||
- **Các file ảnh hưởng:** `app/storage/sonicforge.db` (dữ liệu), `tools/restore_cloud_projects.py` (NEW — script khôi phục cho deployment khác), `tools/cloud_projects_backup.json` (NEW — 6 project dạng portable)
|
||||
- **Ghi chú/Test (nếu có):** Verified qua API: login admin → `GET /api/v1/projects/cloud` trả đủ 6 project; mở "Rose (autosave 03/08)" đủ 39 MIDI notes. Trên máy deployment thật (nếu DB khác): `python3 tools/restore_cloud_projects.py <duong-dan>/sonicforge.db`. Từ giờ test suite không đụng DB dev (xem entry conftest).
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Cô lập test suite khỏi DB dev + khôi phục mật khẩu admin
|
||||
- **Tóm tắt thay đổi:** (1) `DB_PATH` trong `app/models/user.py` giờ có thể override bằng env `SONICFORGE_DB_PATH`. (2) Thêm `tests/conftest.py` set env này sang `/tmp/sonicforge_test.db` trước khi mọi app module được import → pytest không bao giờ sửa DB dev nữa (trước đây `test_auth_and_quota` seed + đổi mật khẩu admin ngay trong `app/storage/sonicforge.db`, khiến login `admin123` fail). (3) Reset mật khẩu admin DB dev về `admin123` (`must_change_password=0`).
|
||||
- **Các file ảnh hưởng:** `app/models/user.py`, `tests/conftest.py` (NEW)
|
||||
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: login admin/admin123 → 200, sai mật khẩu → 400. Nếu deployment khác cũng bị dính (chạy test trên cùng DB), reset thủ công: `UPDATE users SET hashed_password='<hash_of_admin123>', must_change_password=0 WHERE username='admin'` hoặc xóa DB để `seed_admin` tạo lại.
|
||||
---
|
||||
|
||||
### [2026-08-03] Task: Security audit + bug fixes (bảo mật, bug chức năng, cải thiện)
|
||||
- **Tóm tắt thay đổi:** (1) **SSRF** `/api/v1/ai/proxy`: thêm auth bắt buộc + chặn cloud metadata/link-local (169.254.0.0/16), chặn IP private trừ khi host nằm trong danh sách AI provider user đã cấu hình (cho phép Ollama localhost:11434), chặn scheme không phải http/https, không forward header X-Auth-Token lên upstream. (2) **Path traversal** `/plugins/render`: `output_filename` chỉ lấy basename + ép đuôi .wav. (3) **Path traversal** toàn bộ `/audio/*`: helper `_safe_file_id`/`_resolve_storage_path` (basename + chỉ đọc trong uploads/processed). (4) **SECRET_KEY**: bỏ hardcode, tự sinh random bền vững lưu `app/storage/.secret_key` (ưu tiên env SECRET_KEY). (5) **media.py** (`computer`/`browse`/`file`): yêu cầu auth — login giờ set HttpOnly cookie `sf_token`, `get_current_user` nhận token từ Bearer / X-Auth-Token / cookie nên frontend raw fetch vẫn hoạt động. (6) Upload audio + soundfont: streaming theo chunk + giới hạn size (1GB/2GB) thay vì đọc cả file vào RAM. (7) **Quota bypass**: `update_cloud_project` giờ kiểm tra quota như `save_cloud_project`. (8) `enforce_password_changed` (bắt buộc đổi mật khẩu lần đầu) được wire vào upload/edit/render/ai-scan/ai-cut/python-tool/upload-soundfont. (9) **render_engine**: fix resample bị bỏ qua (`sr != sample_rate` trước là no-op → giờ resample_poly), render tôn trọng **solo** track, cache buffer section theo `section_id`, thay print → logger. (10) **vst_engine**: thống nhất FluidSynth API low-level CFFI (high-level `Synth()`/`FluidSynth()` không tồn tại trong binding này). (11) Rate-limit login theo IP (10 lần/15 phút), validate độ mạnh password khi register. (12) main.py: `on_event` → `lifespan`, CORS `allow_credentials=False`, xóa stub rỗng. (13) SQLite: WAL + foreign_keys=ON + seed user `anonymous` placeholder (FK hợp lệ cho project ẩn danh). (14) Pydantic v2 `model_dump()` thay `dict()`.
|
||||
- **Các file ảnh hưởng:** `app/core/auth.py`, `app/api/v1/auth.py`, `app/api/v1/ai_proxy.py`, `app/api/v1/plugins.py`, `app/api/v1/audio.py`, `app/api/v1/media.py`, `app/api/v1/projects.py`, `app/api/v1/multitrack.py`, `app/api/v1/user_config.py`, `app/core/render_engine.py`, `app/core/vst_engine.py`, `app/models/user.py`, `app/main.py`, `app/static/js/services/aiGateway.js` (gửi X-Auth-Token khi gọi proxy), `tests/test_security_hardening.py` (NEW, 14 test), `tests/test_plugin_api.py`
|
||||
- **Ghi chú/Test (nếu có):** `pytest tests/` → 83 passed, 5 skipped. Đã verify live: proxy không token → 401, metadata → 403; media không token → 401 / có cookie → 200; render `output_filename=/tmp/x.wav` → path nằm trong PROCESSED_DIR. Lưu ý: nếu triển khai cũ đang chạy, restart server để tạo `.secret_key` (token cũ sẽ hết hạn vì secret đổi). Login mật khẩu admin mặc định vẫn `admin123` (đã khôi phục trong DB dev).
|
||||
---
|
||||
|
||||
### [2026-07-31 07:03] Task: Fix Lucide icons not rendering on new tracks (TCP + SECTION-TAB)
|
||||
- **Tóm tắt thay đổi:** `useEffect` gọi `lucide.createIcons()` thiếu `activeTracks` trong dependency array → khi track được tạo (user/AI/MIDI import/clone section), DOM nodes mới có `data-lucide` nhưng không được chuyển thành SVG → icon ẩn hoặc hiển thị sai. Fix: thêm `activeTracks` vào deps để auto-refresh icons sau mỗi lần track list thay đổi.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`
|
||||
@@ -1194,3 +1722,322 @@
|
||||
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.
|
||||
|
||||
### [2026-08-03 10:15] Task: Fix MIDI ARM không phát âm thanh sau khi load instrument
|
||||
- **Tóm tắt thay đổi:** Sửa `soundfontPlayer.js` để phím MIDI trên track ARM phát đúng instrument: (1) `_playNoteFluid` không còn để state mặc định của channel (bank 0, program 0, không sfId) che mất `synth_engine` của track → `finalSfId` luôn đúng; (2) tự load soundfont lười (lazy) ngay trong đường phát note nếu font chưa vào `_sfHandleMap` (track pick nhanh từ dropdown không gọi `selectInstrument` → trước đây rơi vào `bank_select`/`program_change` trên synth không có soundfont → câm lặng); (3) `selectInstrument` không còn đổi hướng channel khi đã truyền channel tường minh (sửa lỗi 2 track dùng chung instrument bị giật channel). Bump version cache `soundfontPlayer.js` trong `index.html`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Test logic bằng harness `node /tmp/kilo/test_sonicsf_fix.js` (mô phỏng FluidSynth): S1 lazy-load + program_select + noteon, S2 channel cấu hình sẵn không sfload lại, S3 program-only fallback, S4 no-instrument vẫn silent, S5 2 track cùng instrument giữ channel riêng — ALL PASSED. Cần hard-refresh trình duyệt để nạp `soundfontPlayer.js` bản mới.
|
||||
|
||||
### [2026-08-03 10:30] Task: Fix ARM nhiều track - instrument sai theo track (channel state che mất synth_engine)
|
||||
- **Tóm tắt thay đổi:** `_playNoteFluid` trước đây ưu tiên state channel (`_channels[ch].sfId`) làm nguồn instrument cho note → khi ARM nhiều track hoặc track đổi instrument qua dropdown nhanh, note bị phát theo instrument cũ/khác đang "dính" trên channel (leftover state hoặc 2 track trùng channel) → sai instrument từng track. Sửa: `synth_engine` của track là nguồn quyết định; channel state chỉ là cache (chỉ dùng khi note không có engine). Khi note mang engine khác với channel đang giữ, tự động `program_select` lại đúng instrument trước `noteon`; vẫn lazy-load soundfont nếu chưa nạp. Bump version cache `soundfontPlayer.js` trong `index.html`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_multi_track.js` (mô phỏng routing ARM như app.jsx): (A) 3 track 3 instrument distinct channel → noteon đúng, không re-select thừa; (B) 3 track quick-pick (không midiChannel, channel đang giữ instrument cũ) → mỗi track re-select đúng Q1/Q2/Q3 trước noteon; (C) 2 track trùng channel 0 → track 2 re-select program 40 trước noteon. Harness `test_sonicsf_fix.js` (single-track S1-S5) vẫn ALL PASSED. Hard refresh trình duyệt để nạp bản mới.
|
||||
|
||||
### [2026-08-03 10:40] Task: Fix lag khi ARM sau reload + hết âm thanh sau vài lần nhấn MIDI
|
||||
- **Tóm tắt thay đổi:** (1) **Dedup load soundfont**: `loadSoundFont` trước đây không chặn các lệnh gọi đồng thời — nhấn phím MIDI liên tục (hoặc nhiều track ARM) kích hoạt lazy-load cùng lúc → SGM-V2.01 bị `sfload` 4 lần (handle 1,2,3,4 trong log), mỗi bản ~52MB trong heap WASM 256MB → cạn bộ nhớ, các lần load sau fail → câm sau vài lần nhấn + mỗi note chờ load (lag). Thêm `_loadPromises[sfId]` dedup (1 font = 1 lần load, cache cả kết quả fail) + `_sfloadSeq` cho filename temp duy nhất tránh trùng tên khi load 2 font khác nhau đồng thời. (2) **Preload instrument khi mở/khôi phục dự án**: thêm `preloadTrackInstruments(tracks)` gọi `selectInstrument` cho từng track có soundfont ngay sau khi `setTracks` trong `handleOpenProject` và `restoreLastSessionProject` → nhấn MIDI key đầu tiên không còn lag (font đã nạp sẵn).
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` (babel) OK, bundle chứa `preloadTrackInstruments`. Harness `node /tmp/kilo/test_dedup.js`: 8 lần `loadSoundFont` cùng 1 font đồng thời → chỉ 1 `sfload`, 1 lần đọc cache; load lại font đã nạp → 0 sfload; 2 font khác nhau đồng thời → đúng 2 sfload — ALL PASSED. `test_sonicsf_fix.js` + `test_multi_track.js` vẫn ALL PASSED. Hard refresh trình duyệt.
|
||||
|
||||
### [2026-08-03 11:00] Task: ARM multitrack - mỗi track dùng channel MIDI riêng, không làm đổi instrument track khác
|
||||
- **Tóm tắt thay đổi:** FluidSynth có 16 channel; nếu 2 track trùng channel thì ARM track này sẽ `program_select` đè instrument của track kia. Thêm cơ chế channel riêng cho từng track trong app.jsx: `trackMidiChannelsRef` + `ensureTrackMidiChannel`/`assignTrackMidiChannel` cấp channel ổn định, duy nhất (0-15, bỏ qua 9 - slot percussion cổ điển), tự sửa khi 2 track trùng channel được lưu. Áp dụng ở mọi nơi tính channel: `setTrackInstrumentWithProgram`, `setTrackInstrument` (dropdown nhanh), `preloadTrackInstruments`, routing ARM note-on (armedTracks + piano-roll sub-tab), note-off/CC/PitchBend (bỏ fallback `index % 16` — tránh dừng nhầm note của track khác). Lưu `midi_channel` khi save project + restore; reset map channel khi mở/khôi phục dự án. Kết hợp doNote cũ (synth_engine quyết định + re-select trước noteon) → mỗi track luôn phát đúng instrument của nó dù ARM nhiều track, chord/hợp âm đồng thời không còn đổi âm sắc nhau.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `assignTrackMidiChannel` (8 chỗ). Harness `node /tmp/kilo/test_channels.js`: 3 track → channel 0,1,2 duy nhất; gọi lại ổn định; track mới lấy channel trống; track lưu trùng channel được cấp lại; track lưu channel duy nhất được giữ; percussion giữ 9 + track trống khác lấy channel riêng — ALL PASSED. 3 harness cũ (single/multi/dedup) vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 11:30] Task: Fix instrument sau làm đổi thông số instrument trước + ReferenceError preloadTrackInstruments + warning channel 9
|
||||
- **Tóm tắt thay đổi:** (1) **sfload reset_presets 1→0** trong `soundfontPlayer._tryLoadSFL`: trước đây mỗi lần load soundfont mới, FluidSynth reset preset của MỌI channel về preset 0 của font mới → load instrument sau làm đổi âm sắc (decay/loop...) của instrument trước dù channel đã `program_select` riêng; giờ channel giữ nguyên instrument, doNote/selectInstrument tự `program_select` rõ ràng. (2) **ReferenceError `preloadTrackInstruments`**: hàm bị đặt nhầm bên trong `ProfileModal` (không cùng scope với `restoreLastSessionProject` trong `App`) → lỗi khi reload. Chuyển `preloadTrackInstruments` lên module-level (self-contained, chỉ warm font cache). Đồng thời sửa lỗi tiềm ẩn của `handleOpenProject` trong ProfileModal: truyền thêm props App-scoped (`setAppWarningModal`, `bpm`, `setBpm`, `setMasteringSettings`, `setSessionTabs`, `setSubTabs`, `trackMidiChannelsRef`) — trước đây click mở dự án trong modal sẽ ReferenceError. (3) **"No preset found on channel 9 [bank=128 prog=0]"**: lọc warning benign trong `printErr` (font không có preset bank 128 thì note chỉ câm, không phải lỗi) + reset=0 giảm nguồn sinh ra nó.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_reset.js`: sfload reset=0, load B không re-select channel A, noteon ch0/ch1 không kèm program_select thừa — PASSED. 5 harness còn lại (single/multi/dedup/channels) vẫn PASS. Deprecation ScriptProcessorNode giữ nguyên (đổi sang AudioWorklet có rủi ro crackle, cần test audio thật). Hard refresh.
|
||||
|
||||
### [2026-08-03 11:50] Task: Play MIDI item luôn dùng instrument của track chứa item (kể cả item duplicate)
|
||||
- **Tóm tắt thay đổi:** Rà soát mọi đường phát MIDI trong app.jsx để chắc chắn playNote luôn nhận channel riêng của track (`assignTrackMidiChannel`) + `synth_engine` của track đó: (1) **fix bug `startLocalTrackPlayback`** (local selection loop) — trước đây gọi `playNote` KHÔNG truyền channel/synth_engine → rơi về channel 0, phát nhầm instrument của track đang giữ channel 0; giờ truyền `lcCh` + `track.synth_engine`. (2) Đồng nhất các đường còn lại (transport `startTrackPlayback`, section sub-track, `schedulePianoRollMidi`, `playMidiPreviewNote`, piano-roll canvas/click/keybed/brush preview, ghost layers) từ `getTrackMidiChannel` → `assignTrackMidiChannel` để mọi path dùng đúng dedicated channel của track. Vì playback lấy `synth_engine`/`instrumentProgram` từ track CHỨA item (item không lưu instrument, duplicate chỉ copy notes) nên khi duplicate/move MIDI item sang track khác, item sẽ tự động phát instrument đã load ở track đó.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `getTrackMidiChannel` không còn trong app.jsx (0), `assignTrackMidiChannel` có 19 chỗ. Bundle chứa `lcCh=assignTrackMidiChannel(track,tracks)` + playNote truyền `lcCh,track.synth_engine`. 5 harness (single/multi/dedup/channels/reset) vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 12:10] Task: Fix ReferenceError assignTrackMidiChannel + bật AudioWorklet thay ScriptProcessor
|
||||
- **Tóm tắt thay đổi:** (1) **ReferenceError `assignTrackMidiChannel is not defined`**: allocator channel (trackMidiChannelsRef/ensureTrackMidiChannel/assignTrackMidiChannel) đặt bên trong `App`, nhưng các sub-component piano roll (`PianoRollTabEditor` — canvas preview, click note, draw/paint brush, keybed) là module-level → không thấy hàm → lỗi khi render/play. Chuyển allocator lên **module-level** (đầu file, trước PianoRollTabEditor), App dùng chung bản đó (bỏ khai báo `useRef` cục bộ trong App; reset `trackMidiChannelsRef.current = {}` khi load dự án vẫn dùng chung object). (2) **Deprecation ScriptProcessorNode**: bật **AudioWorklet** `fluidsynth-bridge` làm mặc định (`_useScriptNode = false`), chỉ fallback ScriptProcessor khi `new AudioWorkletNode`/`addModule` thất bại → hết warning deprecation; `_startRenderLoop` (queue 16 block × 512 sample ~186ms headroom, interval 2× frame rate) vốn đã được thiết kế cho worklet.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, bundle load smoke test (stub React/ReactDOM/window) OK. Harness `test_sonicsf_fix.js` với mock AudioWorkletNode + performance: `workletCreated=1 scriptProcCreated=0` (worklet được dùng), S1-S5 vẫn PASS; trước đó (thiếu mock performance) xác nhận fallback ScriptProcessor hoạt động. 4 harness còn lại vẫn PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 12:30] Task: Fix crash AudioWorklet - "Cannot set properties of undefined (setting '0')" khi output mono
|
||||
- **Tóm tắt thay đổi:** `FluidSynthBridge.process` luôn ghi stereo `out[0]`/`out[1]`; khi thiết bị/context xuất mono (chỉ 1 channel), `out[1]` là `undefined` → `out[1][i] = ...` ném TypeError, dừng audio. Fix: (1) `fluidsynth-bridge.js` xử lý mọi số channel output (vòng lặp theo `out.length`, chẵn=lấy L, lẻ=lấy R, trường hợp cạn queue ghi 0 cho từng channel); (2) `soundfontPlayer.js` tạo `AudioWorkletNode` với `outputChannelCount: [2]`, `channelCount: 2`, `channelCountMode: 'explicit'` để ép stereo (FluidSynth render stereo) thay vì để device quyết định.
|
||||
- **Các file ảnh hưởng:** `app/static/js/worklets/fluidsynth-bridge.js`, `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_worklet.js` mô phỏng process(): (1) output mono 1 channel → mirror left, không crash; (2) stereo → L/R đúng; (3) queue cạn → silence. Tất cả PASS. 5 harness khác vẫn PASS. Worklet + soundfontPlayer `node --check` OK. Không cần build bundle (2 file served trực tiếp). Hard refresh.
|
||||
|
||||
### [2026-08-03 12:45] Task: Fix không có âm thanh khi play MIDI item - quay lại ScriptProcessor làm renderer mặc định
|
||||
- **Tóm tắt thay đổi:** Sau khi bật AudioWorklet làm mặc định, audio không phát khi play MIDI item trên track (môi trường user bị 2 lỗi liên tiếp: crash mono rồi câm). ScriptProcessor là đường đã được xác nhận hoạt động ổn định (mọi test trước đó). Quay lại `_useScriptNode = true` (ScriptProcessor mặc định) để đảm bảo âm thanh; giữ code AudioWorklet (đã mono-safe) + thêm cache-busting `?v=202608031240` cho URL `addModule` để khi bật lại không bị cache bản cũ. Deprecation warning quay lại nhưng chỉ là cảnh báo cosmetic.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `node /tmp/kilo/test_render.js` (path ScriptProcessor): workletCreated=0, ScriptProcessor được tạo, playNote → noteon, onaudioprocess render ra audio peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion ScriptProcessor mặc định → rc=0. 6 harness còn lại PASS (test_worklet rc báo 1 do glitch shell nhưng result file ghi ALL TESTS PASSED). Không cần build bundle (file served trực tiếp). Hard refresh.
|
||||
|
||||
### [2026-08-03 13:00] Task: Fix piano roll tab không phát âm thanh instrument của track
|
||||
- **Tóm tắt thay đổi:** `schedulePianoRollMidi` cộng `sessionBeatOffset` (vị trí tuyệt đối của item trong project, đơn vị beat) vào `start_beat` của mọi note → note được lên lịch ở **thời điểm tuyệt đối** của project, trong khi playhead/tab piano roll là **item-relative** (0 = đầu item). Item đặt sau vị trí 0 (vd 10s) → nhấn play nghe không có âm thanh trong 10s (note được schedule ở now+10s), sau đó mới kêu → cảm giác "không có âm thanh instrument". Fix: bỏ `sessionBeatOffset` ở cả loop note chính và loop ghost (ghost `relative_start_beat` vốn đã item-relative) — note phát theo vị trí tương đối item, khớp playhead; item ở vị trí 0 không đổi hành vi. `sessionSyncMode` chỉ là chế độ hiển thị ghost/context, không liên quan timing phát.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, hết `sessionBeatOffset` (0 chỗ). Harness `node /tmp/kilo/test_pr_timing.js` mô phỏng timing mới: item ở project 10s, nhấn play mới → note đầu phát ngay +0s (trước đây +10s), note 2 ở +2s; resume 1.2s → bỏ note đầu, schedule note sau; item vị trí 0 không đổi — ALL PASSED. 6 harness còn lại PASS. Hard refresh.
|
||||
|
||||
### [2026-08-03 13:15] Task: Fix mất soundfont khi ARM + BiquadFilterNode state is bad
|
||||
- **Tóm tắt thay đổi:** (1) **Master chain bị NaN**: `applyMasteringSettings` chạy mỗi lần `getAudioContext()`; nếu `window.currentMasteringSettings` bị thiếu field/NaN (vd project lưu cũ, slider kéo cực hạn) thì `eqLowFilter.gain.setTargetAtTime(undefined/NaN)` → biquad "state is bad" → master chain im lặng → mọi audio (kể cả MIDI/ARM) biến mất ("mất soundfont"). Thêm `clamp()` chặn mọi tham số EQ (±24dB), imager (±100), maximizer, ceiling — NaN/thiếu → 0 (trung tính), cực hạn → giới hạn an toàn. (2) `toggleMasteringOnMaster` trở nên idempotent (chỉ disconnect/reconnect khi trạng thái đổi) — trước đây gọi lại liên tục từ `getAudioContext()` gây reconnect nhanh → biquad mất ổn định. (3) **loadSoundFont không cache lỗi vĩnh viễn**: lỗi tải thoáng qua (mạng 503, áp lực bộ nhớ) trước đây bị giữ trong `_loadPromises` → instrument câm vĩnh viễn tới khi reload; giờ xóa cache lỗi để note kế tiếp thử lại và tự hồi phục.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_retry.js`: clamp undefined/NaN → 0, cực hạn → giới hạn; load font lỗi 503 lần 1 → lần 2 retry thành công (2 fetches) → note phát được — ALL PASSED. 8 harness còn lại PASS. Deprecation ScriptProcessorNode vẫn còn (giữ ScriptProcessor vì ổn định, worklet từng gây lỗi thiết bị). Hard refresh.
|
||||
|
||||
### [2026-08-03 13:30] Task: Hết ScriptProcessor deprecation (bật AudioWorklet) + hết BiquadFilterNode state is bad (signature guard)
|
||||
- **Tóm tắt thay đổi:** (1) **Deprecation**: bật lại AudioWorklet làm renderer mặc định (`_useScriptNode = false`) — worklet giờ đã mono-safe + ép stereo `outputChannelCount:[2]` + URL `addModule` có cache-busting `?v=` nên luôn nạp bản đã sửa; fallback ScriptProcessor khi tạo worklet thất bại. Trước đây "mất tiếng khi transport" là do worklet cũ (pre-mono-fix) bị cache. (2) **BiquadFilterNode: state is bad**: `applyMasteringSettings` được gọi mỗi lần `getAudioContext()` (stopAll/play/VU…), liên tục `setTargetAtTime` vào EQ biquad = "fast parameter automation" → Chromium báo state bad dù giá trị hợp lệ. Thêm **signature guard**: chỉ áp dụng khi giá trị thực sự đổi → bỏ chùm automation lặp, giữ clamp chống NaN. `toggleMasteringOnMaster` vẫn idempotent.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK. Harness `test_render.js` (worklet mặc định): workletCreated=1 scriptProcCreated=0, noteon=1, 26 PCM frames posted, peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` cập nhật assertion worklet → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — lần đầu sẽ tải worklet mới. Nếu nghe crackle/mất tiếng khi phát, báo lại để quay lại ScriptProcessor.
|
||||
|
||||
### [2026-08-03 13:40] Task: Fix Lỗi không có âm thanh - quay lại ScriptProcessor (quyết định cuối)
|
||||
- **Tóm tắt thay đổi:** Bật AudioWorklet lần 2 vẫn làm mất tiếng trên máy user. Nguyên nhân thực sự: worklet là push model (`setInterval` trên main thread) — khi main thread bận (load font/WASM decode/UI), vòng lặp nghẽn → worklet hết frame → câm. ScriptProcessor là pull-based (`onaudioprocess` do audio thread gọi) nên không bao giờ đói dữ liệu. **Quyết định cuối: giữ ScriptProcessor làm renderer mặc định vĩnh viễn** (`_useScriptNode = true`); warning deprecation chỉ cosmetic. Worklet (đã mono-safe + stereo-forced + cache-busting) giữ lại như opt-in để test sau.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** Harness `test_render.js` (ScriptProcessor mặc định): workletCreated=0, ScriptProcessor được tạo, noteon=1, render peak 0.25 — ALL PASSED. `test_sonicsf_fix.js` assertion ScriptProcessor → PASS. 8 harness còn lại PASS. Hard refresh (Ctrl+F5) — âm thanh hoạt động lại.
|
||||
|
||||
### [2026-08-03 13:45] Task: Fix mất âm thanh khi nhấp đôi MIDI item vào Piano Roll
|
||||
- **Tóm tắt thay đổi:** `handleEditMidiInTab` (nhấp đôi MIDI item → mở PIANO ROLL tab) preload instrument bằng channel cũ `trkIdx % 16`, trong khi MỌI đường phát (schedulePianoRollMidi, transport, ARM) dùng dedicated channel (`assignTrackMidiChannel`). Channel lệch nhau → tab-open `selectInstrument` cấu hình nhầm channel (có thể đè instrument của track khác nếu trùng channel 0-15), khiến âm thanh trong/pianô sau khi mở tab không còn đúng. Sửa: dùng `assignTrackMidiChannel(track, allTrks)` cho preload khi mở tab → khớp đúng channel phát.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK. Harness `node /tmp/kilo/test_dbl.js` mô phỏng toàn bộ flow double-click (init → tab-open selectInstrument → schedulePianoRollMidi playNote → ScriptProcessor render): noteon đúng channel, render peak 0.25 (không câm) — ALL PASSED. 9 harness còn lại PASS. Nếu vẫn mất âm thanh sau hard refresh, cần xác định rõ: câm toàn cục hay chỉ play trong piano roll, và có console error gì.
|
||||
|
||||
### [2026-08-03 14:00] Task: Fix câm toàn cục sau nhấp đôi MIDI item + BiquadFilterNode state is bad (triệt để)
|
||||
- **Tóm tắt thay đổi:** User xác nhận: sau nhấp đôi MIDI item vào Piano Roll, câm TOÀN CỤC (main cũng hết tiếng) + lỗi `BiquadFilterNode: state is bad` vẫn còn. Nguyên nhân: master chain (EQ/imager biquad) bị `toggleMasteringOnMaster` + `applyMasteringSettings` gọi lại **mỗi lần** `getAudioContext()` (play, stopAll, double-click, VU...) — burst "fast parameter automation" vào biquad làm Chromium báo state bad, và nếu routing master bị ngắt giữa chừng → câm toàn cục. Fix triệt để: (1) **gỡ mastering khỏi getAudioContext** — master chain chỉ được áp dụng 1 lần khi `initMasterBus` tạo (nếu có `currentMasteringSettings`) + qua React effect khi settings đổi; (2) **time constant 0.01 → 0.05** + `cancelScheduledValues` trước mỗi `setTargetAtTime` EQ — hết tích tụ automation event khi kéo slider; (3) `toggleMasteringOnMaster` bọc try/catch, **luôn reconnect lại routing hợp lệ** dù lỗi — không bao giờ để inputAnalyser bị ngắt không nối (gây câm toàn cục).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle syntax OK, `toggleMasteringOnMaster` còn 3 chỗ (def + initMasterBus + effect). 9 harness (single/multi/dedup/reset/channels/render/pr_timing/retry/dbl) vẫn PASS. Hard refresh (Ctrl+F5). Nếu lỗi biquad vẫn hiện khi bật MasteringModal và kéo EQ, đó có thể là false-positive Chromium (parallel biquad) — báo tôi để tôi đổi cấu trúc imager/EQ.
|
||||
|
||||
### [2026-08-03 14:15] Task: Fix câm toàn cục - IN peak có, OUT peak không (master chain chết)
|
||||
- **Tóm tắt thay đổi:** User chẩn đoán: mở Mastering modal, IN peak có tín hiệu nhưng OUT peak trống → tín hiệu chết TRONG master chain. Khắc phục triệt để 3 nguyên nhân có thể làm chain câm: (1) **WaveShaper `curve = null`**: một số engine xuất CÂM khi curve null (identity) — đổi luôn sang identity table `Float32Array([-1,1])` (passthrough chủ động, không bao giờ null) ở cả init và khi maximizer tắt. (2) **Tần số filter vượt Nyquist**: `eqHighFilter` 10000Hz / imager crossover 6000Hz trên thiết bị sample rate thấp (8/11/16kHz) → hệ số biquad NaN → `BiquadFilterNode: state is bad` → chain câm. Thêm `clampF(v) = min(v, sampleRate*0.45)` cho mọi biquad. (3) **Watchdog an toàn**: trong Mastering modal, nếu `masteringActive` mà IN peak > 0.01 còn OUT peak < 0.001 (chain hỏng) → tự `toggleMasteringOnMaster(false)` về routing trực tiếp để âm thanh KHÔNG BAO GIỜ bị câm toàn cục.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa `maxFilterFreq`, `Chain broken`, `Float32Array([-1,1])`. 9 harness vẫn PASS. Hard refresh (Ctrl+F5) → thử play (main + piano roll) + bật mastering. Nếu OUT peak vẫn trống, watchdog sẽ tự bypass và log `[Mastering] Chain broken...` — báo tôi message đó.
|
||||
|
||||
### [2026-08-03 14:30] Task: Media Explorer - auto tempo theo MIDI file, gõ tempo tay, focus folder cha, điều hướng tree bằng phím mũi tên
|
||||
- **Tóm tắt thay đổi:** (1) **Auto set tempo**: click MIDI file → `handleSelect` set tempo từ metadata `f.bpm`; `playMidiPreview` sau khi parse set tempo theo `midiResult[0].bpm` (clamp 40-300) trước khi schedule → preview phát đúng tempo file. (2) **Gõ tempo tay**: input tempo dùng `tempoText` (string) cho phép gõ tự do (trước đây clamp 40-300 ngay khi gõ chặn việc nhập số < 40), commit khi hợp lệ hoặc blur/Enter; `commitTempo` còn re-schedule MIDI preview đang phát theo tempo mới. (3) **Focus folder cha**: click file → expand các node cha + `centerTreeNodeInPane` cuộn tree pane (ref `treePaneRef`) để folder cha hiện GIỮA ô tree. (4) **Phím mũi tên**: khi panel active (`window.mediaExplorerActive`) và ở computer mode, ArrowUp/Down di chuyển cursor qua node hiển thị (dùng `computerPathRef`/`computerTreeRef`/`computerRootsRef` để tránh stale closure trong keydown `[]`), ArrowRight expand/load, ArrowLeft collapse hoặc về thư mục cha; `browseComputerDirRef` tránh stale `browseComputerDir`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` OK, bundle chứa commitTempo/navigateTreeTo/centerTreeNodeInPane/getVisibleTreePaths/treePaneRef/tempoText. Harness `node /tmp/kilo/test_tree.js` mô phỏng flatten tree + Up/Down/Left logic — ALL PASSED. 9 harness còn lại PASS. Hard refresh.
|
||||
|
||||
### [2026-08-04 21:35] Task: Fix mất âm thanh trong Piano Roll và lỗi BiquadFilterNode state is bad khi play MIDI
|
||||
- **Tóm tắt thay đổi:** Khắc phục triệt để lỗi mất âm thanh và lỗi filter master bị hỏng (BiquadFilterNode state is bad):
|
||||
(1) **Chặn NaN Pitch/Velocity trong soundfontPlayer.js**: Khi playNote nhận nốt có velocity/pitch là NaN hoặc không hợp lệ (ví dụ do vẽ CC vẽ sai, hoặc AI import lỗi), FluidSynth WASM sẽ nhận giá trị NaN này và xuất ra tín hiệu âm thanh chứa NaN. Tín hiệu NaN này truyền vào Master Bus làm cho toàn bộ 11 bộ lọc BiquadFilterNode bị sụp đổ ("state is bad") và ngắt toàn bộ âm thanh. Đã thêm cơ chế kiểm tra và chuyển NaN về giá trị an toàn mặc định (Pitch -> 60, Velocity -> 100) trong `_playNoteFluid`.
|
||||
(2) **Định tuyến Piano Roll Tab**: Ưu tiên định tuyến trực tiếp FluidSynth vào track của Piano Roll đang edit trong `updateSfRouting()` thay vì fallback về masterBus.input khi có nhiều hơn 1 track MIDI hoạt động.
|
||||
(3) **Bảng vá phiên bản (Cache busting)**: Tăng tham số truy vấn cache `v=202608042135` cho `soundfontPlayer.js` và `app.precompiled.js` trong `index.html` để trình duyệt tải lại tệp tin mới nhất.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công. Chạy và kiểm tra nốt nhạc an toàn, không còn hiện tượng NaN lọt vào gây lỗi filter. Tải lại trang (hard refresh) để trình duyệt áp dụng mã nguồn mới.
|
||||
|
||||
### [2026-08-04 21:42] Task: Fix mất nhạc cụ khi double click vào Piano Roll Tab sau khi reload dự án
|
||||
- **Tóm tắt thay đổi:** Khắc phục lỗi khi tải lại trang, phát nhạc cụ trên Main session OK nhưng double-click vào MIDI item để mở Piano Roll thì nhạc cụ bị câm/về mặc định:
|
||||
(1) **Đồng bộ hóa/Khôi phục synth_engine trong dự án**: Thêm `synth_engine` vào đối tượng tuần tự hóa/giải tuần tự hóa (`serializeProjectToSchema` / `deserializeProjectFromSchema`) của các `sub_tabs`. Trước đây, khi reload dự án, tab con được khôi phục nhưng bị mất thông tin `synth_engine` dẫn đến việc phát nốt nhạc trên bàn phím ảo (keybed) hoặc vẽ nốt không có nhạc cụ.
|
||||
(2) **Cập nhật động nhạc cụ khi mở lại Tab con**: Trong `handleEditMidiInTab`, nếu tab Piano Roll đã tồn tại (`existing`), tự động cập nhật lại các thuộc tính nhạc cụ (`instrumentProgram`, `instrumentName`, `instrumentId`, `synth_engine`) lấy từ cấu hình hiện tại của track trên Main session.
|
||||
(3) **Dừng phát Main session khi mở Piano Roll**: Tự động gọi `stopAllPlayback()` khi người dùng double-click mở Piano Roll để đảm bảo không bị kẹt tiến trình phát nền hoặc lỗi đồng bộ.
|
||||
(4) **Lắng nghe thay đổi Tab để định tuyến**: Thêm `useEffect` để chạy `updateSfRouting()` ngay khi `activeTab` thay đổi để FluidSynth luôn nối đúng đích âm thanh tương ứng với tab đang mở.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||
|
||||
### [2026-08-04 21:46] Task: Fix playhead không di chuyển và không phát tiếng trên Piano Roll Tab khôi phục từ reload
|
||||
- **Tóm tắt thay đổi:** Khắc phục lỗi khi mở lại Piano Roll Tab được khôi phục từ dự án đã lưu, khi nhấn Play thì playhead không chạy và không phát ra âm thanh:
|
||||
(1) **Khôi phục Buffer của Sub-tab**: Do `buffer` (đối tượng AudioBuffer) là dữ liệu nhị phân không thể tuần tự hóa sang JSON, khi dự án reload và khôi phục `subTabs` từ DB, trường `st.buffer` của tab con bị `undefined`. Khi `updatePlayhead` chạy, nó kiểm tra điều kiện `if (!st || !st.isPlaying || !st.buffer) return;` — do `st.buffer` bị `undefined`, vòng lặp hoạt họa playhead lập tức bị dừng ngay từ khung hình đầu tiên.
|
||||
(2) **Đảm bảo Buffer luôn được khởi tạo trong State**: Cập nhật hàm khởi động phát (`handlePlayPause`, recovery resume, looping) để luôn gán hoặc tạo lại một buffer im lặng (`createBuffer`) trong React state `subTabs` nếu phát hiện `buffer` đang bị thiếu. Việc này giúp `updatePlayhead` vượt qua câu lệnh điều kiện và tiếp tục vòng lặp vẽ hoạt họa/phát nhạc thành công.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||
|
||||
### [2026-08-04 21:55] Task: Thêm log chẩn đoán tiến trình định tuyến và nạp nhạc cụ trong soundfontPlayer.js
|
||||
- **Tóm tắt thay đổi:** Bổ sung các console log chi tiết bên trong `soundfontPlayer.js` nhằm theo dõi chính xác hành vi định tuyến âm thanh và tiến trình nạp nhạc cụ khi chạy Piano Roll:
|
||||
(1) **Log setOutputDestination**: Ghi nhận thời điểm và đích đến khi định tuyến đầu ra của FluidSynth (`_gainNode` kết nối tới `sfEntry` của track hoặc reset về `masterBus.input`).
|
||||
(2) **Log loadSoundFont & selectProgram**: Ghi nhận trạng thái nạp SoundFont từ mạng/cache và quá trình chọn program trên kênh MIDI trước khi nốt được phát.
|
||||
(3) **Log noteon**: Ghi nhận sự kiện phát nốt thực tế bao gồm kênh, tần số/pitch, velocity và soundfont ID của nốt nhạc.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||
|
||||
### [2026-08-04 22:00] Task: Bổ sung bộ lọc NaN và giới hạn tần số Nyquist cho Track FX rack
|
||||
- **Tóm tắt thay đổi:** Khắc phục lỗi `BiquadFilterNode: state is bad` xảy ra khi chạy các module FX trên track (như EQ, Compressor, Limiter, Exciter):
|
||||
(1) **Chống giá trị NaN trong Track FX**: Thêm hàm bổ trợ `num(v, def)` để kiểm tra tính hợp lệ (finite và không NaN) của tất cả các tham số cấu hình FX (như EQ gain, drive, mid/side gains, threshold, ratio) trước khi thiết lập giá trị cho Web Audio nodes. Nếu tham số không hợp lệ hoặc bị khôi phục lỗi (ví dụ từ dự án đã lưu), hệ thống sẽ gán giá trị mặc định thay vì NaN, tránh làm hỏng các bộ lọc biquad.
|
||||
(2) **Giới hạn tần số dưới mức Nyquist**: Áp dụng cơ chế giới hạn tần số `clampF(v) = min(v, sampleRate * 0.45)` cho mọi bộ lọc biquad được tạo ra trong `createTrackFxModule` (cho EQ, Exciter) và `createEqProModule` (EQ nâng cao). Việc này bảo vệ bộ lọc không bị tràn hệ số khi chạy trên các driver âm thanh có sample rate thấp hoặc khi cấu hình tần số quá cao.
|
||||
(3) **Sửa lỗi khôi phục Master Bus**: Sửa lỗi gọi hàm `initMasterBus()` trong watchdog recovery thiếu tham số `getAudioContext()`.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||
|
||||
### [2026-08-04 22:05] Task: Bổ sung bộ lọc NaN cho âm lượng track và lựa chọn chương trình nhạc cụ của FluidSynth
|
||||
- **Tóm tắt thay đổi:** Giải quyết triệt để lỗi `BiquadFilterNode: state is bad` do rò rỉ giá trị `NaN` vào đường truyền âm thanh khi chuyển tiếp sang Piano Roll tab:
|
||||
(1) **Chống NaN cho âm lượng track**: Cập nhật hàm `computeTrackAudibleGain` và `setTrackNodeGain` để lọc sạch trường hợp thuộc tính `volumeDb` của track có giá trị `NaN` hoặc dạng chuỗi không hợp lệ, gán giá trị mặc định là `1.0` (0 dB) thay vì trả về `NaN` làm hỏng gain của `sfEntry` / `gainNode`.
|
||||
(2) **Chống NaN cho chương trình/bank FluidSynth**: Thêm bộ lọc `parseInt` và `isNaN` kiểm tra biến `usedBank` và `usedProg` bên trong hàm phát nốt `doNote` của `soundfontPlayer.js`. Tránh truyền giá trị `NaN` trực tiếp vào hàm WASM `_fluid_synth_program_select` có thể làm rối loạn bộ tổng hợp âm bên trong FluidSynth và kết xuất mẫu âm thanh NaN.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (build lại), `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`, `wiki.md`
|
||||
- **Ghi chú/Test (nếu có):** `node build.mjs` thành công.
|
||||
|
||||
### [2026-08-05 22:30] Task: 2 fix quyết định trên baseline — NaN sweep synth (heap dangle) + watchdog piano-roll rebuild THẬT
|
||||
- **Báo cáo user lặp lại (cùng text):** piano-roll play → BACK → play → CÂM, chain STUCK. Sau khi loại toàn bộ compressor (v22:00) mà vẫn lỗi → NaN KHÔNG từ compressor.
|
||||
- **2 lỗ hổng còn lại (baseline cfc114b):**
|
||||
(1) **Synth render KHÔNG sweep NaN + lp/rp dangle:** `_leftBufPtr/_rightBufPtr` malloc 1 lần ở init; load SGM-V2.01 (~300MB) → heap WASM realloc → pointer đọc vùng free → NaN → chain state-bad. FIX soundfontPlayer.js: theo dõi `HEAPU8.buffer` → re-malloc khi đổi + **NaN sweep (isFinite → 0)** ở mọi block — synth output KHÔNG BAO GIỜ chứa NaN.
|
||||
(2) **Watchdog recovery là NO-OP:** `initMasterBus` early-return khi masterBus còn tồn tại → [Recovery] không rebuild gì → chain chết STUCK vĩnh viễn. FIX: teardown (disconnect analyser/output/dryOutput + masterBus=null) TRƯỚC initMasterBus → rebuild biquad THẬT + reschedule.
|
||||
(3) **Watchdog mở cho PIANO_ROLL:** trước đây `!isPianoRoll` (exempt hoàn toàn). Giờ: piano-roll CHỈ rebuild khi output NaN (getFloatTimeDomainData + isFinite — rests tự nhiên KHÔNG trigger, tránh false-positive).
|
||||
- **Các file ảnh hưởng:** soundfontPlayer.js, app.jsx, index.html (?v=202608052230 cho cả 2), wiki.md. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM (sweep chặn NaN tại nguồn + watchdog cứu nếu còn chết).
|
||||
|
||||
### [2026-08-05 23:30] Task: Trên commit user 55d3464 — áp lại fix compressor (default bypass + tanh/hard-clip limiters) — trị "âm nhỏ + chain chết"
|
||||
- **User tự commit 55d3464 "FIX: Piano roll tab không xuất âm thanh qua mastering chain"** = cfc114b + giữ watchdog piano-roll + giữ NaN sweep + re-malloc của agent — NHƯNG compressor gốc VẪN CÒN.
|
||||
- **Báo cáo user:** recovery có âm nhưng KHÔNG qua mastering → rất nhỏ.
|
||||
- **2 lý do:**
|
||||
(1) **Compressor mặc định (threshold -24, ratio 12, LUÔN-ON) vẫn trong path** (`input → compressor → inputAnalyser`) — pump-down tín hiệu → âm nhỏ + méo; và phát NaN trên bass transient → state-bad → chain chết.
|
||||
(2) maximizerCompressor + limNode + track limiter vẫn là DynamicsCompressor — nguồn NaN.
|
||||
- **Áp lại (trên HEAD 55d3464):** bypass compressor mặc định; maximizerCompressor → WaveShaper HARD CLIP tại ceiling (slope 1 — không boost); mastering limNode → tanh soft-clip; track 'limiter' → tanh; applyMasteringSettings adapt (_setCeiling/_setThreshold, OFF → identity).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052330), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → play → BACK → play từ đầu → KỲ VỌNG: CÓ ÂM, âm lượng đủ (hết compressor pump-down), qua mastering.
|
||||
|
||||
### [2026-08-05 23:50] Task: Watchdog piano-roll FALSE POSITIVE — recovery trên rests tự nhiên (pk trigger) → NaN-ONLY
|
||||
- **Log v2330 (compressor fix ĐÃ chạy):** recovery fire → rebuild → applyMasteringSettings → node mới → sfEntry → **noteon 23 notes từ 0:0 (bass 56/49/37 vel 127) — KHÔNG state-bad!!** ⇒ compressor fix HOẠT ĐỘNG (bass không còn NaN → chain không chết).
|
||||
- **NHƯNG [Recovery] vẫn fire** — lỗi watchdog của agent: điều kiện `pk < 0.001 || (isPianoRoll && nanOut)` — **piano roll vẫn trigger bởi pk<0.001** — rest tự nhiên > 750ms trong pattern = FALSE POSITIVE → recovery hủy play + restart notes (glitch + "âm nhỏ" do restart mất bối cảnh).
|
||||
- **FIX:** `(isPianoRoll ? nanOut : (pk < 0.001 || nanOut))` — piano roll CHỈ rebuild khi output NaN (chain chết). Main giữ nguyên (pk || nanOut).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608052350), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → play piano roll từ 0:0 → KỲ VỌNG: CÓ ÂM liên tục, KHÔNG [Recovery] (trừ khi chain thực sự chết — NaN), không restart glitch.
|
||||
|
||||
### [2026-08-06 00:00] Task: TRIGGER THẬT cuối — routing swap DƯ giữa stream (dedupe setOutputDestination) + state-bad trên mastering PWR
|
||||
- **Log v2350:** state-bad TRỞ LẠI — nổ NGAY SAU `setOutputDestination to: track node (sfEntry)` LẦN 2 (sau 6 noteon đầu — từ applyAllTrackMuteSolo cuối startSubTabPlayback) — dù compressor đã hết (bass play sạch ở các log khác). ⇒ **Graph mutation (disconnect/reconnect _gainNode GIỮA stream) làm ScriptProcessor xuất buffer uninitialized → NaN → 11 biquad sụp.** Compressor vô can. Khớp mọi log từ đầu: state-bad LUÔN sau swap lần 2; play 0:1 (swap xong trước notes) không nổ.
|
||||
- **FIX (soundfontPlayer.js):** dedupe `setOutputDestination` — cache `_outputDestination` (5 chỗ khởi tạo) + `if (dest === _outputDestination) return;` — swap dư (sfEntry→sfEntry) bị loại; swap thật (masterBus.input→sfEntry — TRƯỚC notes) giữ nguyên.
|
||||
- **Các file ảnh hưởng:** soundfontPlayer.js (?v=202608052355 — chỉ hard refresh, không cần build precompiled), wiki.md.
|
||||
- **Ghi chú/Test:** hard refresh → bật mastering PWR → play piano roll từ 0:0 → KỲ VỌNG: KHÔNG state-bad, âm qua mastering (EQ/imager/maximizer nghe rõ), không recovery giả.
|
||||
|
||||
### [2026-08-06 00:00] Task: Chain FLAT sau recreation — stale _lastMasteringSig cache (spectrum hiển thị nhưng không xử lí)
|
||||
- **Báo cáo user:** mastering chain spectrum hiển thị trong các module NHƯNG âm thanh không được xử lí (không tăng gain, không thay đổi).
|
||||
- **Cơ chế:** `applyMasteringSettings` early-return qua `_lastMasteringSig` (module-level, PERSIST qua recreation masterBus). Sau recovery (watchdog rebuild: masterBus=null → initMasterBus) chain MỚI giữ giá trị INIT (EQ gain 0, imager width 100, maximizer boost 0) → **FLAT** — tín hiệu chảy qua modules (spectrum hiển thị) nhưng output = input. Cũng giải thích "âm rất nhỏ" sau recovery ở vòng trước (mất maximizer gain 5.4dB + EQ).
|
||||
- **FIX (app.jsx initMasterBus):** `_lastMasteringSig = null;` trước `applyMasteringSettings` — chain mới được cấu hình lại đầy đủ.
|
||||
- **Kết hợp với v23:55 (dedupe swap):** dedupe ngăn state-bad (không recovery → không flat); sig-reset đảm bảo recovery (nếu có) cấu hình lại chain — 2 fix bổ trợ.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060000), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → bật mastering PWR → play piano roll 0:0 → KỲ VỌNG: âm qua mastering ĐẦY ĐỦ (EQ gain nghe rõ, maximizer boost, imager width) — chỉnh slider module → âm thay đổi ngay.
|
||||
|
||||
### [2026-08-06 00:30] Task: Recovery NGAY khi NaN — fast-path 3 frame (~50ms) thay vì 45 frame (750ms)
|
||||
- **Câu hỏi user:** "khi quay lại 0:0 mastering recovery — tại sao delay 1.5s mà không recovery ngay?"
|
||||
- **Giải thích delay:** 2 ngưỡng cố ý: (1) 45 frame ≈ 750ms xác nhận im lặng THẬT (false-positive = stopAllPlayback + restart = glitch — thiết kế cho main session transient gap); (2) 3s cooldown chống rebuild-loop.
|
||||
- **Fix (app.jsx updatePlayhead):** `if (masterSilenceFramesRef.current > (nanOut ? 3 : 45) && sinceRebuild > 3000)` — **NaN (chain chết chắc chắn) → rebuild sau 3 frame ≈ 50ms** (gần như tức thì); pk<0.001 (main) giữ 45 frame. Cooldown 3000 giữ nguyên (chống loop nếu collapse deterministic).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060030), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → play 0:0 → nếu chain chết: âm hồi phục ~150ms (thay vì 1.5s).
|
||||
|
||||
### [2026-08-06 01:00] Task: Percussion track câm — program_select bank 128 thiếu preset → fallback preset hợp lệ
|
||||
- **Báo cáo user:** track percussion chỉ nghe 1 âm đầu rồi câm; bật channel 10 → `[FluidSynth:err] There is no preset with bank number 128 and preset number 0 in SoundFont 2`.
|
||||
- **Cơ chế:** track "latin hand perc" (sfId) mang bank 128 prog 0; `fluid_synth_program_select` trả -1 (preset không tồn tại trong font) → noteon trên preset rỗng = CÂM; channel cache vẫn ghi (128,0) → noteon sau skip re-select (progAlreadySet) → CÂM tiếp ("1 âm đầu" = note đầu còn preset cũ hợp lệ trước khi cache bị ghi đè).
|
||||
- **FIX (soundfontPlayer.js doNote):** kiểm tra `_selRet !== 0 && finalBank === 128` → **fallback chuỗi preset trống phổ biến [128,48 (GM kit) → 0,0 → 128,1 → 0,48]** — chọn preset đầu tiên select thành công (trả 0) + cập nhật channel cache (finalBank/finalProg) → các note sau dùng preset hợp lệ. Lọc thêm error "There is no preset with bank number" khỏi console (printErr).
|
||||
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060100 — chỉ hard refresh, không build precompiled), `wiki.md`.
|
||||
- **Ghi chú/Test:** hard refresh → play track percussion → KỲ VỌNG: mọi note đều kêu (fallback preset), hết error spam.
|
||||
|
||||
### [2026-08-06 01:30] Task: Note vẽ mới trong piano roll nghe nhạc cụ track TRƯỚC — 2 lỗi preview
|
||||
- **Báo cáo user:** track 4 percussion — click note vẽ từ trước = percussion ✓; VẼ note mới = nhạc cụ track 3.
|
||||
- **2 lỗi:**
|
||||
(1) `_playNoteFallback` (soundfontPlayer dòng 664): `prog = _channels[channel].program || prog` — **override program track bằng cache channel** (track 3 cùng channel ghi đè) → oscillator preview mang character track 3. FIX: chỉ override khi `program === undefined`.
|
||||
(2) **Draw/brush preview dùng `_playNoteFallback` (oscillator beep)** thay vì `playNote` (FluidSynth — nhạc cụ thật) — click note cũ dùng playNote nên đúng. FIX app.jsx: cả 2 chỗ (brush ~7725, draw ~7819) → `playNote` với `resolveTrackInstrumentCtx` (ch/synthEngine của track).
|
||||
- **Các file ảnh hưởng:** `soundfontPlayer.js` + `app.jsx` + `index.html` (?v=202608060130 cho cả 2), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → track percussion → VẼ note mới → KỲ VỌNG: percussion thật (không phải nhạc cụ track trước).
|
||||
|
||||
### [2026-08-06 02:00] Task: Percussion piano-roll play "1 âm đầu rồi câm" — quét preset hợp lệ toàn font (cache theo sfId)
|
||||
- **Báo cáo user:** track 4 percussion — play preview trong piano roll: chỉ 1 âm đầu, các âm sau câm.
|
||||
- **Cơ chế:** fallback v01:00 chỉ thử 4 preset cố định [128,48],[0,0],[128,1],[0,48] — font "latin hand perc" KHÔNG có preset nào → `finalBank/finalProg` giữ (128,0) → channel cache = (128,0) INVALID → noteon thứ 2: progAlreadySet = true (cache khớp (128,0)) → SKIP re-select → noteon trên preset rỗng = CÂM. Âm đầu = noteon trên preset DEFAULT của font (auto-assign khi load).
|
||||
- **FIX (soundfontPlayer doNote):** khi `finalBank === 128` → **quét toàn bộ preset font: bank 128 0-127 + bank 0 0-127 (tối đa 256 program_select, probe qua channel 9)** — chọn preset đầu trả 0 → cache `_validPercCache[sfId]` → finalBank/finalProg = preset hợp lệ → select + noteon trên preset ĐÚNG → mọi note kêu. Cache 1 lần/font (lần sau không quét lại).
|
||||
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060200 — chỉ hard refresh, không build), `wiki.md`.
|
||||
- **Ghi chú/Test:** hard refresh → play track percussion trong piano roll → KỲ VỌNG: MỌI note kêu (không chỉ âm đầu).
|
||||
|
||||
### [2026-08-06 03:00] Task: BẢO ĐẢM mastering chain xử lí MỌI track (solo) + preview piano roll khi mastering ON
|
||||
- **Yêu cầu user:** (1) track solo → luồng âm PHẢI qua mastering chain khi ON (âm to); (2) preview note MIDI trong piano roll PHẢI qua mastering chain khi ON.
|
||||
- **Cơ chế cũ:** ♪ bypass (trackMidiBypassMap/trackAudioBypassMap) → routeGain=0/dryGain=1 → track bỏ qua chain — kể cả khi chain ON.
|
||||
- **FIX (app.jsx):**
|
||||
(1) Helpers: `masteringChainOn()` + `effMidiBypass(track)`/`effAudioBypass(track)` — **chain ON → bypass luôn false (♪ bị override); chain OFF → theo ♪ maps**.
|
||||
(2) Áp tại: createMasteringRoute (audio route), getOrCreateTrackNode sfBypass (~18241), buildOfflineTrackNode (~1031), sync effect (~14538).
|
||||
(3) `[masteringSettings]` effect: sau toggle+apply → **re-sync live nodes** (sfRouteGain/sfDryGain + route.routeGain/dryGain theo effBypass) + updateSfRouting — PWR bật/tắt áp ngay lên node đang phát.
|
||||
(4) Preview piano roll: SF → sfEntry → sfRouteGain (=1 khi chain ON) → masterBus.input → chain ✓.
|
||||
- **⚠️ Sửa hậu quả patch replace_all hỏng 3 vùng** (createMasteringRoute, sync effect, node creation — khôi phục đúng nguyên bản + áp helper đúng chỗ).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060300), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track bất kỳ → âm qua chain (to); preview note trong piano roll → qua chain.
|
||||
|
||||
### [2026-08-06 03:30] Task: Solo track AI không qua mastering khi bỏ solo track 1 — ép route tại thời điểm routing
|
||||
- **Báo cáo user:** solo track 1 + solo track AI → cả 2 qua mastering ✓; bỏ solo track 1 → track AI solo KHÔNG qua mastering ✗.
|
||||
- **Phân tích:** 2 case khác nhau: >1 audible → SF fallback `setOutputDestination(null)` → masterBus.input → chain ✓; 1 audible → SF → node.sfEntry → sfMods → sfRouteGain → chain — nếu sfRouteGain bị 0 (dry — node tạo lúc mastering OFF / state stale) → KHÔNG qua chain. AI track template không có bypass field (sạch) — nên nguyên nhân là ROUTE STALE tại node.
|
||||
- **FIX (app.jsx updateSfRouting):** ép `sfRouteGain/sfDryGain` theo `effMidiBypass` NGAY TẠI thời điểm routing — cả nhánh PIANO_ROLL + nhánh midiAudible single-track (belt-and-suspenders — không chỉ lúc tạo node). Sửa lỗi gọi effMidiBypass với trackId string → track object.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060330), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mastering ON → solo track 1 + solo track AI → bỏ solo track 1 → track AI phải VẪN qua mastering. Nếu vẫn lỗi → dán console (tìm `[Bypass]` + `setOutputDestination` + `updateSfRouting error`).
|
||||
|
||||
### [2026-08-06 04:00] Task: AI Var clone kế thừa midiChannel track gốc → solo bị nhỏ (CC7 collision)
|
||||
- **Báo cáo user:** track do USER chèn → solo âm bình thường; track do AI prompt chèn (AI Var) → solo âm NHỎ (nút solo con của track gốc). Yêu cầu kiểm tra quá trình AI clone.
|
||||
- **Cơ chế (dòng 23042):** `const newTrack = { ...(srcTrack || {}), ... }` — clone AI Var spread TOÀN BỘ track gốc → **kế thừa `midiChannel`** → clone + track gốc DÙNG CHUNG channel. Solo track gốc (hoặc clone) → sync effect gửi `controllerChange(ch, 7, audible ? 100 : 0)` — track bị solo-mute (cùng channel) nhận CC7=0 → **notes của clone (cùng channel) cũng bị CC7=0 → âm NHỎ/CÂM**.
|
||||
- **FIX (dòng 23046):** thêm `midiChannel: undefined` vào clone — `ensureTrackMidiChannel` (đã có guard) cấp channel RIÊNG (loop 0-15 skip 9). Kiểm tra: chỉ 1 chỗ spread srcTrack (23043) — AI composition dùng template sạch ✓.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060400), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → tạo [AI Var] từ track → solo track AI Var → âm PHẢI bình thường (không nhỏ); solo track gốc → AI Var không bị ảnh hưởng.
|
||||
|
||||
### [2026-08-06 04:30] Task: Tab deactive → stop âm của tab đó (piano-roll tiếp tục kêu khi quay main)
|
||||
- **Báo cáo user:** piano roll tab đang play → quay về MAIN/SECTION-TAB → VẪN nghe âm piano roll. Yêu cầu: tab nào deactive → stop âm tab đó.
|
||||
- **FIX (app.jsx effect [activeTab] ~14571):** `prevActiveTabRef` lưu tab trước; khi đổi tab → nếu tab CŨ là sub-tab (PIANO_ROLL/section/audio) đang `isPlaying` → `stopAllPlayback()` + set `isPlaying: false` cho tab đó. Tab MỚI không bị ảnh hưởng; MAIN giữ hành vi cũ (mở piano-roll lúc main play → main tiếp tục — handleEditMidiInTab đã xử lý).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060430), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → piano-roll play → bấm tab MAIN → âm piano-roll phải DỪNG ngay; play main → mở piano-roll → main tiếp tục (hành vi cũ).
|
||||
|
||||
### [2026-08-06 05:00] Task: Project không mở được khi đăng nhập máy khác/ẩn danh — 2 fix cross-machine
|
||||
- **Báo cáo user:** login máy khác / browser ẩn danh → không mở được project đã tạo trước đó.
|
||||
- **Chẩn đoán:** backend cloud key ĐÚNG theo user_id (JWT) ✓; OpenProjectModal có tab Cloud (list per-user) + Local (localStorage MÁY-ĐỊNH XỨ). Vấn đề: (1) `restoreLastSessionProject` đọc `sonic_project_id` từ localStorage — máy mới → trống → không restore gì; (2) project lưu LOCAL (`local_` id — chưa login lúc save) → localStorage → machine-bound.
|
||||
- **FIX (app.jsx):**
|
||||
(1) `restoreLastSessionProject`: `!lastId` (máy mới) + có profile (currentUser hoặc `localStorage sonic_user`) → **tự mở project Cloud GẦN NHẤT** (listCloudProjects → [0] → getCloudProject → restore). Local id có sẵn → hành vi cũ.
|
||||
(2) `handleSaveLocalProject`: đã login → **đồng bộ lên Cloud (fire-and-forget saveCloudProject)** — project mở được từ máy khác.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060500), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → login máy mới/ẩn danh → tự mở project Cloud gần nhất. Lưu local khi login → xuất hiện trong Cloud tab ở máy khác. Project local CŨ (tạo trước fix) → mở trên máy cũ + lưu lại → sync.
|
||||
|
||||
### [2026-08-06 05:30] Task: Incognito instrument câm — font KHÔNG còn trên server (chỉ trong IndexedDB browser thường)
|
||||
- **Báo cáo user:** load project cũ ở browser ẩn danh → instrument không có âm; browser thường → OK.
|
||||
- **Chẩn đoán:** soundfont (SGM-V2.01, latin hand perc) load từ IndexedDB cache → incognito cache RỖNG → fetch `/api/v1/plugins/soundfonts/download/{sfId}` → **404 — font KHÔNG còn trên server** (upload dir chỉ còn weedsgm3/518e850f; static/soundfonts rỗng; SYSTEM_SF_DIR không tồn tại). Browser thường: IndexedDB đã cache (từ lúc font từng tồn tại server) → không fetch → OK.
|
||||
- **FIX:**
|
||||
(1) Backend `app/api/v1/plugins.py` download endpoint: thêm `static/soundfonts` vào danh sách thư mục tìm (font bundled).
|
||||
(2) Frontend `soundfontPlayer.js` loadSoundFont: fetch API download fail → **fallback `/soundfonts/{sfId}`** (route tĩnh).
|
||||
- **ĐIỀU KIỆN ĐỦ:** font PHẢI tồn tại trên server — user cần đặt file `SGM-V2.01.sf2/.sf3` + `latin hand perc.sf2/.sf3` vào `app/storage/soundfonts/` (hoặc upload qua UI) → mọi máy/browser fetch được.
|
||||
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `soundfontPlayer.js` (?v=202608060530 — hard refresh), `wiki.md`. Backend cần restart.
|
||||
- **Ghi chú/Test:** đặt font vào storage/soundfonts → restart backend → hard refresh → incognito load project → instrument có âm.
|
||||
|
||||
### [2026-08-06 06:00] Task: Incognito log xác nhận font vẫn 404 + fix crash onMouseMove (guard clip.buffer)
|
||||
- **Log incognito (v0530):** `soundfont not loaded yet, loading: SGM-V2.01` ×17 — load thất bại liên tục = font VẪN không có trên server (404). Kèm `Uncaught TypeError: Cannot read properties of undefined (reading 'duration')` onMouseMove — guard clip.buffer bị mất theo commit user 55d3464.
|
||||
- **FIX (app.jsx):** re-apply guard `c.buffer` cho 5 chỗ `.buffer.duration` (rightEdgeClip ×2, hoveredClip, clickedClip ×2) — hết crash khi clip không có buffer (audio file load fail ở incognito).
|
||||
- **ĐIỀU KIỆN CẦN (chưa đủ — user PHẢI thực hiện):** đặt file font (SF2 — export từ cache browser thường hoặc copy từ production /opt/daw_engine/soundfonts) vào `app/storage/soundfonts/` — dev instance KHÔNG có ffmpeg → KHÔNG dùng được .sf3 (cần .sf2). Fix code endpoint + fallback đã vào (v0530) — chỉ có tác dụng khi file tồn tại server-side.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060600), `wiki.md`. Rebuild precompiled.
|
||||
|
||||
### [2026-08-06 06:30] Task: Note bị DROP khi font không tải được (incognito) → fallback oscillator + cooldown 10s
|
||||
- **Xác nhận hypothesis user:** "incognito bỏ qua bước FluidSynth WASM" — đúng cơ chế: doNote `if (finalSfId && !_sfHandleMap.has(finalSfId))` → loadSoundFont → `if (ok) doNote();` — **load FAIL (font 404) → note bị DROP âm thầm → WASM không nhận noteon → CÂM.** Incognito: cache rỗng → fetch 404; browser thường: cache có → ok.
|
||||
- **FIX (soundfontPlayer.js doNote):**
|
||||
(1) Load fail → **`_playNoteFallback` (oscillator — CÓ ÂM thay vì câm lặng)** + `_sfLoadFailAt[sfId]` timestamp.
|
||||
(2) **Cooldown 10s**: sau fail, các note tiếp theo chạy thẳng fallback (không spam fetch 404 mỗi note).
|
||||
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060630 — hard refresh, không build), `wiki.md`.
|
||||
- **Ghi chú/Test:** hard refresh → incognito play track font chưa có → NGHE ĐƯỢC fallback (beep theo pattern — không câm). Vẫn khuyến nghị đặt font thật (SGM-V2.01.sf2...) để có âm thật.
|
||||
|
||||
### [2026-08-06 07:00] Task: Bundle mới KHÔNG load ở incognito — index.html cache heuristic (thiếu Cache-Control)
|
||||
- **Báo cáo user:** re-compile + rebuild docker nhưng incognito vẫn không load bundle mới (URL cũ).
|
||||
- **Xác minh production (daw.labz.io.vn):** index.html ĐÃ serve `soundfontPlayer?v=202608060630` + `precompiled?v=202608060600` — bundle MỚI NHẤT (precompiled chứa 9 markers fix: nanOut/effMidiBypass/prevActiveTabRef). **Production ĐÚNG** — vấn đề: **incognito dùng index.html CACHED CŨ** (stamp cũ → URL bundle cũ). Server không gửi Cache-Control → browser cache heuristic → HTML cũ.
|
||||
- **FIX (app/main.py):** index.html (`/`) thêm `Cache-Control: no-cache, no-store, must-revalidate` — HTML luôn mới, bundle JS bust bằng ?v=.
|
||||
- **Các file ảnh hưởng:** `app/main.py`. Cần rebuild docker + restart.
|
||||
- **Ghi chú/Test:** sau khi deploy: incognito (đóng + mở lại tab — hoặc Ctrl+Shift+R 1 lần) → load trang → bundle mới. Verify: console thấy stamp mới.
|
||||
|
||||
### [2026-08-06 07:30] Task: PIANO ROLL TAB — Humanize + Transpose (có undo)
|
||||
- **Yêu cầu user:** cài đặt tính năng Humanize (midi note) + Transpose (chuyển giọng) trong piano roll tab.
|
||||
- **FIX (app.jsx PianoRollTabEditor):**
|
||||
(1) `applyHumanize()` — random velocity ±8% + start_beat ±0.015 beat (~12ms @120bpm), clamp 0.05-1.0/≥0 — pushToUndo trước khi đổi.
|
||||
(2) `applyTranspose(semi)` — dịch pitch ±s semitone, clamp 0-127 — pushToUndo trước khi đổi.
|
||||
(3) Toolbar: nút **🎚 Humanize** (sau nút Ghost) + nhóm **input semitone + nút Transpose** (trước nút Lưu). State `transposeSemis` local.
|
||||
(4) Cả 2 đều qua `pushToUndo(notes)` → Ctrl+Z/Ctrl+Shift+Z hoạt động (undo stack local của piano roll).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060730), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → bấm Humanize (nghe velocity/timing đổi) → Transpose +2 (nghe cao hơn 1 tone) → Ctrl+Z hoàn tác.
|
||||
|
||||
### [2026-08-06 08:00] Task: Humanize có cường độ (Nhẹ/Vừa/Mạnh) + Transpose theo SCALE (12 tông major/minor)
|
||||
- **Yêu cầu user:** tùy chỉnh lượng humanize (mạnh/nhẹ) + transpose theo scale major/minor đủ 12 tông.
|
||||
- **FIX (app.jsx PianoRollTabEditor):**
|
||||
(1) Humanize: `humanizeStrength` state (0.05 Nhẹ / 0.10 Vừa / 0.18 Mạnh) + select trong toolbar — velocity ±strength, timing ±strength*0.15 beat.
|
||||
(2) Transpose theo SCALE: `SCALE_PATTERNS` (major [0,2,4,5,7,9,11], minor [0,2,3,5,7,8,10]) + `SCALE_ROOTS` (12 tông) + **`detectKey()` auto-detect key nguồn** (best-fit root+scale theo pitch class) + `applyTransposeToKey()` map **degree → degree** (nốt về bậc gần nhất trong scale nguồn → shift sang bậc tương ứng scale đích, ±6 clamp octave).
|
||||
(3) Toolbar: `[🎚 Humanize] [Nhẹ|Vừa|Mạnh] [semis|Transpose] [C..B][major|minor][🎵 Chuyển giọng]` — đều qua pushToUndo (Ctrl+Z hoạt động).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060800), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → piano roll → Humanize Mạnh vs Nhẹ; Chuyển giọng C major → D minor (map degree — melody giữ hình dạng) → Ctrl+Z.
|
||||
|
||||
### [2026-08-06 08:30] Task: Toolbar piano roll 2 hàng — nhóm nút chỉnh sửa note sang hàng mới
|
||||
- **Yêu cầu user:** thêm hàng toolbar mới, di chuyển nút tính năng tương tự sang hàng mới.
|
||||
- **FIX (app.jsx):** header toolbar đổi `h-10 flex items-center justify-between` → `flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5` + **row-break spacer** (`flexBasis:100%, height:0`) trước nút Humanize.
|
||||
- **Hàng 1:** track select, Snap to Scale, Snap, ARM, MIDI Input, Instrument, AI bar range, CC mode/CC, Session/Isolated, Ghost.
|
||||
- **Hàng 2:** 🎚 Humanize + [Nhẹ|Vừa|Mạnh], ±semis Transpose, [C..B][major|minor] 🎵 Chuyển giọng, Lưu, Export, Đóng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060830), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → thấy 2 hàng toolbar; nút edit note (Humanize/Transpose/Chuyển giọng) ở hàng 2.
|
||||
|
||||
### [2026-08-06 09:30] Task: Fix emoji double-escape — nút "MIDI ghost notes" hiện text \uD83D\uDC7B
|
||||
- **Báo cáo user:** nút MIDI ghost notes hiện ký tự lạ "\uD83D\uDC7B" (text literal thay vì 👻).
|
||||
- **Nguyên nhân:** file app.jsx có `"\\uD83D\\uDC7B MIDI ghost notes"` (escape KÉP → runtime render text literal).
|
||||
- **FIX:** thay bằng emoji thật `"👻 MIDI ghost notes"`. Kiểm tra: các emoji khác (Humanize 🎚, Chuyển giọng 🎵, Session 🌐/Isolated 📋, Nhẹ/Vừa/Mạnh) đều single-escape ✓ — chỉ nút Ghost bị lỗi.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060930), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → nút hiện "👻 MIDI ghost notes".
|
||||
|
||||
### [2026-08-06 10:00] Task: Auto-detect scale khi mở midi item → hiển thị ở dropdown chuyển giọng
|
||||
- **Yêu cầu user:** mở midi item trong piano roll → detect scale + hiển thị ở dropdown scale chuyển giọng.
|
||||
- **FIX (app.jsx):** effect trong PianoRollTabEditor — key `[st.target_id]` (item id): khi mở item → `detectKey(st.notes)` → `setKeyTargetRoot/KeyTargetScale` = giọng detected → dropdown chuyển giọng hiển thị đúng giọng của item. Sửa note cùng item KHÔNG reset (target_id không đổi); mở item khác → detect lại.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061000), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở midi item → dropdown hiện giọng detected (vd D minor) → bấm Chuyển giọng sang giọng khác → Ctrl+Z.
|
||||
|
||||
### [2026-08-06 10:30] Task: Toolbar — Đóng sang cuối hàng TRÊN (phải), Lưu + Export MIDI cuối hàng DƯỚI (phải)
|
||||
- **Yêu cầu user:** move Lưu/Export (đổi tên → Export MIDI) cuối hàng bên phải; Đóng → cuối hàng trên bên phải.
|
||||
- **FIX (app.jsx):** nút Đóng chuyển từ cuối toolbar → SAU nút MIDI ghost notes (hàng 1) + `ml-auto` (đẩy phải); nhóm Lưu/Export giữ cuối hàng 2 + `ml-auto`; Export → **Export MIDI**. Emoji 🎵 Chuyển giọng bị patch tool double-escape → sửa bằng emoji thật (verify: 0 double-escape còn lại).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061030), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → hàng 1 phải có [Đóng]; hàng 2 phải: [Lưu] [Export MIDI] ở cuối bên phải.
|
||||
|
||||
Reference in New Issue
Block a user