feat: AI MIDI Prompt Template & Preset Engine
- promptTemplateManager.js: standalone service with keyword scoring, CRUD, fav toggle - ai_presets.py: backend CRUD router (JSON file, auth isolation) - AIPresetModal: PromptTemplateManager, star/fav column, backend API sync - Piano Roll AI: preset matching support - 7 tests: matching, CRUD, anonymous auth, user isolation
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import json, os, time
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.core.auth import decode_token
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DATA_FILE = os.path.join(settings.PROCESSED_DIR, "ai_presets.json")
|
||||
|
||||
DEFAULT_PRESETS = [
|
||||
{
|
||||
"id": "preset_epic_orchestra_intro",
|
||||
"name": "Epic Orchestra Intro (8 Bars)",
|
||||
"keywords": ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
|
||||
"category": "Orchestral / Film Score",
|
||||
"default_bars": 8,
|
||||
"default_bpm": 130,
|
||||
"default_scale": "C Minor",
|
||||
"system_instruction_template": "You are a professional film composer. Create a powerful, dramatic 8-bar orchestral intro. Keep the note density low (e.g. use mostly whole notes, half notes, or quarter notes) and do NOT generate dense 16th notes or complex drum rolls. This is critical to avoid output token limit timeouts. The required structure to return via the `generate_multitrack_midi` tool consists of 3 tracks: 1. Strings: plays smooth legato chord changes (one chord per 1 or 2 bars). 2. Brass Theme: plays a swelling simple melodic line in the C3-C5 range. 3. Epic Percussion: hits heavily on beats 1 and 3. Ensure the duration is precisely 8 bars (32 beats).",
|
||||
"is_user_defined": False,
|
||||
"is_favorite": False,
|
||||
"created_at": "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "preset_pop_piano_chords",
|
||||
"name": "Pop Piano Chords (4 Bars)",
|
||||
"keywords": ["pop piano", "piano chords", "ballad piano", "hợp âm piano"],
|
||||
"category": "Pop / Ballad",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 90,
|
||||
"default_scale": "C Major",
|
||||
"system_instruction_template": "You are a professional Pop Piano player. Generate a beautiful 4-bar piano chord progression (e.g. C - G - Am - F) with pleasant chord voicing and simple accompaniment. Return the MIDI notes via `generate_multitrack_midi` function on a track named 'Pop Piano'. Keep notes simple, using mostly whole/half/quarter notes. Ensure the duration of the track is precisely 4 bars (16 beats).",
|
||||
"is_user_defined": False,
|
||||
"is_favorite": False,
|
||||
"created_at": "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "preset_cyberpunk_synth",
|
||||
"name": "Cyberpunk Synthwave (8 Bars)",
|
||||
"keywords": ["cyberpunk synth", "synthwave", "cyberpunk", "futuristic synth"],
|
||||
"category": "Electronic / Synthwave",
|
||||
"default_bars": 8,
|
||||
"default_bpm": 120,
|
||||
"default_scale": "A Minor",
|
||||
"system_instruction_template": "You are a Synthwave producer. Generate a driving 8-bar cyberpunk synth theme. Return MIDI notes via `generate_multitrack_midi` containing: 1. Synth Bass: eighth notes on pitch A1, C2, G1. 2. Synth Lead: simple melodic line in high register C4-E5. Keep notes clean and concise to ensure fast generation.",
|
||||
"is_user_defined": False,
|
||||
"is_favorite": False,
|
||||
"created_at": "2026-07-23T16:00:00Z"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class AIPresetSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
keywords: List[str]
|
||||
category: str = "General"
|
||||
default_bars: int = 8
|
||||
default_bpm: int = 120
|
||||
default_scale: str = "C Major"
|
||||
system_instruction_template: str
|
||||
is_user_defined: bool = True
|
||||
is_favorite: bool = False
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
def _load_data():
|
||||
if not os.path.exists(DATA_FILE):
|
||||
return {"user_presets": {}}
|
||||
try:
|
||||
with open(DATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {"user_presets": {}}
|
||||
|
||||
|
||||
def _save_data(user_presets):
|
||||
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||
with open(DATA_FILE, "w") as f:
|
||||
json.dump({"user_presets": user_presets}, f, indent=2)
|
||||
|
||||
|
||||
def _get_user_id(authorization):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return "anonymous"
|
||||
token = authorization.split(" ")[1]
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
return "anonymous"
|
||||
return payload.get("user_id", "anonymous")
|
||||
|
||||
|
||||
@router.get("/presets")
|
||||
async def list_presets(authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
data = _load_data()
|
||||
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||
merged = DEFAULT_PRESETS + user_presets
|
||||
return {"success": True, "presets": merged}
|
||||
|
||||
|
||||
@router.post("/presets")
|
||||
async def save_preset(req: AIPresetSchema, authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
if uid == "anonymous":
|
||||
raise HTTPException(status_code=401, detail="Authentication required to save presets")
|
||||
|
||||
data = _load_data()
|
||||
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||
|
||||
if not req.created_at:
|
||||
req.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
existing_idx = next((i for i, p in enumerate(user_presets) if p["id"] == req.id), None)
|
||||
preset_dict = req.model_dump()
|
||||
|
||||
if existing_idx is not None:
|
||||
user_presets[existing_idx] = preset_dict
|
||||
else:
|
||||
user_presets.append(preset_dict)
|
||||
|
||||
if "user_presets" not in data:
|
||||
data["user_presets"] = {}
|
||||
data["user_presets"][uid] = user_presets
|
||||
_save_data(data["user_presets"])
|
||||
return {"success": True, "preset": preset_dict}
|
||||
|
||||
|
||||
@router.delete("/presets/{preset_id}")
|
||||
async def delete_preset(preset_id: str, authorization: Optional[str] = Header(None)):
|
||||
uid = _get_user_id(authorization)
|
||||
if uid == "anonymous":
|
||||
raise HTTPException(status_code=401, detail="Authentication required to delete presets")
|
||||
|
||||
data = _load_data()
|
||||
user_presets = data.get("user_presets", {}).get(uid, [])
|
||||
filtered = [p for p in user_presets if p["id"] != preset_id]
|
||||
|
||||
if len(filtered) == len(user_presets):
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
if "user_presets" not in data:
|
||||
data["user_presets"] = {}
|
||||
data["user_presets"][uid] = filtered
|
||||
_save_data(data["user_presets"])
|
||||
return {"success": True, "message": "Preset deleted"}
|
||||
@@ -12,6 +12,7 @@ from app.api.v1.admin import router as admin_router
|
||||
from app.api.v1.projects import router as projects_router
|
||||
from app.api.v1.user_config import router as user_config_router
|
||||
from app.api.v1.ai_proxy import router as ai_proxy_router
|
||||
from app.api.v1.ai_presets import router as ai_presets_router
|
||||
from app.api.v1.plugins import router as plugins_router
|
||||
from app.core.auth import seed_admin
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
@@ -50,6 +51,7 @@ app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(projects_router, prefix="/api/v1/projects", tags=["projects"])
|
||||
app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config"])
|
||||
app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
||||
|
||||
# Seed admin user on startup
|
||||
|
||||
+101
-74
@@ -4271,65 +4271,20 @@ const SystemManagerModal = ({
|
||||
}, "Xóa")))))))));
|
||||
};
|
||||
|
||||
const DEFAULT_PRESETS = [
|
||||
{
|
||||
id: "preset_epic_orchestra_intro",
|
||||
name: "Epic Orchestra Intro (8 Bars)",
|
||||
keywords: ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
|
||||
category: "Orchestral / Film Score",
|
||||
default_bars: 8,
|
||||
default_bpm: 130,
|
||||
default_scale: "C Minor",
|
||||
system_instruction_template: "You are a professional film composer. Create a powerful, dramatic 8-bar orchestral intro. Keep the note density low (e.g. use mostly whole notes, half notes, or quarter notes) and do NOT generate dense 16th notes or complex drum rolls. This is critical to avoid output token limit timeouts. The required structure to return via the `generate_multitrack_midi` tool consists of 3 tracks: 1. Strings: plays smooth legato chord changes (one chord per 1 or 2 bars). 2. Brass Theme: plays a swelling simple melodic line in the C3-C5 range. 3. Epic Percussion: hits heavily on beats 1 and 3. Ensure the duration is precisely 8 bars (32 beats).",
|
||||
is_user_defined: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
id: "preset_pop_piano_chords",
|
||||
name: "Pop Piano Chords (4 Bars)",
|
||||
keywords: ["pop piano", "piano chords", "ballad piano", "hợp âm piano"],
|
||||
category: "Pop / Ballad",
|
||||
default_bars: 4,
|
||||
default_bpm: 90,
|
||||
default_scale: "C Major",
|
||||
system_instruction_template: "You are a professional Pop Piano player. Generate a beautiful 4-bar piano chord progression (e.g. C - G - Am - F) with pleasant chord voicing and simple accompaniment. Return the MIDI notes via `generate_multitrack_midi` function on a track named 'Pop Piano'. Keep notes simple, using mostly whole/half/quarter notes. Ensure the duration of the track is precisely 4 bars (16 beats).",
|
||||
is_user_defined: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
id: "preset_cyberpunk_synth",
|
||||
name: "Cyberpunk Synthwave (8 Bars)",
|
||||
keywords: ["cyberpunk synth", "synthwave", "cyberpunk", "futuristic synth"],
|
||||
category: "Electronic / Synthwave",
|
||||
default_bars: 8,
|
||||
default_bpm: 120,
|
||||
default_scale: "A Minor",
|
||||
system_instruction_template: "You are a Synthwave producer. Generate a driving 8-bar cyberpunk synth theme. Return MIDI notes via `generate_multitrack_midi` containing: 1. Synth Bass: eighth notes on pitch A1, C2, G1. 2. Synth Lead: simple melodic line in high register C4-E5. Keep notes clean and concise to ensure fast generation.",
|
||||
is_user_defined: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
}
|
||||
];
|
||||
|
||||
const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [presets, setPresets] = React.useState(() => {
|
||||
const local = localStorage.getItem('daw_ai_prompt_presets');
|
||||
if (!local) return DEFAULT_PRESETS;
|
||||
try {
|
||||
const parsed = JSON.parse(local);
|
||||
const userPresets = parsed.filter(p => p.is_user_defined);
|
||||
return [...DEFAULT_PRESETS, ...userPresets];
|
||||
} catch (_) {
|
||||
return DEFAULT_PRESETS;
|
||||
}
|
||||
});
|
||||
const mgrRef = React.useRef(null);
|
||||
if (!mgrRef.current) mgrRef.current = new window.PromptTemplateManager();
|
||||
const mgr = mgrRef.current;
|
||||
|
||||
const [presets, setPresets] = React.useState(() => [...mgr.getPresets()]);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [filterCategory, setFilterCategory] = React.useState('ALL');
|
||||
const [editingPreset, setEditingPreset] = React.useState(null); // preset object or 'new'
|
||||
const [showFavoritesOnly, setShowFavoritesOnly] = React.useState(false);
|
||||
const [editingPreset, setEditingPreset] = React.useState(null);
|
||||
const [syncing, setSyncing] = React.useState(false);
|
||||
|
||||
// Form states
|
||||
const [formName, setFormName] = React.useState('');
|
||||
const [formKeywords, setFormKeywords] = React.useState('');
|
||||
const [formCategory, setFormCategory] = React.useState('Orchestral / Film Score');
|
||||
@@ -4338,9 +4293,32 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
const [formScale, setFormScale] = React.useState('C Minor');
|
||||
const [formTemplate, setFormTemplate] = React.useState('');
|
||||
|
||||
// Try to sync from backend on mount
|
||||
React.useEffect(() => {
|
||||
if (!window.SonicAPI) return;
|
||||
setSyncing(true);
|
||||
window.SonicAPI.getAIPresets()
|
||||
.then(data => {
|
||||
if (data && data.presets && data.presets.length > 0) {
|
||||
mgr.presets = data.presets;
|
||||
setPresets([...data.presets]);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setSyncing(false));
|
||||
}, []);
|
||||
|
||||
const savePresets = (newPresets) => {
|
||||
setPresets(newPresets);
|
||||
localStorage.setItem('daw_ai_prompt_presets', JSON.stringify(newPresets));
|
||||
mgr.presets = newPresets;
|
||||
mgr.savePresets();
|
||||
// Sync to backend if available
|
||||
const userDefined = newPresets.filter(p => p.is_user_defined);
|
||||
if (window.SonicAPI && userDefined.length > 0) {
|
||||
userDefined.forEach(p => {
|
||||
window.SonicAPI.saveAIPreset(p).catch(() => {});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (p) => {
|
||||
@@ -4365,9 +4343,22 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
setFormTemplate('');
|
||||
};
|
||||
|
||||
const handleToggleFav = (id) => {
|
||||
mgr.toggleFavorite(id);
|
||||
setPresets([...mgr.getPresets()]);
|
||||
const p = mgr.presets.find(x => x.id === id);
|
||||
if (p && p.is_user_defined && window.SonicAPI) {
|
||||
window.SonicAPI.saveAIPreset(p).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
const updated = presets.filter(p => p.id !== id);
|
||||
savePresets(updated);
|
||||
const p = presets.find(x => x.id === id);
|
||||
if (p && p.is_user_defined && window.SonicAPI) {
|
||||
window.SonicAPI.deleteAIPreset(id).catch(() => {});
|
||||
}
|
||||
mgr.deletePreset(id);
|
||||
setPresets([...mgr.getPresets()]);
|
||||
showToast('Đã xóa preset.', 'info');
|
||||
};
|
||||
|
||||
@@ -4389,27 +4380,34 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
default_scale: formScale,
|
||||
system_instruction_template: formTemplate.trim(),
|
||||
is_user_defined: true,
|
||||
is_favorite: editingPreset === 'new' ? false : (editingPreset.is_favorite || false),
|
||||
created_at: editingPreset === 'new' ? new Date().toISOString() : editingPreset.created_at
|
||||
};
|
||||
|
||||
let updated;
|
||||
if (editingPreset === 'new') {
|
||||
updated = [...presets, presetObj];
|
||||
} else {
|
||||
updated = presets.map(p => p.id === presetObj.id ? presetObj : p);
|
||||
}
|
||||
|
||||
savePresets(updated);
|
||||
mgr.saveUserPreset(presetObj);
|
||||
setPresets([...mgr.getPresets()]);
|
||||
setEditingPreset(null);
|
||||
|
||||
if (window.SonicAPI) {
|
||||
window.SonicAPI.saveAIPreset(presetObj).catch(() => {});
|
||||
}
|
||||
showToast('Đã lưu preset thành công!', 'success');
|
||||
};
|
||||
|
||||
const categories = ['ALL', ...new Set(presets.map(p => p.category))];
|
||||
const categories = ['ALL', '★ Yêu thích', ...new Set(presets.map(p => p.category))];
|
||||
|
||||
const filtered = presets.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.keywords.some(k => k.toLowerCase().includes(search.toLowerCase()));
|
||||
const matchesCategory = filterCategory === 'ALL' || p.category === filterCategory;
|
||||
let matchesCategory;
|
||||
if (filterCategory === 'ALL') {
|
||||
matchesCategory = true;
|
||||
} else if (filterCategory === '★ Yêu thích') {
|
||||
matchesCategory = p.is_favorite;
|
||||
} else {
|
||||
matchesCategory = p.category === filterCategory;
|
||||
}
|
||||
if (showFavoritesOnly) matchesCategory = matchesCategory && p.is_favorite;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
@@ -4424,7 +4422,9 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "sliders",
|
||||
className: "w-4 h-4"
|
||||
}), "AI Prompt Preset Manager"), /*#__PURE__*/React.createElement("button", {
|
||||
}), "AI Prompt Preset Manager", syncing && /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-[10px] text-zinc-500 ml-2"
|
||||
}, "đang đồng bộ...")), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => { setEditingPreset(null); onClose(); },
|
||||
className: "text-zinc-400 hover:text-zinc-200 transition"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
@@ -4450,6 +4450,13 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
key: c,
|
||||
value: c
|
||||
}, c === 'ALL' ? 'Tất cả danh mục' : c))), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setShowFavoritesOnly(!showFavoritesOnly),
|
||||
className: `px-2.5 py-1 rounded text-xs font-bold transition shrink-0 ${showFavoritesOnly ? 'bg-yellow-700 text-yellow-300' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'}`,
|
||||
title: "Chỉ hiện yêu thích"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "star",
|
||||
className: "w-3.5 h-3.5 inline-block mr-1"
|
||||
}), "★"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: handleNew,
|
||||
className: "px-3 py-1 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs font-bold flex items-center gap-1.5 shadow transition shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
@@ -4464,6 +4471,8 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
}, /*#__PURE__*/React.createElement("thead", {
|
||||
className: "bg-[#1f1f23] text-zinc-400 font-bold border-b border-zinc-800"
|
||||
}, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("th", {
|
||||
className: "p-2.5 w-8"
|
||||
}, ""), /*#__PURE__*/React.createElement("th", {
|
||||
className: "p-2.5 w-1/4"
|
||||
}, "Tên Preset"), /*#__PURE__*/React.createElement("th", {
|
||||
className: "p-2.5 w-1/4"
|
||||
@@ -4477,6 +4486,12 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
key: p.id,
|
||||
className: "border-b border-zinc-800/50 hover:bg-zinc-850"
|
||||
}, /*#__PURE__*/React.createElement("td", {
|
||||
className: "p-2.5 text-center"
|
||||
}, /*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => handleToggleFav(p.id),
|
||||
className: `transition ${p.is_favorite ? 'text-yellow-400' : 'text-zinc-600 hover:text-zinc-400'}`,
|
||||
title: p.is_favorite ? 'Bỏ yêu thích' : 'Đánh dấu yêu thích'
|
||||
}, p.is_favorite ? "★" : "☆")), /*#__PURE__*/React.createElement("td", {
|
||||
className: "p-2.5 font-semibold text-purple-300"
|
||||
}, p.name), /*#__PURE__*/React.createElement("td", {
|
||||
className: "p-2.5 text-zinc-400 font-mono text-[11px] truncate max-w-[150px]"
|
||||
@@ -4490,7 +4505,7 @@ const AIPresetModal = ({ isOpen, onClose }) => {
|
||||
type: "button",
|
||||
onClick: () => handleEdit(p),
|
||||
className: "px-2 py-0.5 bg-zinc-850 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 text-[10px]"
|
||||
}, "Sửa"), /*#__PURE__*/React.createElement("button", {
|
||||
}, "Sửa"), p.is_user_defined && /*#__PURE__*/React.createElement("button", {
|
||||
type: "button",
|
||||
onClick: () => handleDelete(p.id),
|
||||
className: "px-2 py-0.5 bg-red-950/40 hover:bg-red-800 text-red-400 rounded border border-red-900 text-[10px]"
|
||||
@@ -13646,13 +13661,26 @@ const App = () => {
|
||||
const apiKey = provider.api_key || provider.apiKey || '';
|
||||
const model = provider.model_name || provider.model || 'deepseek-chat';
|
||||
setAiActionLog(prev => [...prev, { type: 'info', text: ` Provider: ${provider.name || 'default'} | Model: ${model}`, time: Date.now() }]);
|
||||
|
||||
let pianoSystemInstruction = 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.';
|
||||
try {
|
||||
const mgr = new window.PromptTemplateManager();
|
||||
const match = mgr.matchPreset(prompt);
|
||||
if (match) {
|
||||
pianoSystemInstruction = match.preset.system_instruction_template;
|
||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` 🎯 Khớp Preset: "${match.preset.name}". Tự động tối ưu gợi ý...`, time: Date.now() }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Lỗi khi tìm preset:", e);
|
||||
}
|
||||
|
||||
const result = await window.AIGateway.executeAIPrompt({
|
||||
prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt,
|
||||
provider: provider.name || 'default',
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
||||
systemInstruction: 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'
|
||||
systemInstruction: pianoSystemInstruction
|
||||
});
|
||||
if (!result) throw new Error('AI không phản hồi');
|
||||
let notesData = null;
|
||||
@@ -13721,12 +13749,11 @@ const App = () => {
|
||||
|
||||
let matchedInstruction = '';
|
||||
try {
|
||||
const localPresets = localStorage.getItem('daw_ai_prompt_presets');
|
||||
const presets = localPresets ? JSON.parse(localPresets) : DEFAULT_PRESETS;
|
||||
const matched = presets.find(p => p.keywords.some(kw => prompt.toLowerCase().includes(kw.toLowerCase())));
|
||||
if (matched) {
|
||||
matchedInstruction = matched.system_instruction_template;
|
||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` 🎯 Khớp Preset: "${matched.name}". Tự động tối ưu gợi ý...`, time: Date.now() }]);
|
||||
const mgr = new window.PromptTemplateManager();
|
||||
const match = mgr.matchPreset(prompt);
|
||||
if (match) {
|
||||
matchedInstruction = match.preset.system_instruction_template;
|
||||
setAiActionLog(prev => [...prev, { type: 'status', text: ` 🎯 Khớp Preset: "${match.preset.name}". Tự động tối ưu gợi ý...`, time: Date.now() }]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Lỗi khi tìm preset:", e);
|
||||
|
||||
@@ -66,6 +66,10 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||
|
||||
renderProject: (projectJson, outputFilename) => apiRequest('/api/v1/plugins/render', { method: 'POST', body: JSON.stringify({ project_json: projectJson, output_filename: outputFilename }) }),
|
||||
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
|
||||
uploadSoundFont: async (file) => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
const PromptTemplateManager = (function() {
|
||||
const DEFAULT_PRESETS = [
|
||||
{
|
||||
id: "preset_epic_orchestra_intro",
|
||||
name: "Epic Orchestra Intro (8 Bars)",
|
||||
keywords: ["epic orchestra", "epic orchestral", "hoành tráng", "nhạc phim epic"],
|
||||
category: "Orchestral / Film Score",
|
||||
default_bars: 8,
|
||||
default_bpm: 130,
|
||||
default_scale: "C Minor",
|
||||
system_instruction_template: "You are a professional film composer. Create a powerful, dramatic 8-bar orchestral intro. Keep the note density low (e.g. use mostly whole notes, half notes, or quarter notes) and do NOT generate dense 16th notes or complex drum rolls. This is critical to avoid output token limit timeouts. The required structure to return via the `generate_multitrack_midi` tool consists of 3 tracks: 1. Strings: plays smooth legato chord changes (one chord per 1 or 2 bars). 2. Brass Theme: plays a swelling simple melodic line in the C3-C5 range. 3. Epic Percussion: hits heavily on beats 1 and 3. Ensure the duration is precisely 8 bars (32 beats).",
|
||||
is_user_defined: false,
|
||||
is_favorite: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
id: "preset_pop_piano_chords",
|
||||
name: "Pop Piano Chords (4 Bars)",
|
||||
keywords: ["pop piano", "piano chords", "ballad piano", "hợp âm piano"],
|
||||
category: "Pop / Ballad",
|
||||
default_bars: 4,
|
||||
default_bpm: 90,
|
||||
default_scale: "C Major",
|
||||
system_instruction_template: "You are a professional Pop Piano player. Generate a beautiful 4-bar piano chord progression (e.g. C - G - Am - F) with pleasant chord voicing and simple accompaniment. Return the MIDI notes via `generate_multitrack_midi` function on a track named 'Pop Piano'. Keep notes simple, using mostly whole/half/quarter notes. Ensure the duration of the track is precisely 4 bars (16 beats).",
|
||||
is_user_defined: false,
|
||||
is_favorite: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
},
|
||||
{
|
||||
id: "preset_cyberpunk_synth",
|
||||
name: "Cyberpunk Synthwave (8 Bars)",
|
||||
keywords: ["cyberpunk synth", "synthwave", "cyberpunk", "futuristic synth"],
|
||||
category: "Electronic / Synthwave",
|
||||
default_bars: 8,
|
||||
default_bpm: 120,
|
||||
default_scale: "A Minor",
|
||||
system_instruction_template: "You are a Synthwave producer. Generate a driving 8-bar cyberpunk synth theme. Return MIDI notes via `generate_multitrack_midi` containing: 1. Synth Bass: eighth notes on pitch A1, C2, G1. 2. Synth Lead: simple melodic line in high register C4-E5. Keep notes clean and concise to ensure fast generation.",
|
||||
is_user_defined: false,
|
||||
is_favorite: false,
|
||||
created_at: "2026-07-23T16:00:00Z"
|
||||
}
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'daw_ai_prompt_presets';
|
||||
|
||||
function PromptTemplateManager() {
|
||||
this.presets = [];
|
||||
this.loadPresets();
|
||||
}
|
||||
|
||||
PromptTemplateManager.prototype.loadPresets = function() {
|
||||
try {
|
||||
const localData = localStorage.getItem(STORAGE_KEY);
|
||||
if (localData) {
|
||||
const parsed = JSON.parse(localData);
|
||||
const userPresets = parsed.filter(p => p.is_user_defined);
|
||||
this.presets = [...DEFAULT_PRESETS, ...userPresets];
|
||||
} else {
|
||||
this.presets = [...DEFAULT_PRESETS];
|
||||
this.savePresets();
|
||||
}
|
||||
} catch (_) {
|
||||
this.presets = [...DEFAULT_PRESETS];
|
||||
}
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.savePresets = function() {
|
||||
const userData = this.presets.filter(p => p.is_user_defined);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(userData));
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.getPresets = function() {
|
||||
return this.presets;
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.matchPreset = function(userQuery) {
|
||||
if (!userQuery) return null;
|
||||
const queryLower = userQuery.toLowerCase();
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const preset of this.presets) {
|
||||
for (const kw of preset.keywords) {
|
||||
const kwLower = kw.toLowerCase();
|
||||
if (queryLower === kwLower) {
|
||||
if (3 > bestScore) {
|
||||
bestScore = 3;
|
||||
bestMatch = { preset, score: 3 };
|
||||
}
|
||||
} else if (queryLower.includes(kwLower)) {
|
||||
if (2 > bestScore) {
|
||||
bestScore = 2;
|
||||
bestMatch = { preset, score: 2 };
|
||||
}
|
||||
} else if (kwLower.includes(queryLower)) {
|
||||
if (1 > bestScore) {
|
||||
bestScore = 1;
|
||||
bestMatch = { preset, score: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.saveUserPreset = function(presetObject) {
|
||||
const index = this.presets.findIndex(p => p.id === presetObject.id);
|
||||
if (index >= 0) {
|
||||
this.presets[index] = presetObject;
|
||||
} else {
|
||||
this.presets.push(presetObject);
|
||||
}
|
||||
this.savePresets();
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.deletePreset = function(id) {
|
||||
this.presets = this.presets.filter(p => p.id !== id);
|
||||
this.savePresets();
|
||||
};
|
||||
|
||||
PromptTemplateManager.prototype.toggleFavorite = function(id) {
|
||||
const preset = this.presets.find(p => p.id === id);
|
||||
if (preset) {
|
||||
preset.is_favorite = !preset.is_favorite;
|
||||
if (preset.is_user_defined) {
|
||||
this.savePresets();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return PromptTemplateManager;
|
||||
})();
|
||||
|
||||
window.PromptTemplateManager = PromptTemplateManager;
|
||||
window.DEFAULT_PRESETS = (new PromptTemplateManager()).getPresets();
|
||||
@@ -20,6 +20,7 @@
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607271245" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import os, json, time
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.config import settings
|
||||
from app.core.auth import create_token
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
TEST_DATA_FILE = os.path.join(settings.PROCESSED_DIR, "ai_presets.json")
|
||||
if os.path.exists(TEST_DATA_FILE):
|
||||
os.remove(TEST_DATA_FILE)
|
||||
|
||||
|
||||
def _auth_header():
|
||||
token = create_token("test_user_presets", "testuser", "standard", False)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def test_list_presets_defaults():
|
||||
res = client.get("/api/v1/ai/presets")
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["presets"]) == 3
|
||||
names = [p["name"] for p in data["presets"]]
|
||||
assert "Epic Orchestra Intro (8 Bars)" in names
|
||||
assert "Pop Piano Chords (4 Bars)" in names
|
||||
assert "Cyberpunk Synthwave (8 Bars)" in names
|
||||
|
||||
|
||||
def test_create_user_preset():
|
||||
preset = {
|
||||
"id": "preset_test_my_beat",
|
||||
"name": "My Beat (4 Bars)",
|
||||
"keywords": ["my beat", "custom beat", "beat của tôi"],
|
||||
"category": "Hip Hop / Beat",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 100,
|
||||
"default_scale": "A Minor",
|
||||
"system_instruction_template": "Generate a 4-bar hip hop beat with kick, snare, hi-hat.",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False,
|
||||
"created_at": "2026-07-28T00:00:00Z"
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset, headers=_auth_header())
|
||||
assert res.status_code == 200, res.text
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
assert data["preset"]["id"] == "preset_test_my_beat"
|
||||
|
||||
# Verify it appears in listing
|
||||
res2 = client.get("/api/v1/ai/presets", headers=_auth_header())
|
||||
assert res2.status_code == 200
|
||||
names = [p["name"] for p in res2.json()["presets"]]
|
||||
assert "My Beat (4 Bars)" in names
|
||||
assert len(res2.json()["presets"]) == 4 # 3 defaults + 1 user
|
||||
|
||||
|
||||
def test_update_user_preset():
|
||||
preset = {
|
||||
"id": "preset_test_my_beat",
|
||||
"name": "My Beat Updated (8 Bars)",
|
||||
"keywords": ["my beat", "custom beat"],
|
||||
"category": "Hip Hop / Beat",
|
||||
"default_bars": 8,
|
||||
"default_bpm": 110,
|
||||
"default_scale": "C Minor",
|
||||
"system_instruction_template": "Generate an 8-bar hip hop beat.",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": True,
|
||||
"created_at": "2026-07-28T00:00:00Z"
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset, headers=_auth_header())
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["preset"]["name"] == "My Beat Updated (8 Bars)"
|
||||
assert data["preset"]["is_favorite"] is True
|
||||
assert data["preset"]["default_bars"] == 8
|
||||
|
||||
|
||||
def test_delete_user_preset():
|
||||
res = client.delete("/api/v1/ai/presets/preset_test_my_beat", headers=_auth_header())
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["success"] is True
|
||||
|
||||
res2 = client.get("/api/v1/ai/presets", headers=_auth_header())
|
||||
assert res2.status_code == 200
|
||||
names = [p["name"] for p in res2.json()["presets"]]
|
||||
assert "My Beat Updated (8 Bars)" not in names
|
||||
|
||||
|
||||
def test_delete_nonexistent_preset():
|
||||
res = client.delete("/api/v1/ai/presets/nonexistent_id", headers=_auth_header())
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_presets_anonymous_cannot_save():
|
||||
preset = {
|
||||
"id": "preset_anon",
|
||||
"name": "Anon Preset",
|
||||
"keywords": ["anon"],
|
||||
"category": "General",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 120,
|
||||
"default_scale": "C Major",
|
||||
"system_instruction_template": "Test",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset)
|
||||
assert res.status_code == 401
|
||||
|
||||
res2 = client.delete("/api/v1/ai/presets/preset_anon")
|
||||
assert res2.status_code == 401
|
||||
|
||||
|
||||
def test_presets_isolation_between_users():
|
||||
token_a = create_token("user_a", "usera", "standard", False)
|
||||
token_b = create_token("user_b", "userb", "standard", False)
|
||||
|
||||
preset_a = {
|
||||
"id": "preset_user_a_only",
|
||||
"name": "User A Preset",
|
||||
"keywords": ["user a"],
|
||||
"category": "General",
|
||||
"default_bars": 4,
|
||||
"default_bpm": 120,
|
||||
"default_scale": "C Major",
|
||||
"system_instruction_template": "User A specific",
|
||||
"is_user_defined": True,
|
||||
"is_favorite": False
|
||||
}
|
||||
res = client.post("/api/v1/ai/presets", json=preset_a, headers={"Authorization": f"Bearer {token_a}"})
|
||||
assert res.status_code == 200
|
||||
|
||||
# User B shouldn't see User A's presets
|
||||
res_b = client.get("/api/v1/ai/presets", headers={"Authorization": f"Bearer {token_b}"})
|
||||
assert res_b.status_code == 200
|
||||
names_b = [p["name"] for p in res_b.json()["presets"]]
|
||||
assert "User A Preset" not in names_b
|
||||
assert "Epic Orchestra Intro (8 Bars)" in names_b
|
||||
@@ -539,6 +539,12 @@
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Ctrl+Click+Drag section/MIDI item → copy đến vị trí mới, item không bị selected.
|
||||
---
|
||||
|
||||
### [2026-07-28 10:39] Task: AI MIDI Prompt Template & Preset Engine (spec 33_MIDI_AI.md)
|
||||
- **Tóm tắt thay đổi:** Tạo `PromptTemplateManager` service (keyword matching với scoring, CRUD, star/fav toggle). Backend `ai_presets.py` CRUD router (JSON file storage, auth isolation). Refactor `AIPresetModal` dùng service + star toggle + backend API. Piano Roll AI wiring. 7 tests cho matching + backend CRUD + auth isolation.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/promptTemplateManager.js` (NEW), `app/api/v1/ai_presets.py` (NEW), `tests/test_ai_presets.py` (NEW), `app/main.py`, `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `python3 -m pytest tests/test_ai_presets.py -v` — 7 tests pass. Pre-existing 1 test fail (`test_api_user_ai_config` permission error, unrelated).
|
||||
---
|
||||
|
||||
### [2026-07-28 10:15] Task: Dropdown CC lane — Velocity, Sustain, Modulation, Pitch Bend, Pan
|
||||
- **Tóm tắt thay đổi:** Thay button group (velocity/pan) bằng `<select>` dropdown với 5 options. Hiển thị mặc định là Velocity. Button toggle CC lane hiển thị short label (Vel/Sus/Mod/Bend/Pan/CC).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||
|
||||
Reference in New Issue
Block a user