26 Commits

Author SHA1 Message Date
3dtours f3d48e8837 feat: bổ sung phần profile của user có thể drag 2026-07-22 19:43:04 +07:00
3dtours 0155abd0da feat: bổ sung phần profile của user 2026-07-22 18:55:25 +07:00
3dtours c4302da931 feat: bổ sung phần profile của user 2026-07-22 18:50:34 +07:00
3dtours 9bf9f38864 feat: thêm tính năng lưu dự án bằng modal, Lưu dưới tên khác (Save As) và quản trị dự án/tệp tin trong Hồ sơ cá nhân 2026-07-22 18:42:14 +07:00
3dtours 6f0fac9f2d fix: sửa lỗi Ai prompt multi 2026-07-22 18:37:52 +07:00
3dtours ec178b42f3 fix: hiển thị nút tải về trên thông báo Toast cho cả Export Panel và mixdown đa kênh bất đồng bộ 2026-07-22 18:28:23 +07:00
3dtours 7136cbe904 fix: xử lý phân quyền tải file từ lệnh AI và hỗ trợ nén MP3/OGG qua API máy chủ 2026-07-22 18:23:20 +07:00
3dtours d39b3f74ff fix: di chuyển dspSelectionStats xuống dưới khai báo selLeft/selRight để tránh ReferenceError 2026-07-22 18:15:06 +07:00
3dtours a8b484bd15 fix: giải quyết các lỗi về xuất track, tải cấu hình AI, tràn dropdown, click hủy chọn và làm mới bảng DSP Tools 2026-07-22 18:08:23 +07:00
3dtours 6b7872c636 fix: đồng bộ hóa trạng thái track lựa chọn thời gian thực cho chuỗi lệnh AI 2026-07-22 17:52:00 +07:00
3dtours b9c524230d fix: tự động dịch chuyển thanh cuộn xuống dưới cùng 2026-07-22 17:49:08 +07:00
3dtours 610c384bca fix: copilot Multi-tool Calling) 2026-07-22 17:20:41 +07:00
3dtours b4ec7981a6 fix: sửa copilot panel 2026-07-22 17:12:19 +07:00
3dtours d6fe1326c5 fix: sửa copilot không gửi AI provider 2 2026-07-22 17:01:41 +07:00
3dtours 8a85dd2dfc fix: sửa copilot không gửi AI provider 2026-07-22 15:02:43 +07:00
3dtours b78193dfad fix: sửa UI của bars và lưu thông tin vào profile 2026-07-22 10:54:24 +07:00
3dtours 58089f40f9 fix: sửa UX các tools của AI 2026-07-22 10:17:56 +07:00
3dtours 6fd9db54cf fix: sửa các tools của AI 2026-07-22 09:59:43 +07:00
3dtours 108943ae81 fix: thêm và sửa các tools của AI 2026-07-22 09:51:27 +07:00
3dtours 4791bb22d9 fix: đã sửa lỗi các tools của AI 2026-07-22 09:27:12 +07:00
3dtours 022fbb3351 fix: đã sửa lỗi AI gửi prompt và thêm các tool để AI thực hiện 2026-07-22 09:23:05 +07:00
3dtours e0b849fdf2 fix: đã sửa lỗi AI gửi prompt 2026-07-22 08:52:34 +07:00
3dtours 09bb431a2b fix: đã hoàn thành md 29 và bắt đầu fix bugs 2026-07-22 07:23:36 +07:00
3dtours 90ab2c1824 fix: lỗi cài đặt AI prompt 2026-07-21 22:42:24 +07:00
3dtours 04403b0af7 fix tạm lỗi cài đặt AI prompt 2026-07-21 21:38:37 +07:00
3dtours bbc42c630e feat: cài đặt tính năng AI cho để prompt 2026-07-21 19:48:35 +07:00
17 changed files with 5975 additions and 819 deletions
+40
View File
@@ -0,0 +1,40 @@
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Any, Dict, List
router = APIRouter()
class ProxyRequest(BaseModel):
url: str
headers: Dict[str, str] = {}
body: Dict[str, Any] = {}
import json
@router.post("/proxy")
async def proxy_llm(req: ProxyRequest):
try:
async with httpx.AsyncClient(timeout=60.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
)
raw = resp.text
try:
return resp.json()
except json.JSONDecodeError:
try:
return json.loads(raw[:raw.find('\n')])
except (json.JSONDecodeError, ValueError):
return {"content": raw}
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="AI provider timeout")
except httpx.ConnectError as e:
msg = f"Cannot connect to AI provider: {e}"
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 Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+124 -8
View File
@@ -1,11 +1,16 @@
import os import os
import uuid import uuid
import asyncio import asyncio
from fastapi import APIRouter, UploadFile, File, HTTPException, Query import json
from fastapi import APIRouter, UploadFile, File, HTTPException, Query, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional, List
import json
from app.config import settings from app.config import settings
from app.api.v1.auth import get_current_user
from app.api.v1.projects import get_optional_user
from app.models.user import get_db_connection
router = APIRouter() router = APIRouter()
@@ -52,17 +57,27 @@ class PythonToolRequest(BaseModel):
wave_type: Optional[str] = "sine" wave_type: Optional[str] = "sine"
@router.post("/upload") @router.post("/upload")
async def upload_audio(file: UploadFile = File(...)): async def upload_audio(file: UploadFile = File(...), current_user: Optional[dict] = Depends(get_optional_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)[1]
if not ext: if not ext:
ext = ".wav" ext = ".wav"
file_id = f"{uuid.uuid4()}{ext}" file_id = f"user_{user_id}_{uuid.uuid4()}{ext}"
file_path = os.path.join(settings.UPLOADS_DIR, file_id) file_path = os.path.join(settings.UPLOADS_DIR, file_id)
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
content = await file.read() content = await file.read()
f.write(content) f.write(content)
# Save original filename as sidecar metadata
import json
meta_path = os.path.join(settings.UPLOADS_DIR, file_id + ".meta")
try:
with open(meta_path, "w") as mf:
json.dump({"original_name": file.filename}, mf)
except Exception:
pass
# Trigger celery task # Trigger celery task
from app.tasks.worker import analyze_audio_task from app.tasks.worker import analyze_audio_task
task = analyze_audio_task.delay(file_id) task = analyze_audio_task.delay(file_id)
@@ -231,16 +246,17 @@ async def ai_scan_audio(req: AIScanRequest):
} }
@router.post("/ai-cut") @router.post("/ai-cut")
async def ai_cut_audio(req: AICutRequest): async def ai_cut_audio(req: AICutRequest, current_user: Optional[dict] = Depends(get_optional_user)):
""" """
17_AI_SCAN.md Feature 2: Fade-Free AI Cut (Zero-Crossing Aligned Slicing). 17_AI_SCAN.md Feature 2: Fade-Free AI Cut (Zero-Crossing Aligned Slicing).
Executes raw binary sample slice at exact zero-crossing coordinates. Executes raw binary sample slice at exact zero-crossing coordinates.
""" """
user_id = current_user["user_id"] if current_user else "anonymous"
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
import numpy as np import numpy as np
output_file_id = f"ai_cut_{uuid.uuid4().hex[:8]}.wav" output_file_id = f"user_{user_id}_ai_cut_{uuid.uuid4().hex[:8]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id) out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
file_path = None file_path = None
@@ -273,11 +289,12 @@ async def ai_cut_audio(req: AICutRequest):
} }
@router.post("/python-tool") @router.post("/python-tool")
async def run_python_dsp_tool(req: PythonToolRequest): async def run_python_dsp_tool(req: PythonToolRequest, current_user: Optional[dict] = Depends(get_optional_user)):
""" """
Non-AI Python DSP Tools endpoint. Non-AI Python DSP Tools endpoint.
Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation. Handles normalize peak, invert phase, swap channels, zero-crossing align, and synth wave generation.
""" """
user_id = current_user["user_id"] if current_user else "anonymous"
from app.core.python_tools_engine import PythonToolsEngine from app.core.python_tools_engine import PythonToolsEngine
from app.core.ai_dsp_engine import AIDSPEngine from app.core.ai_dsp_engine import AIDSPEngine
import soundfile as sf import soundfile as sf
@@ -285,7 +302,7 @@ async def run_python_dsp_tool(req: PythonToolRequest):
if req.tool_type == "synth_wave": if req.tool_type == "synth_wave":
wave = PythonToolsEngine.generate_synth_wave(req.wave_type or "sine", req.freq or 440.0, req.duration or 2.0) wave = PythonToolsEngine.generate_synth_wave(req.wave_type or "sine", req.freq or 440.0, req.duration or 2.0)
output_file_id = f"synth_{req.wave_type}_{uuid.uuid4().hex[:6]}.wav" output_file_id = f"user_{user_id}_synth_{req.wave_type}_{uuid.uuid4().hex[:6]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, output_file_id) out_path = os.path.join(settings.PROCESSED_DIR, output_file_id)
sf.write(out_path, wave, 44100) sf.write(out_path, wave, 44100)
return { return {
@@ -306,3 +323,102 @@ async def run_python_dsp_tool(req: PythonToolRequest):
"success": True, "success": True,
"message": f"Python Tool '{req.tool_type}' executed successfully for track {req.track_id}" "message": f"Python Tool '{req.tool_type}' executed successfully for track {req.track_id}"
} }
class MyFilesRequest(BaseModel):
active_file_ids: List[str] = []
@router.post("/my-files")
async def list_user_files(req: MyFilesRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
prefix = f"user_{user_id}_"
# Scan all user's projects to find referenced files
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT data_json FROM projects WHERE user_id = ?", (user_id,))
rows = cursor.fetchall()
conn.close()
referenced_in_db = set()
for row in rows:
try:
proj = json.loads(row["data_json"])
for track in proj.get("tracks", []):
fid = track.get("serverFileId")
if fid:
referenced_in_db.add(fid)
except Exception:
pass
active_set = set(req.active_file_ids) | referenced_in_db
files_map = {}
def scan_dir(directory, type_label):
if not os.path.exists(directory):
return
for filename in os.listdir(directory):
if filename.startswith(prefix):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath):
stat = os.stat(filepath)
is_in_use = filename in active_set
if filename in files_map:
files_map[filename]["size_mb"] = round(files_map[filename]["size_mb"] + stat.st_size / (1024 * 1024), 2)
else:
original_name = filename
meta_path = os.path.join(directory, filename + ".meta")
if os.path.isfile(meta_path):
try:
with open(meta_path, "r") as mf:
meta = json.load(mf)
original_name = meta.get("original_name", filename)
except Exception:
pass
files_map[filename] = {
"file_id": filename,
"original_name": original_name,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"created_at": stat.st_mtime,
"type": type_label,
"is_in_use": is_in_use
}
scan_dir(settings.UPLOADS_DIR, "Upload")
scan_dir(settings.PROCESSED_DIR, "Processed")
user_files = list(files_map.values())
user_files.sort(key=lambda x: x["created_at"], reverse=True)
return user_files
@router.delete("/my-files/{file_id}")
async def delete_user_file(file_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
prefix = f"user_{user_id}_"
# Guard: only own files can be deleted
if not file_id.startswith(prefix):
raise HTTPException(status_code=403, detail="Bạn không có quyền xóa tệp này")
deleted = False
for directory in [settings.UPLOADS_DIR, settings.PROCESSED_DIR]:
filepath = os.path.join(directory, file_id)
if os.path.exists(filepath):
try:
os.remove(filepath)
deleted = True
except Exception:
pass
# Clean up sidecar metadata file
meta_path = os.path.join(directory, file_id + ".meta")
if os.path.isfile(meta_path):
try:
os.remove(meta_path)
except Exception:
pass
if not deleted:
raise HTTPException(status_code=404, detail="Không tìm thấy tệp trên server")
return {"success": True, "message": "Đã xóa tệp thành công"}
+53
View File
@@ -126,3 +126,56 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
"updated_at": r["updated_at"] "updated_at": r["updated_at"]
} for r in rows } for r in rows
] ]
@router.get("/cloud/{project_id}")
async def get_cloud_project(project_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Không tìm thấy dự án")
return {
"id": project_id,
"name": row["name"],
"data_json": row["data_json"]
}
@router.delete("/cloud/{project_id}")
async def delete_cloud_project(project_id: str, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id))
conn.commit()
conn.close()
return {"success": True, "message": "Đã xóa dự án thành công"}
@router.put("/cloud/{project_id}")
async def update_cloud_project(project_id: str, req: SaveProjectRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
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:
conn.close()
raise HTTPException(status_code=404, detail="Không tìm thấy dự án để cập nhật")
new_size_bytes = len(req.data_json.encode("utf-8"))
now = time.time()
cursor.execute("""
UPDATE projects
SET name = ?, data_json = ?, size_bytes = ?, updated_at = ?
WHERE id = ? AND user_id = ?
""", (req.name, req.data_json, new_size_bytes, now, project_id, user_id))
conn.commit()
conn.close()
return {"success": True, "message": "Đã cập nhật dự án thành công"}
+75 -53
View File
@@ -1,12 +1,55 @@
from fastapi import APIRouter, HTTPException, Depends import json, os
from fastapi import APIRouter, HTTPException, Header, Depends
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List, Dict, Any
import time import time
from app.core.auth import decode_token
from app.config import settings
router = APIRouter() router = APIRouter()
# In-memory / per-user AI provider configurations storage dictionary DATA_FILE = os.path.join(settings.PROCESSED_DIR, "user_configs.json")
USER_AI_CONFIGS = {}
def _load_all():
if not os.path.exists(DATA_FILE):
return {"ai_configs": {}, "preferences": {}}
try:
with open(DATA_FILE, "r") as f:
return json.load(f)
except: return {"ai_configs": {}, "preferences": {}}
def _save_all(ai_configs=None, preferences=None):
data = _load_all()
if ai_configs is not None: data["ai_configs"] = ai_configs
if preferences is not None: data["preferences"] = preferences
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
def _get_user_id(authorization):
if not authorization or not authorization.startswith("Bearer "):
return "anonymous"
token = authorization.split(" ")[1]
payload = decode_token(token)
if not payload:
return "anonymous"
return payload.get("user_id", "anonymous")
def _load_ai_configs():
data = _load_all()
return data.get("ai_configs", {})
def _load_preferences():
data = _load_all()
return data.get("preferences", {})
def _get_default_providers():
return [
{"id": "openai_default", "name": "OpenAI Official", "provider_type": "openai", "api_base_url": "https://api.openai.com/v1", "api_key": "", "model_name": "gpt-4o", "temperature": 0.7, "is_active": True},
{"id": "openai_compat_default", "name": "OpenAI Compatible (Ollama/LocalAI/DeepSeek)", "provider_type": "openai_compatible", "api_base_url": "http://localhost:11434/v1", "api_key": "ollama", "model_name": "deepseek-r1", "temperature": 0.7, "is_active": False},
{"id": "anthropic_default", "name": "Anthropic Claude", "provider_type": "anthropic", "api_base_url": "https://api.anthropic.com/v1", "api_key": "", "model_name": "claude-3-5-sonnet", "temperature": 0.7, "is_active": False},
{"id": "gemini_default", "name": "Google Gemini", "provider_type": "gemini", "api_base_url": "https://generativelanguage.googleapis.com", "api_key": "", "model_name": "gemini-1.5-pro", "temperature": 0.7, "is_active": False}
]
class AIProviderSetting(BaseModel): class AIProviderSetting(BaseModel):
id: str id: str
@@ -21,61 +64,40 @@ class AIProviderSetting(BaseModel):
class SaveAIConfigRequest(BaseModel): class SaveAIConfigRequest(BaseModel):
providers: List[AIProviderSetting] providers: List[AIProviderSetting]
class SavePreferencesRequest(BaseModel):
preferences: Dict[str, Any]
@router.get("/preferences")
async def get_user_preferences(authorization: Optional[str] = Header(None)):
uid = _get_user_id(authorization)
prefs = _load_preferences()
return {"success": True, "preferences": prefs.get(uid, {})}
@router.post("/preferences")
async def save_user_preferences(req: SavePreferencesRequest, authorization: Optional[str] = Header(None)):
uid = _get_user_id(authorization)
prefs = _load_preferences()
prefs[uid] = req.preferences
_save_all(preferences=prefs)
return {"success": True, "message": "Đã lưu cấu hình người dùng."}
@router.get("/config/ai") @router.get("/config/ai")
async def get_user_ai_config(): async def get_user_ai_config(authorization: Optional[str] = Header(None)):
"""Fetch user's AI provider configurations.""" uid = _get_user_id(authorization)
if "default_user" not in USER_AI_CONFIGS: configs = _load_ai_configs()
USER_AI_CONFIGS["default_user"] = [ if uid not in configs:
{ configs[uid] = _get_default_providers()
"id": "openai_default",
"name": "OpenAI Official",
"provider_type": "openai",
"api_base_url": "https://api.openai.com/v1",
"api_key": "",
"model_name": "gpt-4o",
"temperature": 0.7,
"is_active": True
},
{
"id": "openai_compat_default",
"name": "OpenAI Compatible (Ollama/LocalAI/DeepSeek)",
"provider_type": "openai_compatible",
"api_base_url": "http://localhost:11434/v1",
"api_key": "ollama",
"model_name": "deepseek-r1",
"temperature": 0.7,
"is_active": False
},
{
"id": "anthropic_default",
"name": "Anthropic Claude",
"provider_type": "anthropic",
"api_base_url": "https://api.anthropic.com/v1",
"api_key": "",
"model_name": "claude-3-5-sonnet",
"temperature": 0.7,
"is_active": False
},
{
"id": "gemini_default",
"name": "Google Gemini",
"provider_type": "gemini",
"api_base_url": "https://generativelanguage.googleapis.com",
"api_key": "",
"model_name": "gemini-1.5-pro",
"temperature": 0.7,
"is_active": False
}
]
return { return {
"success": True, "success": True,
"providers": USER_AI_CONFIGS["default_user"] "providers": configs[uid]
} }
@router.post("/config/ai") @router.post("/config/ai")
async def save_user_ai_config(req: SaveAIConfigRequest): async def save_user_ai_config(req: SaveAIConfigRequest, authorization: Optional[str] = Header(None)):
"""Save user's AI provider configurations.""" uid = _get_user_id(authorization)
USER_AI_CONFIGS["default_user"] = [p.dict() for p in req.providers] configs = _load_ai_configs()
configs[uid] = [p.dict() for p in req.providers]
_save_all(ai_configs=configs)
return { return {
"success": True, "success": True,
"message": "Đã lưu cấu hình AI Providers thành công!" "message": "Đã lưu cấu hình AI Providers thành công!"
+2
View File
@@ -11,6 +11,7 @@ from app.api.v1.auth import router as auth_router
from app.api.v1.admin import router as admin_router from app.api.v1.admin import router as admin_router
from app.api.v1.projects import router as projects_router from app.api.v1.projects import router as projects_router
from app.api.v1.user_config import router as user_config_router from app.api.v1.user_config import router as user_config_router
from app.api.v1.ai_proxy import router as ai_proxy_router
from app.core.auth import seed_admin from app.core.auth import seed_admin
# Ensure storage directories exist # Ensure storage directories exist
@@ -45,6 +46,7 @@ app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"]) app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"]) app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"]) app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"])
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
# Seed admin user on startup # Seed admin user on startup
@app.on_event("startup") @app.on_event("startup")
+2249 -396
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+157 -65
View File
@@ -3,68 +3,71 @@
const AIGateway = (function() { const AIGateway = (function() {
const DEFAULT_TOOLS = [{ const DEFAULT_TOOLS = [{
name: 'create_track', name: 'set_selection', description: 'Chọn vùng timeline', parameters: { type: 'object', properties: { start_bar: { type: 'number' }, end_bar: { type: 'number' }, start_time: { type: 'number' }, end_time: { type: 'number' }, length_bars: { type: 'number' } } }
description: 'Tạo một track mới trong dự án',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Tên track' },
type: { type: 'string', enum: ['audio', 'midi'], description: 'Loại track' }
},
required: ['name', 'type']
}
}, { }, {
name: 'add_midi_item', name: 'cut_audio', description: 'Cắt audio, snap zero-crossing, tạo track mới', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, end_bar: { type: 'number' }, length_bars: { type: 'number' }, snap_silence: { type: 'boolean' }, new_track_name: { type: 'string' } } }
description: 'Thêm một MIDI item/clip vào track',
parameters: {
type: 'object',
properties: {
track_id: { type: 'string', description: 'ID của track đích' },
start_bar: { type: 'number', description: 'Vị trí bắt đầu (tính bằng bar)' },
length_bars: { type: 'number', description: 'Độ dài item (tính bằng bar)' }
},
required: ['track_id', 'start_bar', 'length_bars']
}
}, { }, {
name: 'modify_midi_notes', name: 'create_track', description: 'Tạo track mới', parameters: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string', enum: ['audio', 'midi'] } }, required: ['name'] }
description: 'Thêm, chỉnh sửa hoặc xóa các note MIDI trong item',
parameters: {
type: 'object',
properties: {
item_id: { type: 'string', description: 'ID của MIDI item' },
notes: {
type: 'array',
description: 'Danh sách các note MIDI',
items: {
type: 'object',
properties: {
pitch: { type: 'string', description: 'VD: C4, D#3, F5' },
start_time: { type: 'number', description: 'Thời điểm bắt đầu (bar hoặc giây)' },
duration: { type: 'number', description: 'Độ dài note' },
velocity: { type: 'integer', minimum: 0, maximum: 127, description: 'Độ mạnh 0-127' }
},
required: ['pitch', 'start_time', 'duration']
}
}
},
required: ['item_id', 'notes']
}
}, { }, {
name: 'process_audio_dsp', name: 'delete_track', description: 'Xóa track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
description: 'Gửi yêu cầu chỉnh sửa âm thanh sang Python DSP Backend', }, {
parameters: { name: 'rename_track', description: 'Đổi tên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, name: { type: 'string' } }, required: ['track_id', 'name'] }
type: 'object', }, {
properties: { name: 'add_clip', description: 'Thêm clip rỗng vào track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_time: { type: 'number' }, duration_seconds: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' }, name: { type: 'string' } } }
track_id: { type: 'string', description: 'ID của track âm thanh' }, }, {
action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'], description: 'Loại xử lý DSP' }, name: 'remove_clip', description: 'Xóa clip khỏi track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, clip_id: { type: 'string' } }, required: ['clip_id'] }
params: { type: 'object', description: 'Tham số bổ sung cho hành động' } }, {
}, name: 'set_track_volume', description: 'Chỉnh âm lượng dB', parameters: { type: 'object', properties: { track_id: { type: 'string' }, volume_db: { type: 'number' } }, required: ['volume_db'] }
required: ['track_id', 'action'] }, {
} name: 'set_track_pan', description: 'Chỉnh pan trái/phải', parameters: { type: 'object', properties: { track_id: { type: 'string' }, pan: { type: 'integer' } }, required: ['pan'] }
}, {
name: 'toggle_mute', description: 'Mute/unmute track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
}, {
name: 'toggle_solo', description: 'Solo/unsolo track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
}, {
name: 'set_bpm', description: 'Thay đổi BPM', parameters: { type: 'object', properties: { bpm: { type: 'number' } }, required: ['bpm'] }
}, {
name: 'set_playhead', description: 'Di chuyển playhead', parameters: { type: 'object', properties: { time: { type: 'number' }, bar: { type: 'number' } } }
}, {
name: 'add_marker', description: 'Thêm marker', parameters: { type: 'object', properties: { track_id: { type: 'string' }, time: { type: 'number' }, label: { type: 'string' } } }
}, {
name: 'process_audio_dsp', description: 'Xử lý DSP: normalize/invert/gain/pitch', parameters: { type: 'object', properties: { track_id: { type: 'string' }, action: { type: 'string', enum: ['normalize', 'invert_phase', 'gain', 'pitch_shift'] }, params: { type: 'object' } }, required: ['track_id', 'action'] }
}, {
name: 'create_midi_item', description: 'Tạo MIDI item trên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['track_id', 'start_bar', 'length_bars'] }
}, {
name: 'modify_midi_notes', description: 'Sửa note MIDI trong item', parameters: { type: 'object', properties: { item_id: { type: 'string' }, notes: { type: 'array', items: { type: 'object', properties: { pitch: { type: 'string' }, start_time: { type: 'number' }, duration: { type: 'number' }, velocity: { type: 'integer', minimum: 0, maximum: 127 } }, required: ['pitch', 'start_time', 'duration'] } } }, required: ['item_id', 'notes'] }
}, {
name: 'select_item', description: 'Chọn clip/item theo tên', parameters: { type: 'object', properties: { track_id: { type: 'string' }, item_name: { type: 'string' }, select_all: { type: 'boolean' } } }
}, {
name: 'scan_track', description: 'Phân tích track: BPM, SR, kênh', parameters: { type: 'object', properties: { track_id: { type: 'string' }, set_tempo: { type: 'boolean' } } }
}, {
name: 'fade_in', description: 'Fade-in clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } }
}, {
name: 'export_audio', description: 'Xuất file WAV/MP3/OGG và tải về', parameters: { type: 'object', properties: { track_id: { type: 'string' }, format: { type: 'string', enum: ['wav', 'mp3', 'ogg'] }, sample_rate: { type: 'string', enum: ['22500', '44100'] }, bit_depth: { type: 'string', enum: ['8', '16', '24'] }, quality: { type: 'string', enum: ['44khz', 'lossless'] }, channels: { type: 'string', enum: ['mono', 'stereo'] }, start_time: { type: 'number' }, end_time: { type: 'number' }, start_bar: { type: 'number' }, length_bars: { type: 'number' } }, required: ['format'] }
}, {
name: 'fade_out', description: 'Fade-out clip (0.5s đến max)', parameters: { type: 'object', properties: { track_id: { type: 'string' }, duration_seconds: { type: 'number' }, clip_index: { type: 'number', description: 'Chỉ số của clip trên track (1-based, ví dụ: 1 cho clip 1, 2 cho clip 2)' }, clip_id: { type: 'string', description: 'ID của clip cụ thể' } } }
}]; }];
function parseOrigin(urlStr) {
try { const u = new URL(urlStr); return `${u.protocol}//${u.hostname}${u.port ? ':'+u.port : ''}`; } catch (_) { return null; }
}
function isLocalhost(urlStr) {
try {
const u = new URL(urlStr);
return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0' || u.hostname === '::1';
} catch (_) { return false; }
}
async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) { async function callLLM({ provider, model, apiKey, baseUrl, messages, tools, toolChoice }) {
const url = `${baseUrl.replace(/\/$/, '')}/chat/completions`; const base = baseUrl.replace(/\/$/, '');
const url = `${base}/chat/completions`;
const origin = window.location.origin;
const urlOrigin = parseOrigin(url);
const appOrigin = parseOrigin(origin);
const sameOrigin = urlOrigin === appOrigin;
const targetIsLocal = isLocalhost(url);
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}) ...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {})
@@ -73,19 +76,34 @@ const AIGateway = (function() {
const body = { const body = {
model, model,
messages, messages,
stream: false,
...(tools && tools.length > 0 ? { tools: tools.map(t => ({ type: 'function', function: t })) } : {}), ...(tools && tools.length > 0 ? { tools: tools.map(t => ({ type: 'function', function: t })) } : {}),
...(toolChoice ? { tool_choice: toolChoice } : {}) ...(toolChoice ? { tool_choice: toolChoice } : {})
}; };
const response = await fetch(url, { let response;
method: 'POST',
headers, if (sameOrigin) {
body: JSON.stringify(body) response = await fetch(url, {
}); method: 'POST',
headers,
body: JSON.stringify(body)
});
} else if (targetIsLocal && !isLocalhost(origin)) {
throw new Error(`AI provider local (${url}) không khả dụng từ domain từ xa (${origin}).\nHãy dùng provider từ xa (OpenAI, Anthropic...) hoặc dùng CORS plugin trình duyệt.`);
} else {
response = await fetch(`${origin}/api/v1/ai/proxy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, headers, body })
});
}
if (!response.ok) { if (!response.ok) {
const errText = await response.text(); const errText = await response.text();
throw new Error(`LLM API error ${response.status}: ${errText}`); let detail = errText;
try { const j = JSON.parse(errText); if (j.detail) detail = j.detail; } catch (_) {}
throw new Error(detail);
} }
return await response.json(); return await response.json();
@@ -123,13 +141,53 @@ const AIGateway = (function() {
function buildUserMessage(prompt, context) { function buildUserMessage(prompt, context) {
const contextStr = JSON.stringify(context, null, 2); const contextStr = JSON.stringify(context, null, 2);
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
return [ return [
{ role: 'system', content: 'Bạn là trợ lý AI cho DAW (SonicForge Studio). Hãy phân tích yêu cầu người dùng và phản hồi BẰNG DẠNG FUNCTION CALLS phù hợp. Luôn trả về function call khi có thể thực hiện hành động.' }, { role: 'system', content: `Bạn là trợ lý điều khiển DAW chuyên nghiệp.
Nhiệm vụ của bạn là phân tích yêu cầu của người dùng và chuyển đổi thành danh sách các function calls tương ứng.
QUAN TRỌNG:
1. Bạn đang hoạt động ở chế độ một lượt (one-shot). Hãy trả về TẤT CẢ các function calls cần thiết để thực hiện toàn bộ các bước trong yêu cầu của người dùng trong một phản hồi duy nhất. Đừng thực hiện từng bước qua nhiều lượt chat.
2. Có thể gọi nhiều function cùng một lúc (gọi song song/nối tiếp). Chúng sẽ được thực thi theo thứ tự bạn trả về.
3. Khi người dùng yêu cầu chọn và cắt/sao chép/copy một đoạn nhạc từ track cũ để tạo đoạn nhạc mới (bằng lệnh 'cut_audio'), và sau đó yêu cầu xử lý tiếp đoạn nhạc mới tạo đó (ví dụ: 'sau đó fade in đoạn đó', 'chỉnh âm lượng đoạn đó', 'xuất mp3 đoạn đó'...), thì tất cả các lệnh xử lý tiếp theo này (như 'fade_in', 'export_audio', 'set_track_volume') PHẢI để trống tham số 'track_id' (hoặc truyền null/không truyền) để hệ thống tự động áp dụng lên track mới vừa được tạo ra. KHÔNG ĐƯỢC dùng 'track_id' của track gốc ban đầu cho các lệnh xử lý phía sau.
Ví dụ: "Hãy chọn và copy từ bar 4 đến bar 12 của track 1 sau đó fade in clip đó 3s, xuất ra mp3" -> Bạn phải trả về đồng thời 3 cuộc gọi hàm theo thứ tự:
- cut_audio({"track_id": "1", "start_bar": 4, "end_bar": 12})
- fade_in({"duration_seconds": 3}) (không truyền track_id)
- export_audio({"format": "mp3"}) (không truyền track_id)
4. Bar 0 đại diện cho bar đầu tiên trên timeline.` },
{ role: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` } { role: 'user', content: `Ngữ cảnh DAW hiện tại:\n${contextStr}\n\nYêu cầu người dùng: ${prompt}` }
]; ];
} }
async function executePrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools }) { function buildAIPromptContext(dawState) {
const tracks = (dawState.tracks || []).map(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, name: t.name, startTime: t.startTime || 0, duration: t.buffer.duration }] : []);
return {
id: t.id,
name: t.name,
type: t.buffer ? 'audio' : 'empty',
hasBuffer: !!t.buffer,
muted: t.muted,
solo: t.solo,
volumeDb: t.volumeDb ?? 0,
pan: t.pan ?? 0,
clips: clips.map(c => ({ id: c.id, name: c.name, startTime: parseFloat((c.startTime || 0).toFixed(3)), duration: parseFloat((c.buffer ? c.buffer.duration : 0).toFixed(3)) }))
};
});
return {
tempo: parseInt(dawState.bpm || '120'),
timeSignature: '4/4',
selectedTrackId: dawState.selectedTrackId || null,
playheadPosition: parseFloat((dawState.currentTime || 0).toFixed(3)),
selection: (dawState.selLeft !== null && dawState.selRight !== null && dawState.selRight > dawState.selLeft) ? {
start: parseFloat(dawState.selLeft.toFixed(3)),
end: parseFloat(dawState.selRight.toFixed(3)),
length: parseFloat((dawState.selRight - dawState.selLeft).toFixed(3))
} : null,
tracks
};
}
async function executeAIPrompt({ prompt, provider, model, apiKey, baseUrl, dawContext, tools }) {
const messages = buildUserMessage(prompt, dawContext); const messages = buildUserMessage(prompt, dawContext);
const toolList = tools || DEFAULT_TOOLS; const toolList = tools || DEFAULT_TOOLS;
@@ -143,6 +201,11 @@ const AIGateway = (function() {
toolChoice: 'auto' toolChoice: 'auto'
}); });
if (completion && completion.error) {
const errMsg = completion.error.message || completion.error.code || JSON.stringify(completion.error);
throw new Error(`AI Provider error: ${errMsg}`);
}
const functionCalls = extractFunctionCalls(completion); const functionCalls = extractFunctionCalls(completion);
const textResponse = completion.choices && completion.choices[0] && completion.choices[0].message && completion.choices[0].message.content const textResponse = completion.choices && completion.choices[0] && completion.choices[0].message && completion.choices[0].message.content
? completion.choices[0].message.content ? completion.choices[0].message.content
@@ -155,13 +218,42 @@ const AIGateway = (function() {
}; };
} }
async function createMidiItem(args) {
return fetch('/api/audio_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add_midi', ...args })
}).then(r => r.json());
}
async function modifyMidiNotes(args) {
return fetch('/api/audio_editor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'modify_midi_notes', ...args })
}).then(r => r.json());
}
async function processAIDSP(args) {
return fetch('/api/ai_dsp_engine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'process_ai_dsp', ...args })
}).then(r => r.json());
}
return { return {
DEFAULT_TOOLS, DEFAULT_TOOLS,
callLLM, callLLM,
extractFunctionCalls, extractFunctionCalls,
buildUserMessage, buildUserMessage,
executePrompt buildAIPromptContext,
executeAIPrompt,
createMidiItem,
modifyMidiNotes,
processAIDSP
}; };
})(); })();
window.executeAIPrompt = AIGateway.executeAIPrompt;
window.AIGateway = AIGateway; window.AIGateway = AIGateway;
+9 -1
View File
@@ -46,12 +46,20 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }), getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }), saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }), listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
deleteCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'DELETE' }),
updateCloudProject: (projectId, name, dataJson) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'PUT', body: JSON.stringify({ name, data_json: dataJson }) }),
listMyFiles: (activeFileIds) => apiRequest('/api/v1/audio/my-files', { method: 'POST', body: JSON.stringify({ active_file_ids: activeFileIds }) }),
deleteMyFile: (fileId) => apiRequest(`/api/v1/audio/my-files/${fileId}`, { method: 'DELETE' }),
aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }), aiScan: (trackId, fileId, minLoopDuration = 2.0, maxLoopDuration = 6.0) => apiRequest('/api/v1/audio/ai-scan', { method: 'POST', body: JSON.stringify({ track_id: trackId, file_id: fileId, min_loop_duration: minLoopDuration, max_loop_duration: maxLoopDuration }) }),
aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }), aiCut: (sourceTrackId, fileId, selectionStart, selectionEnd) => apiRequest('/api/v1/audio/ai-cut', { method: 'POST', body: JSON.stringify({ source_track_id: sourceTrackId, file_id: fileId, selection_start: selectionStart, selection_end: selectionEnd }) }),
runPythonTool: (toolType, trackId, fileId, timePos = 0.0, freq = 440.0, duration = 2.0, waveType = "sine") => apiRequest('/api/v1/audio/python-tool', { method: 'POST', body: JSON.stringify({ tool_type: toolType, track_id: trackId, file_id: fileId, time_pos: timePos, freq: freq, duration: duration, wave_type: waveType }) }), runPythonTool: (toolType, trackId, fileId, timePos = 0.0, freq = 440.0, duration = 2.0, waveType = "sine") => apiRequest('/api/v1/audio/python-tool', { method: 'POST', body: JSON.stringify({ tool_type: toolType, track_id: trackId, file_id: fileId, time_pos: timePos, freq: freq, duration: duration, wave_type: waveType }) }),
getAIConfigs: () => apiRequest('/api/v1/user/config/ai', { method: 'GET' }), getAIConfigs: () => apiRequest('/api/v1/user/config/ai', { method: 'GET' }),
saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }) saveAIConfigs: (providers) => apiRequest('/api/v1/user/config/ai', { method: 'POST', body: JSON.stringify({ providers }) }),
getPreferences: () => apiRequest('/api/v1/user/preferences', { method: 'GET' }),
savePreferences: (prefs) => apiRequest('/api/v1/user/preferences', { method: 'POST', body: JSON.stringify({ preferences: prefs }) })
}; };
})(); })();
+35 -3
View File
@@ -39,7 +39,38 @@ const DAWCommandDispatcher = (function() {
if (!registry[name]) { if (!registry[name]) {
return { success: false, error: `Unknown command: ${name}` }; return { success: false, error: `Unknown command: ${name}` };
} }
return registry[name](args); const result = registry[name](args);
pushHistory({ name, args, result, timestamp: Date.now() });
return result;
}
function getHistory() { return history; }
function getHistoryIndex() { return historyIndex; }
function registerDAWCommands(api) {
register('CREATE_TRACK', (args) => api.createTrack(args));
register('DELETE_TRACK', (args) => api.deleteTrack(args));
register('ADD_CLIP', (args) => api.addClip(args));
register('REMOVE_CLIP', (args) => api.removeClip(args));
register('SET_TRACK_VOLUME', (args) => api.setTrackVolume(args));
register('SET_TRACK_PAN', (args) => api.setTrackPan(args));
register('TOGGLE_MUTE', (args) => api.toggleMute(args));
register('TOGGLE_SOLO', (args) => api.toggleSolo(args));
register('PROCESS_AUDIO_DSP', (args) => api.processAudioDsp(args));
register('RENAME_TRACK', (args) => api.renameTrack(args));
register('SCAN_TRACK', (args) => api.scanTrack(args));
register('FADE_IN', (args) => api.fadeIn(args));
register('FADE_OUT', (args) => api.fadeOut(args));
register('CUT_AUDIO', (args) => api.cutAudio(args));
register('SET_SELECTION', (args) => api.setSelection(args));
register('EXPORT_AUDIO', (args) => api.exportAudio(args));
register('SET_BPM', (args) => api.setBpm(args));
register('SET_PLAYHEAD', (args) => api.setPlayhead(args));
register('SELECT_ITEM', (args) => api.selectItem(args));
register('ADD_MARKER', (args) => api.addMarker(args));
register('CREATE_MIDI_ITEM', (args) => AIGateway.createMidiItem(args));
register('MODIFY_MIDI_NOTES', (args) => AIGateway.modifyMidiNotes(args));
register('PROCESS_AI_DSP', (args) => AIGateway.processAIDSP(args));
} }
return { return {
@@ -50,8 +81,9 @@ const DAWCommandDispatcher = (function() {
canUndo, canUndo,
canRedo, canRedo,
pushHistory, pushHistory,
get history() { return history; }, getHistory,
get historyIndex() { return historyIndex; } getHistoryIndex,
registerDAWCommands
}; };
})(); })();
Binary file not shown.
+130
View File
@@ -12,8 +12,18 @@
<script src="/static/js/services/api.js"></script> <script src="/static/js/services/api.js"></script>
<script src="/static/js/services/audioEngine.js"></script> <script src="/static/js/services/audioEngine.js"></script>
<script src="/static/js/services/storage.js"></script> <script src="/static/js/services/storage.js"></script>
<script src="/static/js/services/aiGateway.js"></script>
<script src="/static/js/services/dawCommandDispatcher.js"></script>
<script src="/static/js/app.precompiled.js" defer></script> <script src="/static/js/app.precompiled.js" defer></script>
<style> <style>
:root {
--right-sidebar-width: 320px;
--bottom-strip-height: 220px;
--top-bar-height: 80px;
--status-bar-height: 25px;
--panel-border-color: #2a2a2a;
}
body { body {
background-color: #1a1a1a; background-color: #1a1a1a;
color: #c0c0c0; color: #c0c0c0;
@@ -42,6 +52,126 @@
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */ display: none; /* Safari and Chrome */
} }
/* Fullscreen Fixed App Shell */
.daw-app-shell {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #121212;
color: #e0e0e0;
}
/* Middle Section holding Main Workspace and Right Sidebar */
.daw-body-container {
display: flex;
flex: 1;
height: calc(100vh - var(--top-bar-height) - var(--bottom-strip-height) - var(--status-bar-height));
position: relative;
overflow: hidden;
}
/* Auto-expanding Main Workspace */
.daw-main-workspace {
flex: 1;
display: flex;
overflow: hidden;
}
/* Right Sidebar with Width controlled via CSS Variable */
.daw-right-sidebar {
width: var(--right-sidebar-width);
min-width: 200px;
max-width: 600px;
display: flex;
flex-direction: column;
background-color: #1a1a1a;
border-left: 1px solid var(--panel-border-color);
}
/* Vertically stacked child Panels in Right Sidebar */
.sidebar-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
border-bottom: 1px solid var(--panel-border-color);
}
#panel-media-explorer {
height: 50%; /* Default 50/50 split */
min-height: 100px;
}
#panel-ai {
flex: 1; /* Fills remaining height */
min-height: 100px;
}
/* BOTTOM ROW: Enables Horizontal Scrolling */
.daw-bottom-strip {
height: var(--bottom-strip-height);
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 8px;
overflow-x: auto; /* Enables horizontal scroll when panels overflow */
overflow-y: hidden;
background-color: #161616;
border-top: 1px solid var(--panel-border-color);
white-space: nowrap;
}
/* Optimized custom horizontal scrollbar for DAW styling */
.daw-bottom-strip::-webkit-scrollbar {
height: 8px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 4px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb:hover {
background: #00ffcc;
}
/* Sub-panels inside the bottom strip */
.bottom-panel {
flex: 0 0 auto; /* Prevents shrinking, locks content dimensions */
width: 320px;
height: 100%;
background-color: #222;
border: 1px solid #333;
border-radius: 6px;
box-sizing: border-box;
}
/* RESIZER HANDLES */
.resizer-col-handle {
width: 5px;
cursor: ew-resize; /* Horizontal resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-col-handle:hover,
.resizer-col-handle:active {
background: #00ffcc;
}
.resizer-row-handle {
height: 5px;
cursor: ns-resize; /* Vertical resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-row-handle:hover,
.resizer-row-handle:active {
background: #00ffcc;
}
</style> </style>
</head> </head>
<body class="h-screen w-screen flex flex-col"> <body class="h-screen w-screen flex flex-col">
+63
View File
@@ -0,0 +1,63 @@
# Implementation Plan: Project Management, Save As, and File Management inside Profile
We will add robust cloud/local project management, a custom "Save Project" name modal, a "Save As..." dialog offering server/local options, and a comprehensive file and project manager inside the User Profile Modal.
## User Review Required
> [!IMPORTANT]
> The profile modal will now contain three tabs: Account, Cloud Projects, and My Uploaded Files. Unused files (those not in the current session tracks or any saved projects) can be deleted by the user to free up quota storage.
>
> **Save As...** will trigger a modal allowing the user to type a new name and save it to either the server or export locally as a `.sfs` file.
---
## Proposed Changes
### Backend APIs
#### [MODIFY] [projects.py](file:///home/locpham/SonicForgeStudio/app/api/v1/projects.py)
- **`GET /cloud/{project_id}`**: Retrieves a specific user cloud project.
- **`DELETE /cloud/{project_id}`**: Deletes a specific user cloud project.
- **`PUT /cloud/{project_id}`**: Updates/overwrites an existing user cloud project.
#### [MODIFY] [audio.py](file:///home/locpham/SonicForgeStudio/app/api/v1/audio.py)
- **`POST /upload`**, **`run_python_dsp_tool`** (for synth), and **`ai_cut_audio`**: Prefix file IDs with `user_{user_id}_` to establish file ownership and quota tracking securely.
- **`POST /my-files`**: Lists all files starting with `user_{user_id}_` on the server disk. Identifies if they are referenced in the active project session or any database project records to compute their `is_in_use` status.
- **`DELETE /my-files/{file_id}`**: Deletes a user's uploaded/generated file from the server uploads and processed directories after verifying ownership.
---
### Frontend Services & UI
#### [MODIFY] [api.js](file:///home/locpham/SonicForgeStudio/app/static/js/services/api.js)
- Expose APIs for fetching, deleting, and updating cloud projects.
- Expose APIs for listing and deleting user audio files.
#### [MODIFY] [app.jsx](file:///home/locpham/SonicForgeStudio/app/static/js/app.jsx)
- **State Additions**:
- `currentProjectId`: Tracks the ID of the loaded cloud project (synced with localStorage).
- `saveProjectModalOpen`, `saveAsModalOpen`: Controls the new custom modals.
- **Save Project Modal**:
- Modal with an input for project name, used when saving a project that doesn't have a name yet.
- **Save As Modal**:
- Allows choosing to save under a new name either locally (.sfs file) or on the server.
- **Profile Modal Extensions**:
- Add Tabs: **Account Settings**, **Cloud Projects**, **My Uploaded Files**.
- **Cloud Projects Tab**: Displays saved projects with load (open DAW project) and delete options.
- **My Uploaded Files Tab**: Displays files with sizes, creation dates, usage badges, individual delete buttons, and a global "Clean Up Unused Files" button.
---
## Verification Plan
### Automated Tests
- Run backend lint and sanity checks.
```bash
python -m flake8 app/api/v1/projects.py app/api/v1/audio.py
```
### Manual Verification
1. Create a new project, press Save, verify the custom input modal appears.
2. Upload some files, check the Profile -> My Uploaded Files tab. Verify the files are listed as "In Use".
3. Remove a track containing a file, verify the file changes to "Not In Use". Press delete to free up quota.
4. Click File -> Save As... and select either Cloud or Local .sfs and verify name updates and downloads.
+333
View File
@@ -0,0 +1,333 @@
# DAW UI LAYOUT & PANEL SYSTEM ARCHITECTURE
This document details the interface layout solution (UI Layout Architecture), HTML/CSS structure, and interaction algorithms (Resizing, Scrolling) for a Hybrid DAW system, supporting responsive flexible scaling across Panels and the bottom dock strip.
---
## 1. Overall Layout Diagram (Grid Structure)
The application interface is structured around 3 main axes following an App Shell model (`Viewport Locked 100vh`):
```text
┌──────────────────────────────────────────────────────────────────────────────────┐
│ Top Navigation & Transport Toolbar (Fixed Top Bar) │
├───────────────────────────────────────────────────────────┬──────────────────────┤
│ │ RIGHT COLUMN │
│ MAIN WORKSPACE │ (RIGHT SIDEBAR) │
│ ┌───────────────────────┬───────────────────────────────┐ │ ┌──────────────────┐ │
│ │ Track Control Panels │ Timeline / Audio Viewport │ │ │ Media Explorer │ │
│ │ (Track List) │ (Beat Grid & Waveforms) │ │ │ (Dynamic Height) │ │
│ │ │ │ │ ├──────────────────┤ │
│ │ │ │ │ │ AI Panel │ │
│ │ │ │ │ │ (Dynamic Height) │ │
│ └───────────────────────┴───────────────────────────────┘ │ └──────────────────┘ │
├───────────────────────────────────────────────────────────┴──────────────────────┤
│ BOTTOM DOCK PANEL STRIP - Horizontal Scroll (Overflow-X Auto) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Export Panel │ │ DSP Tools │ │ Panel 03 │ │ Panel 04... │ ──────► │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
├──────────────────────────────────────────────────────────────────────────────────┤
│ Status Bar (Fixed Bottom Status) │
└──────────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Layout Region Details
### A. Main Workspace (Center Region)
* **Function:** Contains the track list (Track Controls), timeline ruler (Timeline Ruler), and audio/MIDI display areas (Audio Waveform & Piano Roll Clip Grid).
* **Behavior:** Auto-expands (`flex-grow: 1`) to fill the remaining screen space after subtracting the width of the Right Sidebar and the height of the Bottom Panel.
### B. Bottom Panel Dock Strip (Bottom Row)
* **Technical Specifications:**
* **Flexible Horizontal Scroll:** The container has a fixed height (e.g., `220px`), using `overflow-x: auto` and `display: flex`.
* **Sub-Panels:** Houses a list of independent Card/Tile tools (Export Panel, Python DSP Tools Panel, Selection Panel, FX Panel, etc.).
* **No Shrinking (`flex-shrink: 0`):** Each Sub-Panel is configured with `flex-shrink: 0` and a minimum width (`min-width: 280px - 350px`). When the combined width of all panels exceeds the screen width, a horizontal scrollbar appears automatically.
### C. Right Resizable Sidebar (Multi-Panel Right Column)
* **Technical Specifications:**
* **Width Resizing:** The entire right column can be resized by dragging its left border (Border Left Drag Handle) to expand or collapse the visible space of the Main Workspace.
* **Vertical Stacking:** Houses stacked child panels (e.g., Media Explorer, AI Panel, Inspector, etc.).
* **Independent Height Resizing:** Horizontal splitters (Horizontal Splitter / Resizer Handle) sit between stacked child panels, allowing users to drag up/down to adjust height ratios between panels.
---
## 3. HTML & CSS Framework Implementation
### HTML Core Structure
```html
<div class="daw-app-shell">
<!-- Top Toolbar -->
<header class="daw-top-bar">...</header>
<!-- Body Middle Container -->
<div class="daw-body-container">
<!-- Main Center Viewport -->
<main class="daw-main-workspace">
<div class="track-headers-column">...</div>
<div class="timeline-canvas-viewport">...</div>
</main>
<!-- Vertical Resizer Handle (Adjusts Right Sidebar Width) -->
<div class="resizer-col-handle" id="col-resizer"></div>
<!-- Right Sidebar Container -->
<aside class="daw-right-sidebar" id="right-sidebar">
<!-- Panel 1: Media Explorer -->
<div class="sidebar-panel" id="panel-media-explorer">
<div class="panel-header">Media Explorer</div>
<div class="panel-content">...</div>
</div>
<!-- Horizontal Resizer Handle (Adjusts Panel Heights inside the Column) -->
<div class="resizer-row-handle" id="row-resizer-1"></div>
<!-- Panel 2: AI Panel -->
<div class="sidebar-panel" id="panel-ai">
<div class="panel-header">AI Panel</div>
<div class="panel-content">...</div>
</div>
</aside>
</div>
<!-- Bottom Panel Strip (Horizontal Scroll Container) -->
<footer class="daw-bottom-strip">
<div class="bottom-panel">Export Panel</div>
<div class="bottom-panel">Python DSP Tools Panel</div>
<div class="bottom-panel">Selection Panel</div>
<div class="bottom-panel">Plugin FX Rack Panel</div>
<div class="bottom-panel">MIDI Event List Panel</div>
</footer>
<!-- Status Bar -->
<div class="daw-status-bar">...</div>
</div>
```
### CSS System Architecture
```css
:root {
--right-sidebar-width: 320px;
--bottom-strip-height: 220px;
--top-bar-height: 80px;
--status-bar-height: 25px;
--panel-border-color: #2a2a2a;
}
/* Fullscreen Fixed App Shell */
.daw-app-shell {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
overflow: hidden;
background-color: #121212;
color: #e0e0e0;
}
/* Middle Section holding Main Workspace and Right Sidebar */
.daw-body-container {
display: flex;
flex: 1;
height: calc(100vh - var(--top-bar-height) - var(--bottom-strip-height) - var(--status-bar-height));
position: relative;
overflow: hidden;
}
/* Auto-expanding Main Workspace */
.daw-main-workspace {
flex: 1;
display: flex;
overflow: hidden;
}
/* Right Sidebar with Width controlled via CSS Variable */
.daw-right-sidebar {
width: var(--right-sidebar-width);
min-width: 200px;
max-width: 600px;
display: flex;
flex-direction: column;
background-color: #1a1a1a;
border-left: 1px solid var(--panel-border-color);
}
/* Vertically stacked child Panels in Right Sidebar */
.sidebar-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: #1e1e1e;
border-bottom: 1px solid var(--panel-border-color);
}
#panel-media-explorer {
height: 50%; /* Default 50/50 split */
min-height: 100px;
}
#panel-ai {
flex: 1; /* Fills remaining height */
min-height: 100px;
}
/* BOTTOM ROW: Enables Horizontal Scrolling */
.daw-bottom-strip {
height: var(--bottom-strip-height);
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 8px;
overflow-x: auto; /* Enables horizontal scroll when panels overflow */
overflow-y: hidden;
background-color: #161616;
border-top: 1px solid var(--panel-border-color);
white-space: nowrap;
}
/* Optimized custom horizontal scrollbar for DAW styling */
.daw-bottom-strip::-webkit-scrollbar {
height: 8px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 4px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb:hover {
background: #00ffcc;
}
/* Sub-panels inside the bottom strip */
.bottom-panel {
flex: 0 0 auto; /* Prevents shrinking, locks content dimensions */
width: 320px;
height: 100%;
background-color: #222;
border: 1px solid #333;
border-radius: 6px;
box-sizing: border-box;
}
/* RESIZER HANDLES */
.resizer-col-handle {
width: 5px;
cursor: ew-resize; /* Horizontal resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-col-handle:hover,
.resizer-col-handle:active {
background: #00ffcc;
}
.resizer-row-handle {
height: 5px;
cursor: ns-resize; /* Vertical resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-row-handle:hover,
.resizer-row-handle:active {
background: #00ffcc;
}
```
---
## 4. Interaction Algorithms (JS Resizing Logic)
To handle smooth resizing without stuttering or dropped events when dragging over `iframe` or `canvas` elements, the algorithms rely on `pointerdown`, `pointermove`, and `pointerup` events.
### A. Right Sidebar Width Resizing Algorithm (Horizontal Resizer)
```javascript
const colResizer = document.getElementById('col-resizer');
const rightSidebar = document.getElementById('right-sidebar');
colResizer.addEventListener('pointerdown', (e) => {
e.preventDefault();
colResizer.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startWidth = rightSidebar.getBoundingClientRect().width;
const onPointerMove = (moveEvent) => {
// Delta calculation: dragging left increases width, dragging right decreases width
const deltaX = startX - moveEvent.clientX;
const newWidth = Math.max(200, Math.min(600, startWidth + deltaX));
document.documentElement.style.setProperty('--right-sidebar-width', `${newWidth}px`);
};
const onPointerUp = (upEvent) => {
colResizer.releasePointerCapture(upEvent.pointerId);
colResizer.removeEventListener('pointermove', onPointerMove);
colResizer.removeEventListener('pointerup', onPointerUp);
};
colResizer.addEventListener('pointermove', onPointerMove);
colResizer.addEventListener('pointerup', onPointerUp);
});
```
### B. Right Sidebar Panel Height Resizing Algorithm (Vertical Resizer)
```javascript
const rowResizer = document.getElementById('row-resizer-1');
const topPanel = document.getElementById('panel-media-explorer');
rowResizer.addEventListener('pointerdown', (e) => {
e.preventDefault();
rowResizer.setPointerCapture(e.pointerId);
const startY = e.clientY;
const startHeight = topPanel.getBoundingClientRect().height;
const onPointerMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY;
const newHeight = Math.max(100, startHeight + deltaY);
topPanel.style.height = `${newHeight}px`;
topPanel.style.flex = 'none'; // Switch from flex ratio to fixed px during drag
};
const onPointerUp = (upEvent) => {
rowResizer.releasePointerCapture(upEvent.pointerId);
rowResizer.removeEventListener('pointermove', onPointerMove);
rowResizer.removeEventListener('pointerup', onPointerUp);
};
colResizer.addEventListener('pointermove', onPointerMove);
colResizer.addEventListener('pointerup', onPointerUp);
});
```
---
## 5. Summary of Solution Advantages
* **Native Horizontal Scrolling:** The bottom Dock area flexibly accommodates an unlimited number of Panels. Users can scroll horizontally (`Shift + Mouse Wheel`) or use a trackpad to browse panels easily.
* **Smooth & Accurate Resizing:** Utilizing Pointer Capture ensures drag interactions do not drop or break even when the cursor moves rapidly beyond the Resizer handle's bounds.
* **Standardized CSS Variables:** Enables easy persistence of layout states (`Width`/`Height`) to the browser's `localStorage`, restoring the user's custom layout configuration on app reload.