Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3d48e8837 | |||
| 0155abd0da | |||
| c4302da931 | |||
| 9bf9f38864 | |||
| 6f0fac9f2d | |||
| ec178b42f3 | |||
| 7136cbe904 | |||
| d39b3f74ff | |||
| a8b484bd15 | |||
| 6b7872c636 | |||
| b9c524230d | |||
| 610c384bca | |||
| b4ec7981a6 | |||
| d6fe1326c5 | |||
| 8a85dd2dfc | |||
| b78193dfad | |||
| 58089f40f9 | |||
| 6fd9db54cf | |||
| 108943ae81 | |||
| 4791bb22d9 | |||
| 022fbb3351 | |||
| e0b849fdf2 | |||
| 09bb431a2b | |||
| 90ab2c1824 | |||
| 04403b0af7 | |||
| bbc42c630e |
@@ -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))
|
||||
+125
-9
@@ -1,11 +1,16 @@
|
||||
import os
|
||||
import uuid
|
||||
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 pydantic import BaseModel
|
||||
from typing import Optional
|
||||
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.projects import get_optional_user
|
||||
from app.models.user import get_db_connection
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -52,17 +57,27 @@ class PythonToolRequest(BaseModel):
|
||||
wave_type: Optional[str] = "sine"
|
||||
|
||||
@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]
|
||||
if not ext:
|
||||
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)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
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
|
||||
from app.tasks.worker import analyze_audio_task
|
||||
task = analyze_audio_task.delay(file_id)
|
||||
@@ -231,16 +246,17 @@ async def ai_scan_audio(req: AIScanRequest):
|
||||
}
|
||||
|
||||
@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).
|
||||
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
|
||||
import soundfile as sf
|
||||
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)
|
||||
|
||||
file_path = None
|
||||
@@ -273,11 +289,12 @@ async def ai_cut_audio(req: AICutRequest):
|
||||
}
|
||||
|
||||
@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.
|
||||
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.ai_dsp_engine import AIDSPEngine
|
||||
import soundfile as sf
|
||||
@@ -285,7 +302,7 @@ async def run_python_dsp_tool(req: PythonToolRequest):
|
||||
|
||||
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)
|
||||
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)
|
||||
sf.write(out_path, wave, 44100)
|
||||
return {
|
||||
@@ -306,3 +323,102 @@ async def run_python_dsp_tool(req: PythonToolRequest):
|
||||
"success": True,
|
||||
"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"}
|
||||
|
||||
@@ -126,3 +126,56 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
|
||||
"updated_at": r["updated_at"]
|
||||
} 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
@@ -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 typing import Optional, List
|
||||
from typing import Optional, List, Dict, Any
|
||||
import time
|
||||
from app.core.auth import decode_token
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# In-memory / per-user AI provider configurations storage dictionary
|
||||
USER_AI_CONFIGS = {}
|
||||
DATA_FILE = os.path.join(settings.PROCESSED_DIR, "user_configs.json")
|
||||
|
||||
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):
|
||||
id: str
|
||||
@@ -21,61 +64,40 @@ class AIProviderSetting(BaseModel):
|
||||
class SaveAIConfigRequest(BaseModel):
|
||||
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")
|
||||
async def get_user_ai_config():
|
||||
"""Fetch user's AI provider configurations."""
|
||||
if "default_user" not in USER_AI_CONFIGS:
|
||||
USER_AI_CONFIGS["default_user"] = [
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
async def get_user_ai_config(authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
configs = _load_ai_configs()
|
||||
if uid not in configs:
|
||||
configs[uid] = _get_default_providers()
|
||||
return {
|
||||
"success": True,
|
||||
"providers": USER_AI_CONFIGS["default_user"]
|
||||
"providers": configs[uid]
|
||||
}
|
||||
|
||||
@router.post("/config/ai")
|
||||
async def save_user_ai_config(req: SaveAIConfigRequest):
|
||||
"""Save user's AI provider configurations."""
|
||||
USER_AI_CONFIGS["default_user"] = [p.dict() for p in req.providers]
|
||||
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]
|
||||
_save_all(ai_configs=configs)
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Đã lưu cấu hình AI Providers thành công!"
|
||||
|
||||
@@ -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.projects import router as projects_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
|
||||
|
||||
# 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(projects_router, prefix="/api/v1/projects", tags=["projects"])
|
||||
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
|
||||
@app.on_event("startup")
|
||||
|
||||
+2249
-396
File diff suppressed because it is too large
Load Diff
+2704
-292
File diff suppressed because it is too large
Load Diff
@@ -3,68 +3,71 @@
|
||||
|
||||
const AIGateway = (function() {
|
||||
const DEFAULT_TOOLS = [{
|
||||
name: 'create_track',
|
||||
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: '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' } } }
|
||||
}, {
|
||||
name: 'add_midi_item',
|
||||
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: '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' } } }
|
||||
}, {
|
||||
name: 'modify_midi_notes',
|
||||
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: 'create_track', description: 'Tạo track mới', parameters: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string', enum: ['audio', 'midi'] } }, required: ['name'] }
|
||||
}, {
|
||||
name: 'process_audio_dsp',
|
||||
description: 'Gửi yêu cầu chỉnh sửa âm thanh sang Python DSP Backend',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
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' },
|
||||
params: { type: 'object', description: 'Tham số bổ sung cho hành động' }
|
||||
},
|
||||
required: ['track_id', 'action']
|
||||
}
|
||||
name: 'delete_track', description: 'Xóa track', parameters: { type: 'object', properties: { track_id: { type: 'string' } } }
|
||||
}, {
|
||||
name: 'rename_track', description: 'Đổi tên track', parameters: { type: 'object', properties: { track_id: { type: 'string' }, name: { type: 'string' } }, required: ['track_id', 'name'] }
|
||||
}, {
|
||||
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' } } }
|
||||
}, {
|
||||
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'] }
|
||||
}, {
|
||||
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'] }
|
||||
}, {
|
||||
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 }) {
|
||||
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 = {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {})
|
||||
@@ -73,19 +76,34 @@ const AIGateway = (function() {
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
...(tools && tools.length > 0 ? { tools: tools.map(t => ({ type: 'function', function: t })) } : {}),
|
||||
...(toolChoice ? { tool_choice: toolChoice } : {})
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
let response;
|
||||
|
||||
if (sameOrigin) {
|
||||
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) {
|
||||
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();
|
||||
@@ -123,13 +141,53 @@ const AIGateway = (function() {
|
||||
|
||||
function buildUserMessage(prompt, context) {
|
||||
const contextStr = JSON.stringify(context, null, 2);
|
||||
const toolNames = DEFAULT_TOOLS.map(t => ` - ${t.name}: ${t.description}`).join('\n');
|
||||
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}` }
|
||||
];
|
||||
}
|
||||
|
||||
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 toolList = tools || DEFAULT_TOOLS;
|
||||
|
||||
@@ -143,6 +201,11 @@ const AIGateway = (function() {
|
||||
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 textResponse = completion.choices && completion.choices[0] && completion.choices[0].message && 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 {
|
||||
DEFAULT_TOOLS,
|
||||
callLLM,
|
||||
extractFunctionCalls,
|
||||
buildUserMessage,
|
||||
executePrompt
|
||||
buildAIPromptContext,
|
||||
executeAIPrompt,
|
||||
createMidiItem,
|
||||
modifyMidiNotes,
|
||||
processAIDSP
|
||||
};
|
||||
})();
|
||||
|
||||
window.executeAIPrompt = AIGateway.executeAIPrompt;
|
||||
window.AIGateway = AIGateway;
|
||||
|
||||
@@ -46,12 +46,20 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
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 }) }),
|
||||
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 }) }),
|
||||
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 }) }),
|
||||
|
||||
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 }) })
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -39,7 +39,38 @@ const DAWCommandDispatcher = (function() {
|
||||
if (!registry[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 {
|
||||
@@ -50,8 +81,9 @@ const DAWCommandDispatcher = (function() {
|
||||
canUndo,
|
||||
canRedo,
|
||||
pushHistory,
|
||||
get history() { return history; },
|
||||
get historyIndex() { return historyIndex; }
|
||||
getHistory,
|
||||
getHistoryIndex,
|
||||
registerDAWCommands
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Binary file not shown.
@@ -12,8 +12,18 @@
|
||||
<script src="/static/js/services/api.js"></script>
|
||||
<script src="/static/js/services/audioEngine.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>
|
||||
<style>
|
||||
:root {
|
||||
--right-sidebar-width: 320px;
|
||||
--bottom-strip-height: 220px;
|
||||
--top-bar-height: 80px;
|
||||
--status-bar-height: 25px;
|
||||
--panel-border-color: #2a2a2a;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #c0c0c0;
|
||||
@@ -42,6 +52,126 @@
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
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>
|
||||
</head>
|
||||
<body class="h-screen w-screen flex flex-col">
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user