IMPROVE: cho phép lưu dự án và phục hồi dự án vừa tắt

This commit is contained in:
2026-07-30 20:47:44 +07:00
parent 313d1be5af
commit 58a294d845
7 changed files with 1071 additions and 18 deletions
+30 -1
View File
@@ -20,6 +20,12 @@ class UpdateUserRoleRequest(BaseModel):
role: str # 'admin', 'standard', 'premium' role: str # 'admin', 'standard', 'premium'
is_active: Optional[bool] = True is_active: Optional[bool] = True
class CreateUserRequest(BaseModel):
username: str
email: str
password: str
role: Optional[str] = 'standard'
@router.get("/users") @router.get("/users")
async def list_users(admin: dict = Depends(require_admin)): async def list_users(admin: dict = Depends(require_admin)):
conn = get_db_connection() conn = get_db_connection()
@@ -52,6 +58,29 @@ async def list_users(admin: dict = Depends(require_admin)):
}) })
return users return users
@router.post("/users")
async def create_user(req: CreateUserRequest, admin: dict = Depends(require_admin)):
import uuid, time
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ? OR email = ?", (req.username, req.email))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=409, detail="Tên đăng nhập hoặc email đã tồn tại")
user_id = str(uuid.uuid4())
hashed = hash_password(req.password)
now = time.time()
cursor.execute("""
INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active)
VALUES (?, ?, ?, ?, ?, 1, ?, 1)
""", (user_id, req.username, req.email, hashed, req.role, now))
cursor.execute("""
INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks) VALUES (?, 500, 16)
""", (user_id,))
conn.commit()
conn.close()
return {"message": f"Đã tạo người dùng '{req.username}' thành công", "user_id": user_id}
@router.put("/users/{user_id}/role") @router.put("/users/{user_id}/role")
async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)): async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)):
conn = get_db_connection() conn = get_db_connection()
@@ -81,7 +110,7 @@ async def delete_user(user_id: str, admin: dict = Depends(require_admin)):
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) cursor.execute("DELETE FROM users WHERE id = ?", (user_id,))
cursor.execute("DELETE FROM user_quotas WHERE id = ?", (user_id,)) cursor.execute("DELETE FROM user_quotas WHERE user_id = ?", (user_id,))
cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,)) cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,))
conn.commit() conn.commit()
conn.close() conn.close()
+118 -6
View File
@@ -2,9 +2,9 @@ import time
import json import json
import uuid import uuid
import os import os
from fastapi import APIRouter, HTTPException, Depends, Header from fastapi import APIRouter, HTTPException, Depends, Header, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any, Dict from typing import Optional, Any, Dict, List
from jsonschema import validate, ValidationError from jsonschema import validate, ValidationError
from app.models.user import get_db_connection from app.models.user import get_db_connection
from app.api.v1.auth import get_current_user, decode_token from app.api.v1.auth import get_current_user, decode_token
@@ -228,9 +228,11 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, name, size_bytes, updated_at FROM projects SELECT p.id, p.name, p.size_bytes, p.updated_at,
WHERE user_id = ? AND is_temp = 0 (SELECT COUNT(*) FROM project_backups pb WHERE pb.project_id = p.id AND pb.user_id = p.user_id) as backup_count
ORDER BY updated_at DESC FROM projects p
WHERE p.user_id = ? AND p.is_temp = 0
ORDER BY p.updated_at DESC
""", (user_id,)) """, (user_id,))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close() conn.close()
@@ -240,7 +242,8 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)):
"id": r["id"], "id": r["id"],
"name": r["name"], "name": r["name"],
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2), "size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
"updated_at": r["updated_at"] "updated_at": r["updated_at"],
"backup_count": r["backup_count"]
} for r in rows } for r in rows
] ]
@@ -298,6 +301,115 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current
conn.close() conn.close()
return {"success": True, "message": "Đã cập nhật dự án thành công"} return {"success": True, "message": "Đã cập nhật dự án thành công"}
class BackupConfigRequest(BaseModel):
max_count: int = 10
@router.post("/cloud/{project_id}/backup")
async def create_project_backup(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()
if not row:
conn.close()
raise HTTPException(status_code=404, detail="Không tìm thấy dự án")
backup_id = f"backup_{uuid.uuid4().hex[:12]}"
now = time.time()
size_bytes = len(row["data_json"].encode("utf-8"))
backup_name = f"[Backup] {row['name']} ({time.strftime('%Y-%m-%d %H:%M', time.localtime(now))})"
cursor.execute("""
INSERT INTO project_backups (id, user_id, project_id, name, data_json, size_bytes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (backup_id, user_id, project_id, backup_name, row["data_json"], size_bytes, now))
# Enforce retention limit: lấy max_count từ query param mặc định 10
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (project_id, user_id))
count = cursor.fetchone()["cnt"]
max_backup = 10 # default
if count > max_backup:
excess = count - max_backup
cursor.execute("""
DELETE FROM project_backups WHERE id IN (
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
ORDER BY created_at ASC LIMIT ?
)
""", (project_id, user_id, excess))
conn.commit()
conn.close()
return {
"backup_id": backup_id,
"name": backup_name,
"created_at": now,
"max_backups": max_backup
}
@router.get("/cloud/{project_id}/backups")
async def list_project_backups(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 id, name, size_bytes, created_at FROM project_backups
WHERE project_id = ? AND user_id = ?
ORDER BY created_at DESC
""", (project_id, user_id))
rows = cursor.fetchall()
conn.close()
return [
{
"id": r["id"],
"name": r["name"],
"size_mb": round(r["size_bytes"] / (1024 * 1024), 2),
"created_at": r["created_at"]
} for r in rows
]
@router.delete("/cloud/backups/{backup_id}")
async def delete_project_backup(backup_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 project_backups WHERE id = ? AND user_id = ?", (backup_id, user_id))
conn.commit()
conn.close()
return {"success": True, "message": "Đã xóa bản backup"}
class CleanupBackupsRequest(BaseModel):
keep: int = 10
@router.post("/cloud/backups/cleanup")
async def cleanup_all_backups(req: CleanupBackupsRequest, current_user: dict = Depends(get_current_user)):
user_id = current_user["user_id"]
keep = max(5, min(20, req.keep))
conn = get_db_connection()
cursor = conn.cursor()
# Lấy tất cả project_id của user
cursor.execute("SELECT DISTINCT project_id FROM project_backups WHERE user_id = ?", (user_id,))
projects = cursor.fetchall()
total_deleted = 0
for p in projects:
pid = p["project_id"]
cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (pid, user_id))
cnt = cursor.fetchone()["cnt"]
if cnt > keep:
excess = cnt - keep
cursor.execute("""
DELETE FROM project_backups WHERE id IN (
SELECT id FROM project_backups WHERE project_id = ? AND user_id = ?
ORDER BY created_at ASC LIMIT ?
)
""", (pid, user_id, excess))
total_deleted += excess
conn.commit()
conn.close()
return {"success": True, "deleted": total_deleted, "keep": keep}
class RenderProjectRequest(BaseModel): class RenderProjectRequest(BaseModel):
sample_rate: Optional[int] = 44100 sample_rate: Optional[int] = 44100
+24 -9
View File
@@ -5,25 +5,40 @@ import time
import base64 import base64
import uuid import uuid
import os import os
import secrets
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from app.models.user import get_db_connection from app.models.user import get_db_connection
from app.config import settings from app.config import settings
SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026") SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026")
def hash_password(password: str) -> str: def hash_password(password: str, salt: Optional[str] = None) -> str:
""" """
Hash password using PBKDF2 HMAC SHA-256 with salt. Hash password using PBKDF2 HMAC SHA-256 with a per-user random salt.
Guarantees raw passwords are NEVER stored or exposed in plaintext. Guarantees raw passwords are NEVER stored or exposed in plaintext.
Returns format: 'salt_hex:hashed_key_hex' (colon-delimited, stores both values).
If salt is provided, uses that salt (for verification).
If salt is None, generates a new 32-byte random salt.
""" """
salt = b"sonicforge_crypto_salt_2026_secure_" if salt is None:
key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) salt = secrets.token_hex(32)
return key.hex() key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 600000)
return f"{salt}:{key.hex()}"
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, stored_value: str) -> bool:
"""Verify plain password against PBKDF2 hashed password using constant-time comparison.""" """Verify plain password against stored 'salt:hash' using constant-time comparison.
computed_hash = hash_password(plain_password) Also supports legacy-format hash (hex only, without salt) for backward compatibility."""
return hmac.compare_digest(computed_hash, hashed_password) if ':' in stored_value:
parts = stored_value.split(':', 1)
salt = parts[0]
expected_hash = parts[1]
computed = hash_password(plain_password, salt)
computed_hash = computed.split(':', 1)[1]
return hmac.compare_digest(computed_hash, expected_hash)
else:
expected_hash = stored_value
computed_hash = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), b"sonicforge_crypto_salt_2026_secure_", 600000).hex()
return hmac.compare_digest(computed_hash, expected_hash)
def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str: def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str:
payload = { payload = {
+25 -1
View File
@@ -65,8 +65,32 @@ def init_db():
); );
""") """)
# Migration: thêm cột backup nếu chưa tồn tại
try:
cursor.execute("ALTER TABLE projects ADD COLUMN is_backup INTEGER DEFAULT 0")
except Exception:
pass # column already exists
try:
cursor.execute("ALTER TABLE projects ADD COLUMN original_id TEXT DEFAULT NULL")
except Exception:
pass
# Bảng Project Backups (snapshot riêng, không lẫn với projects chính)
cursor.execute("""
CREATE TABLE IF NOT EXISTS project_backups (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
data_json TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
created_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
""")
conn.commit() conn.commit()
conn.close() conn.close()
# Tự động khởi tạo DB khi module được import # Tự động khởi tạo DB khi module được import
init_db() init_db()
+1
View File
@@ -41,6 +41,7 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }), updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }),
updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }), updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }),
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }), deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }),
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }), saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }),
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }), getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
+5 -1
View File
@@ -21,7 +21,11 @@
solo: t.solo, solo: t.solo,
color: t.color, color: t.color,
markers: t.markers || [], markers: t.markers || [],
serverFileId: t.serverFileId || null serverFileId: t.serverFileId || null,
clips: t.clips || [],
sections: t.sections || [],
midiItems: t.midiItems || [],
channelInfo: t.channelInfo || null
})) }))
}; };
} }
+868
View File
@@ -0,0 +1,868 @@
<!DOCTYPE html>
<html lang="vi" class="h-full bg-slate-950 text-slate-100 select-none">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIDI AI Prompt Generator & Preset Builder</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
/* Custom Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #090d16; }
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #334155; }
/* Form Panels */
.panel-bg {
background: linear-gradient(180deg, #111827 0%, #0f172a 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
}
</style>
</head>
<body class="h-full flex flex-col bg-slate-950 text-slate-200 overflow-hidden">
<!-- TOP HEADER TOOLBAR -->
<header class="h-14 bg-slate-900 border-b border-slate-800 px-5 flex items-center justify-between z-20 shrink-0">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl bg-gradient-to-tr from-cyan-600 to-indigo-600 flex items-center justify-center text-white shadow-lg shadow-cyan-950">
<i class="fa-solid fa-wand-magic-sparkles text-sm"></i>
</div>
<div>
<h1 class="text-sm font-bold tracking-wide text-white flex items-center gap-2">
MIDI AI PROMPT GENERATOR <span class="text-[10px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded font-mono">FULL BEAT COVERAGE</span>
</h1>
<p class="text-[11px] text-slate-400">Công cụ tạo mẫu Prompt nhạc lý ép AI sinh đủ nốt phủ kín số ô nhịp</p>
</div>
</div>
<!-- Right Header Actions -->
<div class="flex items-center gap-2">
<button id="btnLoadDefaultPreset" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 transition-colors">
<i class="fa-solid fa-rotate-left text-cyan-400"></i> Nạp Mẫu Piano
</button>
<button id="btnExportJSON" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 transition-colors">
<i class="fa-solid fa-download"></i> Xuất Preset JSON
</button>
<label id="btnImportJSON" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 border border-slate-700 cursor-pointer transition-colors">
<i class="fa-solid fa-upload"></i> Nhập JSON
<input type="file" id="jsonFileInput" accept=".json" class="hidden">
</label>
</div>
</header>
<!-- MAIN WORKSPACE -->
<main class="flex-1 grid grid-cols-12 overflow-hidden">
<!-- LEFT COLUMN: FORM CONTROLS (WIDTH: 7 COLS) -->
<div class="col-span-7 border-r border-slate-800/80 p-5 overflow-y-auto space-y-5 custom-scrollbar bg-slate-950/60">
<!-- 1. VAI TRÒ & PHONG CÁCH -->
<section class="panel-bg rounded-xl p-4 space-y-3">
<div class="flex items-center gap-2 text-xs font-bold text-cyan-400 uppercase tracking-wider border-b border-slate-800/80 pb-2">
<i class="fa-solid fa-user-ninja"></i> 1. Vai Trò Nghệ Sĩ & Phong Cách
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div>
<label class="block text-slate-400 mb-1 font-medium">Vai trò / Chuyên môn:</label>
<input type="text" id="inputRole" list="roleDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim" placeholder="Chọn hoặc nhập vai trò mới...">
<datalist id="roleDatalist"></datalist>
</div>
<div>
<label class="block text-slate-400 mb-1 font-medium">Hành động / Thể loại đoạn nhạc:</label>
<input type="text" id="inputActionDesc" list="actionDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="tiếp tục bản nhạc piano đầy cảm xúc này" placeholder="Chọn hoặc nhập hành động mới...">
<datalist id="actionDatalist"></datalist>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div>
<label class="block text-slate-400 mb-1 font-medium">Loại Phân đoạn (Section Type):</label>
<input type="text" id="inputSectionType" list="sectionTypeDatalist" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-cyan-500 font-sans" value="Cao trào (Build-Up) kịch tính" placeholder="Chọn hoặc nhập phân đoạn mới...">
<datalist id="sectionTypeDatalist"></datalist>
</div>
<div>
<label class="block text-slate-400 mb-1 font-medium">Tên Tool AI Gọi (Function Tool Name):</label>
<input type="text" id="inputToolName" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-cyan-400 font-mono focus:outline-none focus:border-cyan-500" value="generate_multitrack_midi">
</div>
</div>
</section>
<!-- 2. Ô NHỊP & PHÁCH (BARS & BEATS COMPUTATION) -->
<section class="panel-bg rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div class="flex items-center gap-2 text-xs font-bold text-amber-400 uppercase tracking-wider">
<i class="fa-solid fa-stopwatch"></i> 2. Ô Nhịp & Phách (Bars & Beats Auto-Calc)
</div>
<span class="text-[10px] font-mono bg-amber-950/80 border border-amber-800/60 text-amber-300 px-2 py-0.5 rounded">Auto-Computed</span>
</div>
<div class="grid grid-cols-4 gap-3 text-xs">
<div>
<label class="block text-slate-400 mb-1 font-medium">Ô nhịp Bắt đầu:</label>
<input type="number" id="inputStartBar" min="1" max="999" value="9" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
</div>
<div>
<label class="block text-slate-400 mb-1 font-medium">Ô nhịp Kết thúc:</label>
<input type="number" id="inputEndBar" min="1" max="999" value="16" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-amber-500">
</div>
<div>
<label class="block text-slate-400 mb-1 font-medium">Số phách/Ô nhịp (Time Sig):</label>
<select id="selectBeatsPerBar" class="w-full bg-slate-900 border border-slate-800 rounded-lg px-3 py-2 text-amber-300 font-mono focus:outline-none focus:border-amber-500">
<option value="4">4 Phách (Nhịp 4/4)</option>
<option value="3">3 Phách (Nhịp 3/4 Waltz)</option>
<option value="6">6 Phách (Nhịp 6/8)</option>
</select>
</div>
<div>
<label class="block text-slate-400 mb-1 font-medium">Quy đổi Tổng Số Phách:</label>
<div id="readoutTotalBeats" class="w-full bg-slate-950 border border-amber-900/60 rounded-lg px-3 py-2 text-amber-400 font-mono font-bold">
64.0 Phách
</div>
</div>
</div>
<div class="text-[11px] font-mono bg-slate-950 p-2.5 rounded-lg border border-slate-800 text-slate-400 flex items-center justify-between">
<span>Chuỗi Ô nhịp tự động sinh:</span>
<span id="readoutBarList" class="text-cyan-300 font-bold">9, 10, 11, 12, 13, 14, 15 và 16</span>
</div>
</section>
<!-- 3. MẬT ĐỘ NỐT & QUY TẮC HIỆU NĂNG -->
<section class="panel-bg rounded-xl p-4 space-y-3">
<div class="flex items-center gap-2 text-xs font-bold text-emerald-400 uppercase tracking-wider border-b border-slate-800/80 pb-2">
<i class="fa-solid fa-gauge-simple-high"></i> 3. Mật Độ Nốt & Giới Hạn Token
</div>
<div class="text-xs">
<label class="block text-slate-400 mb-1 font-medium">Hướng dẫn Mật độ Nốt (Note Density Instruction):</label>
<textarea id="inputDensityRule" rows="2" class="w-full bg-slate-900 border border-slate-800 rounded-lg p-3 text-white font-sans focus:outline-none focus:border-emerald-500 leading-relaxed text-xs">Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).</textarea>
</div>
</section>
<!-- 4. DANH SÁCH TRACKS (DYNAMIC TRACKS EDITOR) -->
<section class="panel-bg rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between border-b border-slate-800/80 pb-2">
<div class="flex items-center gap-2 text-xs font-bold text-indigo-400 uppercase tracking-wider">
<i class="fa-solid fa-layer-group"></i> 4. Danh Sách Track Cấu Trúc (<span id="trackCountBadge">3</span> Tracks)
</div>
<button id="btnAddTrack" class="px-2.5 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs font-bold transition-all flex items-center gap-1 shadow-md shadow-indigo-950">
<i class="fa-solid fa-plus text-[10px]"></i> Thêm Track
</button>
</div>
<!-- Datalists for Track Names and Track Labels History -->
<datalist id="trackNameDatalist"></datalist>
<datalist id="trackLabelDatalist"></datalist>
<!-- Tracks Container -->
<div id="tracksContainer" class="space-y-3">
<!-- Dynamic Track Cards Rendered via JS -->
</div>
</section>
</div>
<!-- RIGHT COLUMN: LIVE PROMPT PREVIEW & COPY (WIDTH: 5 COLS) -->
<div class="col-span-5 bg-slate-900 flex flex-col overflow-hidden relative">
<!-- Preview Header -->
<div class="h-10 bg-slate-950 border-b border-slate-800 px-4 flex items-center justify-between text-xs font-mono">
<span class="text-slate-300 font-bold flex items-center gap-2">
<i class="fa-solid fa-code text-cyan-400"></i> LIVE PROMPT PREVIEW
</span>
<div class="flex items-center gap-2">
<button id="btnCopyPrompt" class="px-2.5 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-sans font-semibold text-xs rounded flex items-center gap-1 transition-all border border-slate-700" title="Sao Chép Văn Bản">
<i class="fa-solid fa-copy text-cyan-400"></i> <span>Sao Chép</span>
</button>
<button id="btnSavePresetModal" class="px-3 py-1 bg-purple-600 hover:bg-purple-500 text-white font-sans font-bold text-xs rounded flex items-center gap-1.5 transition-all shadow-md shadow-purple-950">
<i class="fa-solid fa-bookmark"></i> <span>Lưu thành Preset</span>
</button>
</div>
</div>
<!-- Live Generated Text Output Box -->
<div class="flex-1 p-4 overflow-y-auto custom-scrollbar">
<div id="promptOutputText" class="w-full h-full bg-slate-950 border border-slate-800 rounded-xl p-4 font-mono text-xs text-slate-200 leading-relaxed whitespace-pre-wrap select-text selection:bg-cyan-900 selection:text-white">
<!-- Text generated dynamically -->
</div>
</div>
<!-- Toast Notification Container -->
<div id="toastNotification" class="absolute bottom-5 right-5 bg-emerald-600 text-white px-4 py-2.5 rounded-xl shadow-2xl font-semibold text-xs flex items-center gap-2 transition-all duration-300 transform translate-y-10 opacity-0 pointer-events-none z-50">
<i class="fa-solid fa-circle-check text-base"></i>
<span id="toastMessage">Đã sao chép câu Prompt vào bộ nhớ tạm!</span>
</div>
</div>
</main>
<!-- MODAL: AI PROMPT PRESET MANAGER (FORM TẠO PRESET MỚI) -->
<div id="presetManagerModal" class="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center hidden p-4">
<div class="w-full max-w-3xl bg-[#13141a] border border-slate-800 rounded-2xl shadow-2xl p-6 space-y-4 text-slate-200 relative overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h2 class="text-sm font-bold tracking-widest text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-400 font-mono uppercase">
AI PROMPT PRESET MANAGER
</h2>
<button id="btnCloseModalX" class="w-7 h-7 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Section Title -->
<div class="text-xs font-bold text-slate-300 uppercase tracking-wider font-mono flex items-center gap-2">
<i class="fa-solid fa-wand-magic-sparkles text-purple-400"></i> TẠO PRESET MỚI
</div>
<!-- Preset Form Grid -->
<div class="space-y-3 text-xs">
<!-- Row 1: Name & Category -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">TÊN PRESET</label>
<input type="text" id="presetNameInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="Nhập tên preset (VD: Epic Orchestra Intro)...">
</div>
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">DANH MỤC</label>
<input type="text" id="presetCategoryInput" list="categoryDatalist" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" value="Orchestral / Film Score" placeholder="Chọn hoặc nhập danh mục...">
<datalist id="categoryDatalist">
<option value="Orchestral / Film Score"></option>
<option value="Piano Solo"></option>
<option value="EDM / Pop"></option>
<option value="Cinematic"></option>
<option value="Jazz & Swing"></option>
<option value="Lo-fi Chillhop"></option>
</datalist>
</div>
</div>
<!-- Row 2: Keywords -->
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">TỪ KHÓA KÍCH HOẠT (NGĂN CÁCH BẰNG DẤU PHẨY)</label>
<input type="text" id="presetKeywordsInput" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-purple-500 font-sans" placeholder="Ví dụ: epic orchestra, hoành tráng, nhạc phim epic">
</div>
<!-- Row 3: Bars, BPM, Scale -->
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">SỐ BARS MẶC ĐỊNH</label>
<input type="number" id="presetBarsInput" value="8" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
</div>
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">BPM MẶC ĐỊNH</label>
<input type="number" id="presetBpmInput" value="120" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500">
</div>
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">ÂM GIAI (SCALE) MẶC ĐỊNH</label>
<input type="text" id="presetScaleInput" value="C Minor" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg px-3 py-2 text-white font-mono focus:outline-none focus:border-purple-500" placeholder="VD: C Minor, A Major...">
</div>
</div>
<!-- Row 4: System Prompt Template -->
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">SYSTEM PROMPT TEMPLATE / LUẬT SOẠN NHẠC</label>
<textarea id="presetTemplateTextarea" rows="6" class="w-full bg-[#1a1c23] border border-slate-800 rounded-lg p-3 text-white font-mono text-xs focus:outline-none focus:border-purple-500 leading-relaxed custom-scrollbar"></textarea>
</div>
</div>
<!-- Action Buttons Row -->
<div class="flex items-center justify-between pt-2 border-t border-slate-800/80">
<div class="flex items-center gap-2">
<button id="btnModalBack" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
Quay lại
</button>
<button id="btnSavePresetSubmit" class="px-5 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-xs font-bold transition-all shadow-lg shadow-purple-950/80 flex items-center gap-1.5">
<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Preset</span>
</button>
</div>
<button id="btnCloseModalBottom" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-xs font-semibold transition-colors">
Đóng
</button>
</div>
</div>
</div>
<!-- JAVASCRIPT LOGIC & STATE ENGINE -->
<script>
// Default Template State matching User Specification
const state = {
role: "nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim",
actionDesc: "tiếp tục bản nhạc piano đầy cảm xúc này",
sectionType: "Cao trào (Build-Up) kịch tính",
startBar: 9,
endBar: 16,
beatsPerBar: 4,
densityRule: "Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).",
toolName: "generate_multitrack_midi",
tracks: [
{
id: "track_1",
name: "Piano Bass & Octaves",
label: "Tiếng Bass & Quãng 8 Piano",
description: "đánh các xung bass trầm nốt đen đều đặn (trong dải C1-C3) liên tục trên mỗi phách từ phách 0.0 đến phách 64.0 (tổng cộng 64 nốt đen)."
},
{
id: "track_2",
name: "Piano Accompaniment",
label: "Tiếng Đệm Piano",
description: "đánh các đoạn rải hợp âm (broken chord arpeggios) nốt móc đơn có nhịp điệu ở âm vực trung (dải C3-C4), lấp đầy liên tục toàn bộ 8 ô nhịp từ phách 0.0 đến phách 64.0."
},
{
id: "track_3",
name: "Piano Melody - Right Hand",
label: "Giai điệu Piano - Tay phải",
description: "đánh một tuyến giai điệu đi lên ở âm vực cao (dải C4-C6) sử dụng nốt trắng và nốt tròn trải dài từ Ô nhịp 9 đến 16, kết thúc bằng một hợp âm chủ (tonic chord) ngân dài được giữ cho đến phách 64.0."
}
]
};
// History & Dropdown Options Management
const defaultHistory = {
roles: [
"nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim",
"nhà sản xuất âm nhạc EDM / Electro Pop",
"nhà soạn nhạc phim Epic Orchestra hoành tráng",
"nghệ sĩ nhạc Jazz ngẫu hứng chuyên nghiệp",
"nhà sản xuất nhạc Lo-fi Chillhop & Beatmaker"
],
actions: [
"tiếp tục bản nhạc piano đầy cảm xúc này",
"tạo một đoạn nhạc dồn dập kịch tính",
"phát triển giai điệu mở đầu du dương",
"xây dựng đoạn điệp khúc bùng nổ và hoành tráng"
],
sectionTypes: [
"Cao trào (Build-Up) kịch tính",
"Mở đầu (Intro) du dương",
"Điệp khúc (Chorus / Climax) bùng nổ",
"Đoạn lắng (Breakdown / Interlude)",
"Đoạn kết (Outro / Resolution)"
],
trackNames: [
"Piano Bass & Octaves",
"Piano Accompaniment",
"Piano Melody - Right Hand",
"Strings Ensemble",
"Brass Theme",
"Epic Percussion",
"Synth Lead",
"Bassline"
],
trackLabels: [
"Tiếng Bass & Quãng 8 Piano",
"Tiếng Đệm Piano",
"Giai điệu Piano - Tay phải",
"Dàn dây Ostinato",
"Tuyến Kèn Brass",
"Bộ Trống Epic",
"Giai điệu Synth",
"Tuyến Bass Sub"
]
};
// DOM References
const inputRole = document.getElementById('inputRole');
const inputActionDesc = document.getElementById('inputActionDesc');
const inputSectionType = document.getElementById('inputSectionType');
const inputToolName = document.getElementById('inputToolName');
const roleDatalist = document.getElementById('roleDatalist');
const actionDatalist = document.getElementById('actionDatalist');
const sectionTypeDatalist = document.getElementById('sectionTypeDatalist');
const inputStartBar = document.getElementById('inputStartBar');
const inputEndBar = document.getElementById('inputEndBar');
const selectBeatsPerBar = document.getElementById('selectBeatsPerBar');
const inputDensityRule = document.getElementById('inputDensityRule');
const readoutTotalBeats = document.getElementById('readoutTotalBeats');
const readoutBarList = document.getElementById('readoutBarList');
const tracksContainer = document.getElementById('tracksContainer');
const trackCountBadge = document.getElementById('trackCountBadge');
const promptOutputText = document.getElementById('promptOutputText');
const btnAddTrack = document.getElementById('btnAddTrack');
const btnCopyPrompt = document.getElementById('btnCopyPrompt');
const btnLoadDefaultPreset = document.getElementById('btnLoadDefaultPreset');
const btnExportJSON = document.getElementById('btnExportJSON');
const jsonFileInput = document.getElementById('jsonFileInput');
const toastNotification = document.getElementById('toastNotification');
// Preset Modal Elements
const presetManagerModal = document.getElementById('presetManagerModal');
const btnSavePresetModal = document.getElementById('btnSavePresetModal');
const btnCloseModalX = document.getElementById('btnCloseModalX');
const btnCloseModalBottom = document.getElementById('btnCloseModalBottom');
const btnModalBack = document.getElementById('btnModalBack');
const btnSavePresetSubmit = document.getElementById('btnSavePresetSubmit');
const presetNameInput = document.getElementById('presetNameInput');
const presetCategoryInput = document.getElementById('presetCategoryInput');
const presetKeywordsInput = document.getElementById('presetKeywordsInput');
const presetBarsInput = document.getElementById('presetBarsInput');
const presetBpmInput = document.getElementById('presetBpmInput');
const presetScaleInput = document.getElementById('presetScaleInput');
const presetTemplateTextarea = document.getElementById('presetTemplateTextarea');
// LocalStorage Helper Functions for History
function getHistory(key, defaults) {
try {
const saved = localStorage.getItem(`midi_prompt_history_${key}`);
return saved ? JSON.parse(saved) : defaults;
} catch (e) {
return defaults;
}
}
function saveHistoryItem(key, newValue, defaults) {
const val = (newValue || "").trim();
if (!val) return;
let list = getHistory(key, defaults);
if (!list.includes(val)) {
list.unshift(val); // Put newest entry first
try {
localStorage.setItem(`midi_prompt_history_${key}`, JSON.stringify(list));
} catch (e) {}
renderDatalistOptions(key, list);
}
}
function renderDatalistOptions(key, list) {
let datalistElem;
if (key === 'roles') datalistElem = roleDatalist;
else if (key === 'actions') datalistElem = actionDatalist;
else if (key === 'sectionTypes') datalistElem = sectionTypeDatalist;
else if (key === 'trackNames') datalistElem = document.getElementById('trackNameDatalist');
else if (key === 'trackLabels') datalistElem = document.getElementById('trackLabelDatalist');
if (!datalistElem) return;
datalistElem.innerHTML = list.map(item => `<option value="${escapeHtml(item)}"></option>`).join('');
}
function initHistoryDatalists() {
const roles = getHistory('roles', defaultHistory.roles);
const actions = getHistory('actions', defaultHistory.actions);
const sectionTypes = getHistory('sectionTypes', defaultHistory.sectionTypes);
const trackNames = getHistory('trackNames', defaultHistory.trackNames);
const trackLabels = getHistory('trackLabels', defaultHistory.trackLabels);
renderDatalistOptions('roles', roles);
renderDatalistOptions('actions', actions);
renderDatalistOptions('sectionTypes', sectionTypes);
renderDatalistOptions('trackNames', trackNames);
renderDatalistOptions('trackLabels', trackLabels);
}
// Auto-save history on input change/blur
inputRole.addEventListener('change', () => saveHistoryItem('roles', inputRole.value, defaultHistory.roles));
inputActionDesc.addEventListener('change', () => saveHistoryItem('actions', inputActionDesc.value, defaultHistory.actions));
inputSectionType.addEventListener('change', () => saveHistoryItem('sectionTypes', inputSectionType.value, defaultHistory.sectionTypes));
// Helper to calculate total bars, total beats, and format bar range list
function computeTimeMetrics() {
const start = parseInt(inputStartBar.value) || 1;
const end = parseInt(inputEndBar.value) || 1;
const beatsPerBar = parseInt(selectBeatsPerBar.value) || 4;
const barCount = Math.max(1, (end - start) + 1);
const totalBeats = (barCount * beatsPerBar).toFixed(1);
// Format bar list string: e.g. "9, 10, 11, 12, 13, 14, 15 và 16"
const barNumbers = [];
for (let i = start; i <= end; i++) {
barNumbers.push(i);
}
let barListStr = "";
if (barNumbers.length === 1) {
barListStr = `${barNumbers[0]}`;
} else if (barNumbers.length === 2) {
barListStr = `${barNumbers[0]} và ${barNumbers[1]}`;
} else {
const leading = barNumbers.slice(0, -1).join(', ');
const last = barNumbers[barNumbers.length - 1];
barListStr = `${leading} và ${last}`;
}
readoutTotalBeats.innerText = `${totalBeats} Phách`;
readoutBarList.innerText = barListStr;
return {
barCount,
totalBeats,
start,
end,
barListStr
};
}
// Render Dynamic Track Input Cards
function renderTrackEditors() {
tracksContainer.innerHTML = "";
trackCountBadge.innerText = state.tracks.length;
state.tracks.forEach((track, idx) => {
const card = document.createElement('div');
card.className = "bg-slate-900/90 border border-slate-800 p-3 rounded-xl space-y-2 relative group";
card.innerHTML = `
<div class="flex items-center justify-between text-xs font-bold text-slate-300">
<span class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-[10px] text-cyan-400 font-mono">${idx + 1}</span>
Track ${idx + 1}
</span>
<div class="flex items-center gap-1">
<button onclick="moveTrack(${idx}, -1)" class="w-6 h-6 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors" title="Di chuyển lên">
<i class="fa-solid fa-chevron-up text-[10px]"></i>
</button>
<button onclick="moveTrack(${idx}, 1)" class="w-6 h-6 rounded bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-colors" title="Di chuyển xuống">
<i class="fa-solid fa-chevron-down text-[10px]"></i>
</button>
<button onclick="removeTrack(${idx})" class="w-6 h-6 rounded bg-red-950/80 hover:bg-red-900 text-red-400 flex items-center justify-center transition-colors ml-1" title="Xóa Track">
<i class="fa-solid fa-trash-can text-[10px]"></i>
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Tên Track (Mã Tiếng Anh):</label>
<input type="text" list="trackNameDatalist" value="${escapeHtml(track.name)}" oninput="updateTrackData(${idx}, 'name', this.value)" onchange="saveHistoryItem('trackNames', this.value, defaultHistory.trackNames); updateTrackData(${idx}, 'name', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-white font-mono text-xs focus:outline-none focus:border-cyan-500" placeholder="Chọn hoặc nhập tên track...">
</div>
<div>
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Nhãn / Chú Thích Tiếng Việt:</label>
<input type="text" list="trackLabelDatalist" value="${escapeHtml(track.label)}" oninput="updateTrackData(${idx}, 'label', this.value)" onchange="saveHistoryItem('trackLabels', this.value, defaultHistory.trackLabels); updateTrackData(${idx}, 'label', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded px-2.5 py-1.5 text-slate-300 text-xs focus:outline-none focus:border-cyan-500" placeholder="Chọn hoặc nhập nhãn chú thích...">
</div>
</div>
<div class="text-xs">
<label class="block text-[10px] text-slate-500 font-mono mb-0.5">Mô tả Quy cách Đánh & Tiết tấu:</label>
<textarea rows="2" oninput="updateTrackData(${idx}, 'description', this.value)" class="w-full bg-slate-950 border border-slate-800 rounded p-2 text-white font-sans text-xs focus:outline-none focus:border-cyan-500 leading-relaxed">${escapeHtml(track.description)}</textarea>
</div>
`;
tracksContainer.appendChild(card);
});
}
function escapeHtml(str) {
return (str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// Main Prompt Generator
function generatePromptText() {
const timeData = computeTimeMetrics();
const role = inputRole.value.trim();
const actionDesc = inputActionDesc.value.trim();
const sectionType = inputSectionType.value.trim();
const toolName = inputToolName.value.trim();
const densityRule = inputDensityRule.value.trim();
const trackCount = state.tracks.length;
let promptStr = `Bạn là ${role}. Hãy ${actionDesc} bằng một đoạn ${sectionType} dài ${timeData.barCount} ô nhịp (Từ Ô nhịp ${timeData.start} đến ${timeData.end}, tương ứng với Phách 0.0 đến ${timeData.totalBeats}).\n\n`;
promptStr += `YÊU CẦU BẮT BUỘC VỀ ĐỘ DÀI TOÀN BỘ:\n\n`;
promptStr += `Tất cả ${trackCount} track ĐỀU PHẢI tạo ra các nốt nhạc bao phủ toàn bộ khoảng từ phách 0.0 đến phách ${timeData.totalBeats}, không được dừng lại giữa chừng.\n\n`;
promptStr += `Nhịp điệu phải liên tục không đứt đoạn qua các Ô nhịp ${timeData.barListStr}.\n\n`;
promptStr += `Nốt/hợp âm cuối cùng của mỗi track phải kéo dài đến hoặc giải kết (resolve) tại phách ${timeData.totalBeats}.\n\n`;
if (densityRule) {
promptStr += `${densityRule}\n\n`;
}
promptStr += `Cấu trúc bắt buộc trả về thông qua công cụ ${toolName} gồm ${trackCount} track:\n\n`;
state.tracks.forEach((track, i) => {
promptStr += `${track.name} (${track.label}): ${track.description.trim()}\n\n`;
});
promptStr += `Đảm bảo mọi track đều chạm tới phách ${timeData.totalBeats}.`;
promptOutputText.innerText = promptStr;
}
// Dynamic Track Actions
window.updateTrackData = function(index, key, val) {
state.tracks[index][key] = val;
generatePromptText();
};
window.removeTrack = function(index) {
if (state.tracks.length <= 1) {
showToast("Cần giữ ít nhất 1 Track!", "error");
return;
}
state.tracks.splice(index, 1);
renderTrackEditors();
generatePromptText();
};
window.moveTrack = function(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= state.tracks.length) return;
const temp = state.tracks[index];
state.tracks[index] = state.tracks[newIndex];
state.tracks[newIndex] = temp;
renderTrackEditors();
generatePromptText();
};
btnAddTrack.addEventListener('click', () => {
const newId = `track_${Date.now()}`;
state.tracks.push({
id: newId,
name: `Track ${state.tracks.length + 1}`,
label: `Mô tả Track ${state.tracks.length + 1}`,
description: `đánh tuyến nốt hòa âm liên tục từ phách 0.0 đến phách 64.0.`
});
renderTrackEditors();
generatePromptText();
});
// Toast Notification System
function showToast(msg, type = "success") {
const toastMsg = document.getElementById('toastMessage');
toastMsg.innerText = msg;
if (type === "error") {
toastNotification.classList.remove('bg-emerald-600');
toastNotification.classList.add('bg-red-600');
} else {
toastNotification.classList.remove('bg-red-600');
toastNotification.classList.add('bg-emerald-600');
}
toastNotification.classList.remove('translate-y-10', 'opacity-0', 'pointer-events-none');
setTimeout(() => {
toastNotification.classList.add('translate-y-10', 'opacity-0', 'pointer-events-none');
}, 2500);
}
// Open Save Preset Modal Event Handler
btnSavePresetModal.addEventListener('click', () => {
const timeData = computeTimeMetrics();
const currentPromptText = promptOutputText.innerText;
// Auto-populate Preset Form Fields
const sectionType = inputSectionType.value.trim() || "Building Section";
const role = inputRole.value.trim() || "Composer";
presetNameInput.value = `${sectionType} (${timeData.barCount} Bars)`;
presetCategoryInput.value = role.includes("piano") ? "Piano Solo" : "Orchestral / Film Score";
presetKeywordsInput.value = `${sectionType.toLowerCase()}, ${role.toLowerCase().split(' ')[0]}, ${timeData.barCount} bars`;
presetBarsInput.value = timeData.barCount;
presetBpmInput.value = 120;
presetScaleInput.value = "C Minor";
presetTemplateTextarea.value = currentPromptText;
// Display Modal
presetManagerModal.classList.remove('hidden');
});
// Close Modal Event Handlers
[btnCloseModalX, btnCloseModalBottom, btnModalBack].forEach(btn => {
if (btn) {
btn.addEventListener('click', () => {
presetManagerModal.classList.add('hidden');
});
}
});
// Save Preset Action Handler (Saves to daw_ai_prompt_presets localStorage)
btnSavePresetSubmit.addEventListener('click', () => {
const name = presetNameInput.value.trim();
const category = presetCategoryInput.value.trim() || "General";
const keywordsRaw = presetKeywordsInput.value.trim();
const defaultBars = parseInt(presetBarsInput.value) || 8;
const defaultBpm = parseInt(presetBpmInput.value) || 120;
const defaultScale = presetScaleInput.value.trim() || "C Minor";
const template = presetTemplateTextarea.value.trim();
if (!name) {
showToast("Vui lòng nhập Tên Preset!", "error");
presetNameInput.focus();
return;
}
if (!template) {
showToast("Nội dung System Prompt không được trống!", "error");
return;
}
const keywords = keywordsRaw ? keywordsRaw.split(',').map(k => k.trim()).filter(Boolean) : [name.toLowerCase()];
const newPresetObj = {
id: `preset_${Date.now()}`,
name: name,
category: category,
keywords: keywords,
default_bars: defaultBars,
default_bpm: defaultBpm,
default_scale: defaultScale,
system_instruction_template: template,
is_user_defined: true,
created_at: new Date().toISOString()
};
// Read existing saved presets list from localStorage
let savedPresets = [];
try {
const stored = localStorage.getItem('daw_ai_prompt_presets');
savedPresets = stored ? JSON.parse(stored) : [];
} catch (e) {
savedPresets = [];
}
savedPresets.unshift(newPresetObj);
try {
localStorage.setItem('daw_ai_prompt_presets', JSON.stringify(savedPresets));
showToast(`Đã lưu Preset "${name}" thành công!`);
presetManagerModal.classList.add('hidden');
} catch (e) {
showToast("Lỗi khi lưu vào bộ nhớ trình duyệt!", "error");
}
});
// Copy Prompt Button
btnCopyPrompt.addEventListener('click', () => {
// Save current input values to history when copying
saveHistoryItem('roles', inputRole.value, defaultHistory.roles);
saveHistoryItem('actions', inputActionDesc.value, defaultHistory.actions);
saveHistoryItem('sectionTypes', inputSectionType.value, defaultHistory.sectionTypes);
// Save tracks history
state.tracks.forEach(track => {
saveHistoryItem('trackNames', track.name, defaultHistory.trackNames);
saveHistoryItem('trackLabels', track.label, defaultHistory.trackLabels);
});
const textToCopy = promptOutputText.innerText;
// Fallback copy execCommand for iframe compatibility
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
showToast("Đã sao chép câu Prompt thành công!");
} catch (err) {
showToast("Lỗi khi sao chép!", "error");
}
document.body.removeChild(textarea);
});
// Reset Default Piano Preset
btnLoadDefaultPreset.addEventListener('click', () => {
inputRole.value = "nghệ sĩ piano chuyên nghiệp và là nhà soạn nhạc phim";
inputActionDesc.value = "tiếp tục bản nhạc piano đầy cảm xúc này";
inputSectionType.value = "Cao trào (Build-Up) kịch tính";
inputToolName.value = "generate_multitrack_midi";
inputStartBar.value = "9";
inputEndBar.value = "16";
selectBeatsPerBar.value = "4";
inputDensityRule.value = "Hãy giữ mật độ nốt hiệu quả (chủ yếu là nốt đen và các đoạn rải hợp âm nốt móc đơn đều đặn, tránh các chuỗi nốt móc đôi/móc ba quá dày đặc) để tránh bị quá giới hạn thời gian xuất dữ liệu (token timeout).";
state.tracks = [
{
id: "track_1",
name: "Piano Bass & Octaves",
label: "Tiếng Bass & Quãng 8 Piano",
description: "đánh các xung bass trầm nốt đen đều đặn (trong dải C1-C3) liên tục trên mỗi phách từ phách 0.0 đến phách 64.0 (tổng cộng 64 nốt đen)."
},
{
id: "track_2",
name: "Piano Accompaniment",
label: "Tiếng Đệm Piano",
description: "đánh các đoạn rải hợp âm (broken chord arpeggios) nốt móc đơn có nhịp điệu ở âm vực trung (dải C3-C4), lấp đầy liên tục toàn bộ 8 ô nhịp từ phách 0.0 đến phách 64.0."
},
{
id: "track_3",
name: "Piano Melody - Right Hand",
label: "Giai điệu Piano - Tay phải",
description: "đánh một tuyến giai điệu đi lên ở âm vực cao (dải C4-C6) sử dụng nốt trắng và nốt tròn trải dài từ Ô nhịp 9 đến 16, kết thúc bằng một hợp âm chủ (tonic chord) ngân dài được giữ cho đến phách 64.0."
}
];
renderTrackEditors();
generatePromptText();
showToast("Đã nạp lại Mẫu Prompt Piano!");
});
// Export Preset JSON
btnExportJSON.addEventListener('click', () => {
const exportData = {
role: inputRole.value,
actionDesc: inputActionDesc.value,
sectionType: inputSectionType.value,
startBar: parseInt(inputStartBar.value),
endBar: parseInt(inputEndBar.value),
beatsPerBar: parseInt(selectBeatsPerBar.value),
densityRule: inputDensityRule.value,
toolName: inputToolName.value,
tracks: state.tracks
};
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 2));
const downloadAnchor = document.createElement('a');
downloadAnchor.setAttribute("href", dataStr);
downloadAnchor.setAttribute("download", `midi_prompt_preset_${Date.now()}.json`);
document.body.appendChild(downloadAnchor);
downloadAnchor.click();
downloadAnchor.remove();
showToast("Đã xuất tệp JSON Preset!");
});
// Import Preset JSON
jsonFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const imported = JSON.parse(event.target.result);
if (imported.role) inputRole.value = imported.role;
if (imported.actionDesc) inputActionDesc.value = imported.actionDesc;
if (imported.sectionType) inputSectionType.value = imported.sectionType;
if (imported.startBar) inputStartBar.value = imported.startBar;
if (imported.endBar) inputEndBar.value = imported.endBar;
if (imported.beatsPerBar) selectBeatsPerBar.value = imported.beatsPerBar;
if (imported.densityRule !== undefined) inputDensityRule.value = imported.densityRule;
if (imported.toolName) inputToolName.value = imported.toolName;
if (Array.isArray(imported.tracks)) state.tracks = imported.tracks;
renderTrackEditors();
generatePromptText();
showToast("Đã nạp Preset từ tệp JSON thành công!");
} catch (err) {
showToast("Tệp JSON không hợp lệ!", "error");
}
};
reader.readAsText(file);
});
// Attach input listeners
[inputRole, inputActionDesc, inputSectionType, inputToolName, inputStartBar, inputEndBar, selectBeatsPerBar, inputDensityRule].forEach(elem => {
elem.addEventListener('input', generatePromptText);
});
// Initial Render
window.addEventListener('load', () => {
initHistoryDatalists();
renderTrackEditors();
generatePromptText();
});
</script>
</body>
</html>