IMPROVE: cho phép lưu dự án và phục hồi dự án vừa tắt
This commit is contained in:
+30
-1
@@ -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
@@ -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
@@ -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 = {
|
||||||
|
|||||||
@@ -65,6 +65,30 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
Reference in New Issue
Block a user