Compare commits
4 Commits
289746f187
...
e99e54773f
| Author | SHA1 | Date | |
|---|---|---|---|
| e99e54773f | |||
| 0ba31c57bf | |||
| 454dd91f96 | |||
| e9f29e09ca |
@@ -151,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
|
||||
@router.get("/soundfonts/download/{sf_id}")
|
||||
async def download_soundfont_asset(sf_id: str):
|
||||
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
|
||||
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
|
||||
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
|
||||
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
|
||||
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
|
||||
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
|
||||
if not os.path.isdir(base_dir):
|
||||
continue
|
||||
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
|
||||
|
||||
+6
-1
@@ -81,7 +81,12 @@ async def get_index():
|
||||
if not os.path.exists(index_path):
|
||||
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
|
||||
with open(index_path, "r", encoding="utf-8") as file:
|
||||
return HTMLResponse(content=file.read(), status_code=200)
|
||||
resp = HTMLResponse(content=file.read(), status_code=200)
|
||||
# no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) —
|
||||
# nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load
|
||||
# được bundle mới" ở incognito — cache heuristic không có Cache-Control).
|
||||
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/favicon.svg")
|
||||
|
||||
+173
-17
@@ -2675,6 +2675,7 @@ const WaveformLane = ({
|
||||
// Check if hovering near right edge of a clip for time-stretching (Alt key required)
|
||||
const toleranceSec = 8 / zoom;
|
||||
const rightEdgeClip = clips.find(c => {
|
||||
if (!c.buffer) return false;
|
||||
const duration = c.buffer.duration / (c.speed || 1.0);
|
||||
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
||||
});
|
||||
@@ -2725,7 +2726,7 @@ const WaveformLane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const hoveredClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const isOverClip = !!hoveredClip;
|
||||
if (activeTool === 'pen') {
|
||||
canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed';
|
||||
@@ -2806,6 +2807,7 @@ const WaveformLane = ({
|
||||
// Check if time-stretching (Alt + Right Edge)
|
||||
const toleranceSec = 8 / zoom;
|
||||
const rightEdgeClip = clips.find(c => {
|
||||
if (!c.buffer) return false;
|
||||
const duration = c.buffer.duration / (c.speed || 1.0);
|
||||
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
|
||||
});
|
||||
@@ -2880,7 +2882,7 @@ const WaveformLane = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
|
||||
// Set selected clip ID
|
||||
if (clickedClip) {
|
||||
@@ -3022,7 +3024,7 @@ const WaveformLane = ({
|
||||
if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.id);
|
||||
return;
|
||||
}
|
||||
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -7034,6 +7036,93 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||
}, []);
|
||||
|
||||
// ── Humanize: ngẫu nhiên hóa velocity + timing theo cường độ ──
|
||||
const [humanizeStrength, setHumanizeStrength] = React.useState(0.10); // 0.05 nhẹ / 0.10 vừa / 0.18 mạnh
|
||||
const applyHumanize = React.useCallback(() => {
|
||||
if (!notes || !notes.length) { showToast('Không có nốt nào để humanize.', 'warning'); return; }
|
||||
const velAmt = humanizeStrength;
|
||||
const timeAmt = humanizeStrength * 0.15; // ±0.015 beat @ vừa (~12ms @120bpm)
|
||||
pushToUndo(notes);
|
||||
setNotes(prev => (prev || []).map(n => ({
|
||||
...n,
|
||||
velocity: Math.max(0.05, Math.min(1.0, (n.velocity || 0.8) + (Math.random() * 2 - 1) * velAmt)),
|
||||
start_beat: Math.max(0, (n.start_beat || 0) + (Math.random() * 2 - 1) * timeAmt)
|
||||
})));
|
||||
showToast('Đã humanize ' + notes.length + ' nốt (velocity ±' + Math.round(velAmt * 100) + '%, timing ±' + Math.round(timeAmt * 1000) + 'ms).', 'success');
|
||||
}, [notes, pushToUndo, setNotes, showToast, humanizeStrength]);
|
||||
|
||||
// ── Transpose semitone: dịch pitch tất cả nốt (clamp 0-127) ──
|
||||
const applyTranspose = React.useCallback((semi) => {
|
||||
const s = parseInt(semi);
|
||||
if (isNaN(s) || s === 0) { showToast('Nhập số semitone khác 0.', 'warning'); return; }
|
||||
if (!notes || !notes.length) { showToast('Không có nốt nào để transpose.', 'warning'); return; }
|
||||
pushToUndo(notes);
|
||||
setNotes(prev => (prev || []).map(n => ({
|
||||
...n,
|
||||
pitch: Math.max(0, Math.min(127, (n.pitch || 60) + s))
|
||||
})));
|
||||
showToast('Đã transpose ' + notes.length + ' nốt ' + (s > 0 ? '+' : '') + s + ' semitone.', 'success');
|
||||
}, [notes, pushToUndo, setNotes, showToast]);
|
||||
|
||||
// ── Transpose theo SCALE (chuyển giọng): detect key hiện tại → map degree ──
|
||||
const SCALE_PATTERNS = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10] };
|
||||
const SCALE_ROOTS = { C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11 };
|
||||
const detectKey = React.useCallback((noteList) => {
|
||||
const roots = Object.keys(SCALE_ROOTS);
|
||||
let best = null, bestScore = -1;
|
||||
for (let ri = 0; ri < roots.length; ri++) {
|
||||
for (const st of ['major', 'minor']) {
|
||||
const tones = new Set(SCALE_PATTERNS[st].map(s => (SCALE_ROOTS[roots[ri]] + s) % 12));
|
||||
let score = 0;
|
||||
(noteList || []).forEach(n => { const pc = (((n.pitch || 60) % 12) + 12) % 12; if (tones.has(pc)) score++; });
|
||||
if (score > bestScore) { bestScore = score; best = { root: roots[ri], scale: st }; }
|
||||
}
|
||||
}
|
||||
return best || { root: 'C', scale: 'major' };
|
||||
}, []);
|
||||
const [keyTargetRoot, setKeyTargetRoot] = React.useState('C');
|
||||
const [keyTargetScale, setKeyTargetScale] = React.useState('major');
|
||||
// Auto-detect scale khi MỞ midi item → hiển thị ở dropdown chuyển giọng.
|
||||
// Key theo st.target_id (item id) — sửa note cùng item KHÔNG reset lựa chọn
|
||||
// của user; mở item khác → detect lại.
|
||||
React.useEffect(() => {
|
||||
const itemNotes = st.notes || [];
|
||||
if (itemNotes.length) {
|
||||
const k = detectKey(itemNotes);
|
||||
setKeyTargetRoot(k.root);
|
||||
setKeyTargetScale(k.scale);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [st.target_id]);
|
||||
const applyTransposeToKey = React.useCallback(() => {
|
||||
if (!notes || !notes.length) { showToast('Không có nốt nào để chuyển giọng.', 'warning'); return; }
|
||||
const srcKey = detectKey(notes);
|
||||
const dstKey = { root: keyTargetRoot, scale: keyTargetScale };
|
||||
if (srcKey.root === dstKey.root && srcKey.scale === dstKey.scale) {
|
||||
showToast('Đã ở giọng ' + dstKey.root + ' ' + dstKey.scale + ' rồi.', 'info');
|
||||
return;
|
||||
}
|
||||
const srcTones = SCALE_PATTERNS[srcKey.scale].map(s => (SCALE_ROOTS[srcKey.root] + s) % 12);
|
||||
const dstTones = SCALE_PATTERNS[dstKey.scale].map(s => (SCALE_ROOTS[dstKey.root] + s) % 12);
|
||||
pushToUndo(notes);
|
||||
setNotes(prev => (prev || []).map(n => {
|
||||
const p = n.pitch || 60;
|
||||
const pc = ((p % 12) + 12) % 12;
|
||||
// Degree gần nhất trong scale nguồn (7 bậc)
|
||||
let bestIdx = 0, bestDist = 99;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
let d = Math.abs(pc - srcTones[i]); if (d > 6) d = 12 - d;
|
||||
if (d < bestDist) { bestDist = d; bestIdx = i; }
|
||||
}
|
||||
let shift = dstTones[bestIdx] - srcTones[bestIdx];
|
||||
if (shift > 6) shift -= 12; else if (shift < -6) shift += 12;
|
||||
return { ...n, pitch: Math.max(0, Math.min(127, p + shift)) };
|
||||
}));
|
||||
showToast('Chuyển giọng ' + srcKey.root + ' ' + srcKey.scale + ' → ' + dstKey.root + ' ' + dstKey.scale + ' (' + notes.length + ' nốt).', 'success');
|
||||
}, [notes, pushToUndo, setNotes, showToast, detectKey, keyTargetRoot, keyTargetScale]);
|
||||
|
||||
const [transposeSemis, setTransposeSemis] = React.useState(0);
|
||||
|
||||
const handleUndo = React.useCallback(() => {
|
||||
const prev = undoStackRef.current.pop();
|
||||
if (!prev) return;
|
||||
@@ -8385,9 +8474,9 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
return React.createElement("div", {
|
||||
className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full"
|
||||
},
|
||||
/* 1. TOOLBAR HEADER */
|
||||
/* 1. TOOLBAR HEADER — 2 hàng (wrap tự nhiên; spacer 100% ép hàng mới) */
|
||||
React.createElement("div", {
|
||||
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
|
||||
className: "bg-[#282828] border-b border-zinc-900 flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5 shrink-0 text-slate-200"
|
||||
}, React.createElement("div", {
|
||||
className: "flex items-center gap-4"
|
||||
}, React.createElement("select", {
|
||||
@@ -8473,12 +8562,56 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
|
||||
}(),
|
||||
title: "Toggle ghost notes visibility"
|
||||
}, "\uD83D\uDC7B Ghost"), React.createElement("div", {
|
||||
className: "flex items-center gap-1"
|
||||
}, "👻 MIDI ghost notes"), React.createElement("button", {
|
||||
onClick: onClose,
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition ml-auto"
|
||||
}, React.createElement("i", {
|
||||
"data-lucide": "x",
|
||||
className: "w-3 h-3"
|
||||
}), "Đóng"), React.createElement("div", {
|
||||
style: { flexBasis: "100%", height: 0 }
|
||||
}), React.createElement("button", {
|
||||
onClick: applyHumanize,
|
||||
className: "px-2 py-1 rounded text-xs bg-amber-900/40 text-amber-300 border border-amber-700/60 hover:bg-amber-800/50 transition",
|
||||
title: "Humanize: randomize velocity + timing"
|
||||
}, "\uD83C\uDF9A Humanize"), React.createElement("select", {
|
||||
key: "humstr", value: humanizeStrength,
|
||||
onChange: function(e) { setHumanizeStrength(parseFloat(e.target.value)); },
|
||||
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||
title: "Cường độ humanize"
|
||||
}, React.createElement("option", { key: "l", value: 0.05 }, "Nh\u1EB9"), React.createElement("option", { key: "m", value: 0.10 }, "V\u1EEBa"), React.createElement("option", { key: "s", value: 0.18 }, "M\u1EA1nh")), React.createElement("div", {
|
||||
key: "transpose", className: "flex items-center gap-1"
|
||||
}, React.createElement("input", {
|
||||
key: "in", type: "number", step: 1, min: -24, max: 24, value: transposeSemis,
|
||||
onChange: function(e) { setTransposeSemis(e.target.value); },
|
||||
className: "w-12 px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-center text-zinc-200",
|
||||
title: "Semitone offset (vd 2 = cao hơn 1 tone)"
|
||||
}), React.createElement("button", {
|
||||
key: "btn", onClick: function() { applyTranspose(transposeSemis); },
|
||||
className: "px-2 py-1 rounded text-xs bg-sky-900/40 text-sky-300 border border-sky-700/60 hover:bg-sky-800/50 transition",
|
||||
title: "Transpose all notes by the semitone offset"
|
||||
}, "Transpose")), React.createElement("div", {
|
||||
key: "keyshift", className: "flex items-center gap-1"
|
||||
}, React.createElement("select", {
|
||||
key: "root", value: keyTargetRoot,
|
||||
onChange: function(e) { setKeyTargetRoot(e.target.value); },
|
||||
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||
title: "Giọng đích (root)"
|
||||
}, ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"].map(function(r) { return React.createElement("option", { key: r, value: r }, r); })), React.createElement("select", {
|
||||
key: "scale", value: keyTargetScale,
|
||||
onChange: function(e) { setKeyTargetScale(e.target.value); },
|
||||
className: "px-1 py-1 rounded text-xs bg-zinc-900 border border-zinc-700 text-zinc-300 cursor-pointer",
|
||||
title: "Thể scale đích"
|
||||
}, React.createElement("option", { key: "maj", value: "major" }, "major"), React.createElement("option", { key: "min", value: "minor" }, "minor")), React.createElement("button", {
|
||||
key: "btn", onClick: applyTransposeToKey,
|
||||
className: "px-2 py-1 rounded text-xs bg-violet-900/40 text-violet-300 border border-violet-700/60 hover:bg-violet-800/50 transition",
|
||||
title: "Chuyển giọng: map degree hiện tại sang giọng đích (auto-detect key nguồn)"
|
||||
}, "🎵 Chuyển giọng")), React.createElement("div", {
|
||||
className: "flex items-center gap-1 ml-auto"
|
||||
}, React.createElement("button", {
|
||||
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
|
||||
className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), React.createElement("button", {
|
||||
}, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "L\u01B0u"), React.createElement("button", {
|
||||
onClick: () => {
|
||||
const ppq = 480;
|
||||
const bpmNum = parseInt(bpm) || 120;
|
||||
@@ -8528,13 +8661,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
showToast('Đã xuất file MIDI!', 'success');
|
||||
},
|
||||
className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold"
|
||||
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export"), React.createElement("button", {
|
||||
onClick: onClose,
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition"
|
||||
}, React.createElement("i", {
|
||||
"data-lucide": "x",
|
||||
className: "w-3 h-3"
|
||||
}), "Đóng"))),
|
||||
}, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export MIDI"))),
|
||||
|
||||
/* 2. BAR RULER */
|
||||
React.createElement("div", {
|
||||
@@ -14775,10 +14902,27 @@ const App = () => {
|
||||
if (!pendingWasNull) return;
|
||||
|
||||
var lastId = localStorage.getItem('sonic_project_id');
|
||||
if (!lastId) return;
|
||||
var lastName = localStorage.getItem('sonic_project_name') || 'Dự án';
|
||||
try {
|
||||
var parsed = null;
|
||||
if (!lastId) {
|
||||
// Máy mới / browser ẩn danh: localStorage TRỐNG (id/name không tồn tại
|
||||
// trên máy này) → tự mở project Cloud GẦN NHẤT của tài khoản đang đăng
|
||||
// nhập — project tạo trên máy khác vẫn mở được ngay.
|
||||
const prof = currentUser || (function () { try { return JSON.parse(localStorage.getItem('sonic_user') || 'null'); } catch (e) { return null; } })();
|
||||
if (prof && window.SonicAPI && window.SonicAPI.listCloudProjects) {
|
||||
try {
|
||||
const cloudList = await window.SonicAPI.listCloudProjects();
|
||||
if (cloudList && cloudList.length > 0) {
|
||||
const latest = cloudList[0]; // backend ORDER BY updated_at DESC
|
||||
lastId = latest.id;
|
||||
lastName = latest.name || lastName;
|
||||
const proj = await window.SonicAPI.getCloudProject(lastId);
|
||||
if (proj && proj.data_json) parsed = JSON.parse(proj.data_json);
|
||||
}
|
||||
} catch (e) { console.warn('restore cloud fallback error:', e); }
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (lastId.startsWith('local_')) {
|
||||
var localData = localStorage.getItem('sonic_local_project_data');
|
||||
if (localData) parsed = JSON.parse(localData);
|
||||
@@ -14786,6 +14930,9 @@ const App = () => {
|
||||
var proj = await window.SonicAPI.getCloudProject(lastId);
|
||||
if (proj) parsed = JSON.parse(proj.data_json);
|
||||
}
|
||||
} catch (e) { console.warn('restore load error:', e); }
|
||||
}
|
||||
try {
|
||||
if (!parsed) return;
|
||||
var restoredBpm = bpm;
|
||||
var restoredTracks = [];
|
||||
@@ -21740,6 +21887,15 @@ const App = () => {
|
||||
setProjectName(finalName);
|
||||
window.SonicStorage.exportProjectToSFS(projectSchemaObj);
|
||||
showToast(`Đã lưu dự án local "${finalName}" thành công!`, "success");
|
||||
// Đã đăng nhập → đồng bộ lên Cloud (fire-and-forget): project mở được từ
|
||||
// máy khác / browser ẩn danh khi đăng nhập cùng tài khoản.
|
||||
if (currentUser && window.SonicAPI && window.SonicAPI.saveCloudProject) {
|
||||
window.SonicAPI.saveCloudProject(finalName, dataStr).then(function (res) {
|
||||
if (res && res.project_id) {
|
||||
console.log('[Cloud] local project synced:', res.project_id);
|
||||
}
|
||||
}).catch(function (e) { console.warn('Cloud sync local project failed:', e); });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCloudProject = async (name, existingProjectId) => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@
|
||||
let _pendingOutputDestination = null;
|
||||
let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream
|
||||
let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ
|
||||
let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail
|
||||
let _scheduledNotes = [];
|
||||
let _loadPromises = {};
|
||||
let _sfloadSeq = 0;
|
||||
@@ -326,9 +327,16 @@
|
||||
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
|
||||
var resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
// Fallback: font bundled theo deployment (static/soundfonts —
|
||||
// serve qua /soundfonts/{f} — catalog default-soundfonts).
|
||||
var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now();
|
||||
var resp2 = await fetch(url2);
|
||||
if (!resp2.ok) {
|
||||
console.warn("[SonicSF] SoundFont not found:", sfId);
|
||||
return false;
|
||||
}
|
||||
resp = resp2;
|
||||
}
|
||||
buf = await resp.arrayBuffer();
|
||||
if (cache) await cache.saveBuffer(sfId, buf);
|
||||
var sfHandle = this._tryLoadSFL(buf, '.sf3');
|
||||
@@ -543,10 +551,24 @@
|
||||
// Quick instrument pick on a track does not pre-load it, so load
|
||||
// lazily here and retry the note once the font is ready.
|
||||
if (finalSfId && !_sfHandleMap.has(finalSfId)) {
|
||||
// Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s)
|
||||
// — note chạy thẳng fallback để CÓ ÂM.
|
||||
var _lastFail = _sfLoadFailAt[finalSfId] || 0;
|
||||
if (Date.now() - _lastFail < 10000) {
|
||||
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId);
|
||||
self.loadSoundFont(finalSfId).then(function (ok) {
|
||||
console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId);
|
||||
if (ok) doNote();
|
||||
if (ok) {
|
||||
doNote();
|
||||
} else {
|
||||
// Font KHÔNG tải được (404/format) → KHÔNG drop note
|
||||
// câm lặng ("bỏ qua WASM") — fallback oscillator.
|
||||
_sfLoadFailAt[finalSfId] = Date.now();
|
||||
try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608060200"></script>
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202608060630"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202608037200"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
|
||||
@@ -24,7 +24,7 @@
|
||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608060430" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608061030" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -1955,3 +1955,89 @@
|
||||
- **FIX (app.jsx effect [activeTab] ~14571):** `prevActiveTabRef` lưu tab trước; khi đổi tab → nếu tab CŨ là sub-tab (PIANO_ROLL/section/audio) đang `isPlaying` → `stopAllPlayback()` + set `isPlaying: false` cho tab đó. Tab MỚI không bị ảnh hưởng; MAIN giữ hành vi cũ (mở piano-roll lúc main play → main tiếp tục — handleEditMidiInTab đã xử lý).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060430), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → piano-roll play → bấm tab MAIN → âm piano-roll phải DỪNG ngay; play main → mở piano-roll → main tiếp tục (hành vi cũ).
|
||||
|
||||
### [2026-08-06 05:00] Task: Project không mở được khi đăng nhập máy khác/ẩn danh — 2 fix cross-machine
|
||||
- **Báo cáo user:** login máy khác / browser ẩn danh → không mở được project đã tạo trước đó.
|
||||
- **Chẩn đoán:** backend cloud key ĐÚNG theo user_id (JWT) ✓; OpenProjectModal có tab Cloud (list per-user) + Local (localStorage MÁY-ĐỊNH XỨ). Vấn đề: (1) `restoreLastSessionProject` đọc `sonic_project_id` từ localStorage — máy mới → trống → không restore gì; (2) project lưu LOCAL (`local_` id — chưa login lúc save) → localStorage → machine-bound.
|
||||
- **FIX (app.jsx):**
|
||||
(1) `restoreLastSessionProject`: `!lastId` (máy mới) + có profile (currentUser hoặc `localStorage sonic_user`) → **tự mở project Cloud GẦN NHẤT** (listCloudProjects → [0] → getCloudProject → restore). Local id có sẵn → hành vi cũ.
|
||||
(2) `handleSaveLocalProject`: đã login → **đồng bộ lên Cloud (fire-and-forget saveCloudProject)** — project mở được từ máy khác.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060500), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → login máy mới/ẩn danh → tự mở project Cloud gần nhất. Lưu local khi login → xuất hiện trong Cloud tab ở máy khác. Project local CŨ (tạo trước fix) → mở trên máy cũ + lưu lại → sync.
|
||||
|
||||
### [2026-08-06 05:30] Task: Incognito instrument câm — font KHÔNG còn trên server (chỉ trong IndexedDB browser thường)
|
||||
- **Báo cáo user:** load project cũ ở browser ẩn danh → instrument không có âm; browser thường → OK.
|
||||
- **Chẩn đoán:** soundfont (SGM-V2.01, latin hand perc) load từ IndexedDB cache → incognito cache RỖNG → fetch `/api/v1/plugins/soundfonts/download/{sfId}` → **404 — font KHÔNG còn trên server** (upload dir chỉ còn weedsgm3/518e850f; static/soundfonts rỗng; SYSTEM_SF_DIR không tồn tại). Browser thường: IndexedDB đã cache (từ lúc font từng tồn tại server) → không fetch → OK.
|
||||
- **FIX:**
|
||||
(1) Backend `app/api/v1/plugins.py` download endpoint: thêm `static/soundfonts` vào danh sách thư mục tìm (font bundled).
|
||||
(2) Frontend `soundfontPlayer.js` loadSoundFont: fetch API download fail → **fallback `/soundfonts/{sfId}`** (route tĩnh).
|
||||
- **ĐIỀU KIỆN ĐỦ:** font PHẢI tồn tại trên server — user cần đặt file `SGM-V2.01.sf2/.sf3` + `latin hand perc.sf2/.sf3` vào `app/storage/soundfonts/` (hoặc upload qua UI) → mọi máy/browser fetch được.
|
||||
- **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `soundfontPlayer.js` (?v=202608060530 — hard refresh), `wiki.md`. Backend cần restart.
|
||||
- **Ghi chú/Test:** đặt font vào storage/soundfonts → restart backend → hard refresh → incognito load project → instrument có âm.
|
||||
|
||||
### [2026-08-06 06:00] Task: Incognito log xác nhận font vẫn 404 + fix crash onMouseMove (guard clip.buffer)
|
||||
- **Log incognito (v0530):** `soundfont not loaded yet, loading: SGM-V2.01` ×17 — load thất bại liên tục = font VẪN không có trên server (404). Kèm `Uncaught TypeError: Cannot read properties of undefined (reading 'duration')` onMouseMove — guard clip.buffer bị mất theo commit user 55d3464.
|
||||
- **FIX (app.jsx):** re-apply guard `c.buffer` cho 5 chỗ `.buffer.duration` (rightEdgeClip ×2, hoveredClip, clickedClip ×2) — hết crash khi clip không có buffer (audio file load fail ở incognito).
|
||||
- **ĐIỀU KIỆN CẦN (chưa đủ — user PHẢI thực hiện):** đặt file font (SF2 — export từ cache browser thường hoặc copy từ production /opt/daw_engine/soundfonts) vào `app/storage/soundfonts/` — dev instance KHÔNG có ffmpeg → KHÔNG dùng được .sf3 (cần .sf2). Fix code endpoint + fallback đã vào (v0530) — chỉ có tác dụng khi file tồn tại server-side.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060600), `wiki.md`. Rebuild precompiled.
|
||||
|
||||
### [2026-08-06 06:30] Task: Note bị DROP khi font không tải được (incognito) → fallback oscillator + cooldown 10s
|
||||
- **Xác nhận hypothesis user:** "incognito bỏ qua bước FluidSynth WASM" — đúng cơ chế: doNote `if (finalSfId && !_sfHandleMap.has(finalSfId))` → loadSoundFont → `if (ok) doNote();` — **load FAIL (font 404) → note bị DROP âm thầm → WASM không nhận noteon → CÂM.** Incognito: cache rỗng → fetch 404; browser thường: cache có → ok.
|
||||
- **FIX (soundfontPlayer.js doNote):**
|
||||
(1) Load fail → **`_playNoteFallback` (oscillator — CÓ ÂM thay vì câm lặng)** + `_sfLoadFailAt[sfId]` timestamp.
|
||||
(2) **Cooldown 10s**: sau fail, các note tiếp theo chạy thẳng fallback (không spam fetch 404 mỗi note).
|
||||
- **Các file ảnh hưởng:** `soundfontPlayer.js` (?v=202608060630 — hard refresh, không build), `wiki.md`.
|
||||
- **Ghi chú/Test:** hard refresh → incognito play track font chưa có → NGHE ĐƯỢC fallback (beep theo pattern — không câm). Vẫn khuyến nghị đặt font thật (SGM-V2.01.sf2...) để có âm thật.
|
||||
|
||||
### [2026-08-06 07:00] Task: Bundle mới KHÔNG load ở incognito — index.html cache heuristic (thiếu Cache-Control)
|
||||
- **Báo cáo user:** re-compile + rebuild docker nhưng incognito vẫn không load bundle mới (URL cũ).
|
||||
- **Xác minh production (daw.labz.io.vn):** index.html ĐÃ serve `soundfontPlayer?v=202608060630` + `precompiled?v=202608060600` — bundle MỚI NHẤT (precompiled chứa 9 markers fix: nanOut/effMidiBypass/prevActiveTabRef). **Production ĐÚNG** — vấn đề: **incognito dùng index.html CACHED CŨ** (stamp cũ → URL bundle cũ). Server không gửi Cache-Control → browser cache heuristic → HTML cũ.
|
||||
- **FIX (app/main.py):** index.html (`/`) thêm `Cache-Control: no-cache, no-store, must-revalidate` — HTML luôn mới, bundle JS bust bằng ?v=.
|
||||
- **Các file ảnh hưởng:** `app/main.py`. Cần rebuild docker + restart.
|
||||
- **Ghi chú/Test:** sau khi deploy: incognito (đóng + mở lại tab — hoặc Ctrl+Shift+R 1 lần) → load trang → bundle mới. Verify: console thấy stamp mới.
|
||||
|
||||
### [2026-08-06 07:30] Task: PIANO ROLL TAB — Humanize + Transpose (có undo)
|
||||
- **Yêu cầu user:** cài đặt tính năng Humanize (midi note) + Transpose (chuyển giọng) trong piano roll tab.
|
||||
- **FIX (app.jsx PianoRollTabEditor):**
|
||||
(1) `applyHumanize()` — random velocity ±8% + start_beat ±0.015 beat (~12ms @120bpm), clamp 0.05-1.0/≥0 — pushToUndo trước khi đổi.
|
||||
(2) `applyTranspose(semi)` — dịch pitch ±s semitone, clamp 0-127 — pushToUndo trước khi đổi.
|
||||
(3) Toolbar: nút **🎚 Humanize** (sau nút Ghost) + nhóm **input semitone + nút Transpose** (trước nút Lưu). State `transposeSemis` local.
|
||||
(4) Cả 2 đều qua `pushToUndo(notes)` → Ctrl+Z/Ctrl+Shift+Z hoạt động (undo stack local của piano roll).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060730), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → bấm Humanize (nghe velocity/timing đổi) → Transpose +2 (nghe cao hơn 1 tone) → Ctrl+Z hoàn tác.
|
||||
|
||||
### [2026-08-06 08:00] Task: Humanize có cường độ (Nhẹ/Vừa/Mạnh) + Transpose theo SCALE (12 tông major/minor)
|
||||
- **Yêu cầu user:** tùy chỉnh lượng humanize (mạnh/nhẹ) + transpose theo scale major/minor đủ 12 tông.
|
||||
- **FIX (app.jsx PianoRollTabEditor):**
|
||||
(1) Humanize: `humanizeStrength` state (0.05 Nhẹ / 0.10 Vừa / 0.18 Mạnh) + select trong toolbar — velocity ±strength, timing ±strength*0.15 beat.
|
||||
(2) Transpose theo SCALE: `SCALE_PATTERNS` (major [0,2,4,5,7,9,11], minor [0,2,3,5,7,8,10]) + `SCALE_ROOTS` (12 tông) + **`detectKey()` auto-detect key nguồn** (best-fit root+scale theo pitch class) + `applyTransposeToKey()` map **degree → degree** (nốt về bậc gần nhất trong scale nguồn → shift sang bậc tương ứng scale đích, ±6 clamp octave).
|
||||
(3) Toolbar: `[🎚 Humanize] [Nhẹ|Vừa|Mạnh] [semis|Transpose] [C..B][major|minor][🎵 Chuyển giọng]` — đều qua pushToUndo (Ctrl+Z hoạt động).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060800), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → piano roll → Humanize Mạnh vs Nhẹ; Chuyển giọng C major → D minor (map degree — melody giữ hình dạng) → Ctrl+Z.
|
||||
|
||||
### [2026-08-06 08:30] Task: Toolbar piano roll 2 hàng — nhóm nút chỉnh sửa note sang hàng mới
|
||||
- **Yêu cầu user:** thêm hàng toolbar mới, di chuyển nút tính năng tương tự sang hàng mới.
|
||||
- **FIX (app.jsx):** header toolbar đổi `h-10 flex items-center justify-between` → `flex flex-wrap items-center gap-x-2 gap-y-1 px-4 py-1.5` + **row-break spacer** (`flexBasis:100%, height:0`) trước nút Humanize.
|
||||
- **Hàng 1:** track select, Snap to Scale, Snap, ARM, MIDI Input, Instrument, AI bar range, CC mode/CC, Session/Isolated, Ghost.
|
||||
- **Hàng 2:** 🎚 Humanize + [Nhẹ|Vừa|Mạnh], ±semis Transpose, [C..B][major|minor] 🎵 Chuyển giọng, Lưu, Export, Đóng.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060830), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở piano roll → thấy 2 hàng toolbar; nút edit note (Humanize/Transpose/Chuyển giọng) ở hàng 2.
|
||||
|
||||
### [2026-08-06 09:30] Task: Fix emoji double-escape — nút "MIDI ghost notes" hiện text \uD83D\uDC7B
|
||||
- **Báo cáo user:** nút MIDI ghost notes hiện ký tự lạ "\uD83D\uDC7B" (text literal thay vì 👻).
|
||||
- **Nguyên nhân:** file app.jsx có `"\\uD83D\\uDC7B MIDI ghost notes"` (escape KÉP → runtime render text literal).
|
||||
- **FIX:** thay bằng emoji thật `"👻 MIDI ghost notes"`. Kiểm tra: các emoji khác (Humanize 🎚, Chuyển giọng 🎵, Session 🌐/Isolated 📋, Nhẹ/Vừa/Mạnh) đều single-escape ✓ — chỉ nút Ghost bị lỗi.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608060930), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → nút hiện "👻 MIDI ghost notes".
|
||||
|
||||
### [2026-08-06 10:00] Task: Auto-detect scale khi mở midi item → hiển thị ở dropdown chuyển giọng
|
||||
- **Yêu cầu user:** mở midi item trong piano roll → detect scale + hiển thị ở dropdown scale chuyển giọng.
|
||||
- **FIX (app.jsx):** effect trong PianoRollTabEditor — key `[st.target_id]` (item id): khi mở item → `detectKey(st.notes)` → `setKeyTargetRoot/KeyTargetScale` = giọng detected → dropdown chuyển giọng hiển thị đúng giọng của item. Sửa note cùng item KHÔNG reset (target_id không đổi); mở item khác → detect lại.
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061000), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → mở midi item → dropdown hiện giọng detected (vd D minor) → bấm Chuyển giọng sang giọng khác → Ctrl+Z.
|
||||
|
||||
### [2026-08-06 10:30] Task: Toolbar — Đóng sang cuối hàng TRÊN (phải), Lưu + Export MIDI cuối hàng DƯỚI (phải)
|
||||
- **Yêu cầu user:** move Lưu/Export (đổi tên → Export MIDI) cuối hàng bên phải; Đóng → cuối hàng trên bên phải.
|
||||
- **FIX (app.jsx):** nút Đóng chuyển từ cuối toolbar → SAU nút MIDI ghost notes (hàng 1) + `ml-auto` (đẩy phải); nhóm Lưu/Export giữ cuối hàng 2 + `ml-auto`; Export → **Export MIDI**. Emoji 🎵 Chuyển giọng bị patch tool double-escape → sửa bằng emoji thật (verify: 0 double-escape còn lại).
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608061030), `wiki.md`. Rebuild precompiled.
|
||||
- **Ghi chú/Test:** `npm run build` → hard refresh → hàng 1 phải có [Đóng]; hàng 2 phải: [Lưu] [Export MIDI] ở cuối bên phải.
|
||||
|
||||
Reference in New Issue
Block a user