71 Commits

Author SHA1 Message Date
3dtours 4d10b9485b FEAT: Media Explorer preview loop liên tục vùng chọn + margin 2px canvas 2026-08-03 07:56:19 +07:00
3dtours adccf6430a FIX: Media Explorer panel chọn tập tin ở thư mục nào thì di chuyển đến thư mục đó 2026-08-02 21:55:52 +07:00
3dtours 73025fc387 fix: instrument SF3/Sonatina không phát âm thanh - chuyển SF3->SF2, bỏ convert SF2->SF3, sửa converter 2026-08-02 21:44:49 +07:00
3dtours 9f762c2a78 FIX: Media Explorer panel preview nút Synth cho phép lọc nhanh nhạc cụ 2026-08-02 21:09:01 +07:00
3dtours a0b6110fd1 fix: Media Explorer canvas wheel dùng native non-passive listener 2026-08-02 20:57:05 +07:00
3dtours e88ce2e3ea FIX: Media Explorer panel preview cho phép zoom in zoom out và copy nội dung 2026-08-02 20:55:39 +07:00
3dtours 827693dfc4 FIX: Media Explorer panel hiển thị nội dung thư mục của máy client và thêm các link của các thư mục Favorited 2026-08-02 20:31:57 +07:00
3dtours d04631c7d3 feat: My Computer hiển thị cây thư mục client-side qua directory picker 2026-08-02 19:06:35 +07:00
3dtours 224fd54a56 fix: My Computer hiển thị ổ đĩa/thư mục, fallback khi client handle hết quyền 2026-08-02 18:59:35 +07:00
3dtours befc0d35fd fix: My Computer luôn hiển thị cây hệ thống client, bỏ restore folder cũ 2026-08-02 18:55:14 +07:00
3dtours 5e3a283bd9 fix: My Computer hiển thị cây hệ thống thay vì Favorites session cũ 2026-08-02 18:47:39 +07:00
3dtours 4a04bc04ef fix: My Computer client-side tự động quét cây không mở picker 2026-08-02 18:41:54 +07:00
3dtours dc384b3102 fix: My Computer auto-scan không mở picker, playhead chạy tới cuối canvas 2026-08-02 18:35:02 +07:00
3dtours 0b9079632a feat: My Computer auto-load cây + Favorites thư mục 2026-08-02 18:30:19 +07:00
3dtours 9be6991222 fix: canvas audioclip hiển thị theo tỉ lệ tempo, không giãn khung 2026-08-02 18:20:48 +07:00
3dtours 0853a13f62 feat: fix canvas audio, drag&drop Media Explorer vào timeline 2026-08-02 18:10:02 +07:00
3dtours c09f994ede feat: bars MIDI đúng, synth realtime, loop preview, icons FA, lưu folder path 2026-08-02 18:01:34 +07:00
3dtours 249e2afea2 feat: canvas scroll preview, giữ state khi toggle, restore session folder/files 2026-08-02 17:47:44 +07:00
3dtours b65188fcc8 fix: canvas MIDI lần đầu + thêm Tempo control preview 2026-08-02 17:35:36 +07:00
3dtours ba5883951d fix: MIDI auto-play đọc nhầm file cũ, canvas/audio không đồng bộ 2026-08-02 17:26:35 +07:00
3dtours 7000529d8d fix: MIDI preview dừng file cũ khi click file midi khác 2026-08-02 17:18:08 +07:00
3dtours 1de6bd6b57 feat: Media Explorer persist tree width, canvas realtime, Synth instrument preview MIDI 2026-08-02 17:08:54 +07:00
3dtours c435d00897 fix: Media Explorer preview cập nhật realtime khi chọn file 2026-08-02 16:48:11 +07:00
3dtours b075a5eca7 feat: Media Explorer resize chiều rộng cây thư mục 2026-08-02 16:40:14 +07:00
3dtours 36ee2bdf90 fix: My Computer client liệt kê folder+files, điều hướng file list đúng 2026-08-02 14:53:37 +07:00
3dtours 5423686c73 feat: My Computer mở explorer client qua File System Access API 2026-08-02 14:47:54 +07:00
3dtours 4db777eb5d feat: Media Explorer duyệt toàn bộ folder và files (tree đệ quy) 2026-08-02 14:31:42 +07:00
3dtours 1989bfb105 fix: Window Explorer liệt kê ổ đĩa thật + xử lý lỗi API 2026-08-02 14:27:03 +07:00
3dtours d1995307f5 feat: Media Explorer My Computer truy cập filesystem thật 2026-07-31 13:08:54 +07:00
3dtours f647291523 FEAT: thêm Media explorer panel 2026-07-31 13:02:38 +07:00
3dtours fdaabe3a08 feat: Media Explorer panel (F6) với mutual exclusion Mixer (F7) 2026-07-31 11:59:20 +07:00
3dtours 548ab1258f FIX: sửa lỗi play MIDI items bị cracking 2026-07-31 10:58:20 +07:00
3dtours 2c2ee59d7d fix: bump soundfontPlayer cache version 2026-07-31 10:52:35 +07:00
3dtours 7e5f406226 fix: crackling - time-based render buffer, no voice stacking, lower synth gain/verbose 2026-07-31 10:52:22 +07:00
3dtours ad5cda5a40 fix: reduce MIDI playback crackling (no per-note prog reload, underrun-proof render loop, gentle stopAll) 2026-07-31 10:49:31 +07:00
3dtours 05e6a467d8 FIX: sửa lỗi drag and drop vào track thì sẽ mở midi file ở track đó 2026-07-31 10:47:33 +07:00
3dtours 4b43f027ce fix: drop MIDI on existing track loads into that track 2026-07-31 10:44:22 +07:00
3dtours 175bce6743 FIX: sửa lỗi nhấn SOLO không play realtime 2026-07-31 10:39:01 +07:00
3dtours 3380b0e201 feat: realtime solo toggle during playback 2026-07-31 10:37:52 +07:00
3dtours 619a2484ce FIX: sửa lỗi vẫn diễn anim khi track đã bị mute 2026-07-31 10:34:48 +07:00
3dtours 24ac703d4d fix: track VU meter stays idle when track not audible (solo/mute) 2026-07-31 10:33:14 +07:00
3dtours c47eb5d718 FIX: sửa lỗi bật nút POWER FX nhưng âm thanh vẫn bị bypass 2026-07-31 10:29:36 +07:00
3dtours 3a345c3f4e fix: master strip PWR button now un-bypasses mastering when enabled 2026-07-31 10:27:16 +07:00
3dtours 86b304e173 fix: piano roll dropdown lists tracks only, not every MIDI item 2026-07-31 10:22:35 +07:00
3dtours 01d02a9d71 FIX: cho phép click và active, sửa các MIDI item trong cùng track 2026-07-31 10:21:19 +07:00
3dtours 2bf9f8bcb8 feat: click wait-to-active note switches to editing that MIDI item 2026-07-31 10:19:39 +07:00
3dtours 99a445b789 fix: selected dim note's MIDI item wins focus over opened item 2026-07-31 10:13:49 +07:00
3dtours ee39dda5b9 feat: ctrl+click dim note selects it and focuses its MIDI item 2026-07-31 10:10:32 +07:00
3dtours be8430fbd9 revert: remove left-column MIDI item buttons (ghost note click focuses item) 2026-07-31 10:07:53 +07:00
3dtours f1b00eb060 feat: all same-track MIDI items selectable to focus in piano roll left column 2026-07-31 10:06:42 +07:00
3dtours d81afba93d feat: click note focuses its MIDI item in piano roll 2026-07-31 10:02:49 +07:00
3dtours 223254eb07 feat: per-MIDI-item focus brightness in piano roll 2026-07-31 09:59:14 +07:00
3dtours 698fa818e9 fix: focused MIDI item notes brighter than siblings 2026-07-31 09:53:17 +07:00
3dtours 60d05e2b4d feat: opened MIDI item notes darker than same-track siblings 2026-07-31 09:50:54 +07:00
3dtours 0ea994fb2f FIX: active tất cả các note của các MIDI item cùng track 2026-07-31 09:48:42 +07:00
3dtours eb370319cb feat: piano roll shows all same-track MIDI items notes as active 2026-07-31 09:46:55 +07:00
3dtours 9b25766fca FIX: drag and drop vào khung track timeline thì tự động tạo track cho midi 2026-07-31 09:40:50 +07:00
3dtours dfbeb24a33 feat: drag-drop MIDI file creates new tracks 2026-07-31 09:31:10 +07:00
3dtours 3c4f106e14 FIX: lỗi khi play MIDI thì cho phép active multi track khác realtime 2026-07-31 09:26:51 +07:00
3dtours 4f5aee6830 fix: multitrack play select plays immediately in realtime 2026-07-31 09:21:39 +07:00
3dtours 42d64f93d8 feat: piano roll loads track instrument + multi-track play select 2026-07-31 08:50:14 +07:00
3dtours 6c11daccf8 FIX: lỗi khi play MIDI thì cho phép active các track khác realtime 2026-07-31 08:47:37 +07:00
3dtours 88007dce34 feat: click track name triggers realtime MIDI playback 2026-07-31 08:39:45 +07:00
3dtours 1790f743d6 fix: add 400ms decay to MIDI noteoff for natural release 2026-07-31 08:26:06 +07:00
3dtours ef6370f92e FIX: lỗi cắt MIDI note thành nhiều notes 2026-07-31 08:23:38 +07:00
3dtours 3cac617aff fix: drag-move delta relative to clicked note, not first selected 2026-07-31 08:20:15 +07:00
3dtours e1550c8ffe fix: insert noteB after noteA in split, not at end of array 2026-07-31 08:15:56 +07:00
3dtours c60803ee2a fix: bump precompiled cache version for piano roll split fix 2026-07-31 08:10:06 +07:00
3dtours 9c5507f7b2 feat: MIDI note duration inheritance & Ctrl+Shift+click split 2026-07-31 08:01:52 +07:00
3dtours 47e87c8363 FIX: lỗi load dự án và hiển thị mixer panel 2026-07-31 07:51:18 +07:00
3dtours 652fb98d2c fix: resolve 3 bugs - panText undefined, cloud project load, auto-restore error handling 2026-07-31 07:46:47 +07:00
12 changed files with 3792 additions and 251 deletions
+158
View File
@@ -0,0 +1,158 @@
import os
import platform
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse
router = APIRouter()
MEDIA_EXTS = {
".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus",
".mid", ".midi"
}
AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".flac", ".aiff", ".aif", ".m4a", ".aac", ".opus"}
MIDI_EXTS = {".mid", ".midi"}
def _safe_path(path: str) -> str:
if not path:
raise HTTPException(status_code=400, detail="Thiếu path")
if "\x00" in path:
raise HTTPException(status_code=400, detail="Path không hợp lệ")
return os.path.normpath(path)
REAL_FS_TYPES = {
"ext2", "ext3", "ext4", "xfs", "btrfs", "jfs", "reiserfs",
"ntfs", "ntfs3", "vfat", "exfat", "fat", "hfs", "hfsplus", "apfs",
"zfs", "f2fs", "iso9660", "udf", "nfs", "nfs4", "cifs", "smb3", "fuseblk",
}
PSEUDO_FS_TYPES = {
"proc", "sysfs", "devpts", "tmpfs", "devtmpfs", "overlay", "squashfs",
"cgroup", "cgroup2", "pstore", "securityfs", "debugfs", "tracefs",
"configfs", "fusectl", "hugetlbfs", "mqueue", "binfmt_misc", "nsfs",
"autofs", "ramfs", "efivarfs", "rpc_pipefs", "fuse", "fusefs",
}
@router.get("/computer")
async def list_computer_roots():
"""Liệt kê các ổ đĩa / mount point thật của máy (My Computer)."""
system = platform.system()
roots = []
if system == "Windows":
import string
for drive in string.ascii_uppercase:
root = drive + ":\\"
try:
if os.path.exists(root):
roots.append({"path": root, "name": drive + ":", "is_dir": True})
except OSError:
continue
else:
# Unix/Linux/macOS: chỉ liệt kê filesystem thật, bỏ pseudo/docker/systemd mounts
seen = set()
try:
with open("/proc/mounts", "r") as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
device, mount, fstype = parts[0], parts[1], parts[2]
if fstype in PSEUDO_FS_TYPES:
continue
if fstype not in REAL_FS_TYPES:
# giữ mount point root "/" nếu không thuộc pseudo
if mount != "/":
continue
if mount in seen:
continue
seen.add(mount)
# lọc mount point rác kiểu /run/credentials/...
if mount.startswith("/run/") or mount.startswith("/var/lib/docker"):
continue
try:
if os.path.isdir(mount):
label = mount if mount != "/" else "Root (/)"
roots.append({"path": mount, "name": label, "is_dir": True})
except OSError:
pass
except OSError:
pass
# macOS fallback: liệt kê /Volumes
if system == "Darwin":
try:
for name in sorted(os.listdir("/Volumes")):
full = os.path.join("/Volumes", name)
if os.path.isdir(full):
roots.append({"path": full, "name": name, "is_dir": True})
except OSError:
pass
if not roots:
roots = [{"path": "/", "name": "Root (/)", "is_dir": True}]
return {"system": system, "roots": roots}
@router.get("/browse")
async def browse_directory(path: str = Query(...)):
"""Liệt kê nội dung một thư mục trên máy: thư mục con + file audio/MIDI."""
resolved = _safe_path(path)
if not os.path.isdir(resolved):
raise HTTPException(status_code=404, detail="Không tìm thấy thư mục")
dirs, files = [], []
try:
entries = os.listdir(resolved)
except OSError as e:
raise HTTPException(status_code=403, detail=f"Không thể đọc thư mục: {e}")
for name in entries:
if name.startswith("."):
continue
full = os.path.join(resolved, name)
try:
if os.path.isdir(full):
dirs.append({"name": name, "path": full, "is_dir": True})
else:
ext = os.path.splitext(name)[1].lower()
try:
size = os.path.getsize(full)
except OSError:
size = 0
kind = "midi" if ext in MIDI_EXTS else ("audio" if ext in AUDIO_EXTS else "other")
files.append({
"name": name,
"path": full,
"is_dir": False,
"size_mb": round(size / (1024 * 1024), 2),
"ext": ext,
"kind": kind
})
except OSError:
continue
dirs.sort(key=lambda d: d["name"].lower())
files.sort(key=lambda f: f["name"].lower())
parent = os.path.dirname(resolved)
return {
"path": resolved,
"parent": parent if parent != resolved else None,
"dirs": dirs,
"files": files
}
@router.get("/file")
async def serve_local_file(path: str = Query(...)):
"""Phục vụ file audio/MIDI cục bộ để preview."""
resolved = _safe_path(path)
if not os.path.isfile(resolved):
raise HTTPException(status_code=404, detail="Không tìm thấy file")
ext = os.path.splitext(resolved)[1].lower()
if ext not in MEDIA_EXTS:
raise HTTPException(status_code=403, detail="Loại file không được hỗ trợ preview")
media_type = "audio/wav" if ext in AUDIO_EXTS else "audio/midi"
return FileResponse(resolved, media_type=media_type, filename=os.path.basename(resolved))
+23 -6
View File
@@ -142,12 +142,29 @@ async def download_soundfont_asset(sf_id: str):
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
if not os.path.isdir(base_dir):
continue
for ext in [".sf2", ".sf3"]:
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ext and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename=f"soundfont{ext}")
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
# samples, so any SF3 would play silence in the browser.
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
# Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
try:
from app.core.soundfont_converter import SoundFontConverter
sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
if result != full and os.path.exists(result):
return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
except Exception as e:
print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
raise HTTPException(status_code=404, detail="SoundFont asset not found")
+333 -39
View File
@@ -83,6 +83,35 @@ class SoundFontConverter:
except Exception:
return False
def _encode_sample_ogg(self, pcm: bytes, rate: int, tmp_dir: str) -> bytes:
"""Encode a single mono 16-bit PCM slice to an Ogg Vorbis stream."""
tmp_wav = os.path.join(tmp_dir, "sample_tmp.wav")
tmp_ogg = os.path.join(tmp_dir, "sample_tmp.ogg")
try:
with open(tmp_wav, "wb") as fw:
with wave.open(fw, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(pcm)
subprocess.run([
"ffmpeg", "-y", "-i", tmp_wav,
"-c:a", "libvorbis", "-q:a", "3",
"-f", "ogg", tmp_ogg
], capture_output=True, timeout=600, check=True)
with open(tmp_ogg, "rb") as fo:
return fo.read()
except Exception as e:
logger.error(f"Sample OGG encode failed: {e}")
return b""
finally:
for p in [tmp_wav, tmp_ogg]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
has_ogg = self._check_ffmpeg_ogg()
if not has_ogg:
@@ -100,7 +129,7 @@ class SoundFontConverter:
logger.warning("Not a valid SF2 file")
return False
# Find smpl chunk recursively
# Find smpl chunk (PCM sample data) recursively
smpl = _find_chunk(data, b"smpl")
if smpl is None:
logger.warning("No smpl chunk found in SF2")
@@ -113,46 +142,103 @@ class SoundFontConverter:
logger.warning("Sample data too small")
return False
tmp_wav = sf3_path + ".tmp.wav"
tmp_ogg = sf3_path + ".tmp.ogg"
# Locate shdr (sample headers) inside the pdta LIST
pdta = _find_list_of_type(data, b"pdta", 12)
if not pdta:
logger.warning("No pdta LIST found")
return False
_, _, pdta_size, pdta_data_off = pdta
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
if not shdr:
logger.warning("No shdr chunk found")
return False
_, _, shdr_size, shdr_data_off = shdr
if shdr_size <= 0 or shdr_size % 46 != 0:
logger.warning(f"Invalid shdr size {shdr_size}")
return False
n_samples = shdr_size // 46
# Locate ifil (version) inside the INFO LIST
ifil_abs = None
info = _find_list_of_type(data, b"INFO", 12)
if info:
_, _, info_size, info_data_off = info
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
if ifil:
ifil_abs = ifil[3]
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="sf3conv_")
ogg_parts = []
new_shdr = bytearray()
byte_offset = 0
ogg_bytes = 0
try:
# Write samples as WAV
with open(tmp_wav, "wb") as fw:
with wave.open(fw, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(sample_data)
for i in range(n_samples):
base = shdr_data_off + i * 46
# shdr layout: name[20] | start(u32) end(u32) loopstart(u32) loopend(u32) samplerate(i32) ...
start, end, startloop, endloop, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
ogg_stream = b""
if end >= start and start * 2 < len(sample_data):
pcm = sample_data[start * 2:(end + 1) * 2]
if len(pcm) >= 4:
safe_rate = rate if 1000 < rate < 192000 else 44100
ogg_stream = self._encode_sample_ogg(pcm, safe_rate, tmp_dir)
# SF3: start/end are byte offsets into the concatenated OGG stream.
# FluidSynth treats shdr `end` as EXCLUSIVE (reads [start..end-1]),
# so end = start + ogg length.
new_start = byte_offset
new_end = byte_offset + len(ogg_stream)
# OGG loop pointers are relative to the individual decompressed sample
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
new_stype = sampletype | 0x20
new_shdr += data[base:base + 20] # sample name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
new_shdr += struct.pack("<H", new_stype)
ogg_parts.append(ogg_stream)
ogg_bytes += len(ogg_stream)
byte_offset = new_start + len(ogg_stream)
if len(ogg_stream) % 2 == 1:
ogg_parts.append(b"\x00")
byte_offset += 1
# Compress to Ogg Vorbis
subprocess.run([
"ffmpeg", "-y", "-i", tmp_wav,
"-c:a", "libvorbis", "-q:a", "3",
"-f", "ogg", tmp_ogg
], capture_output=True, timeout=600, check=True)
if not ogg_parts:
logger.warning("No samples to encode")
return False
with open(tmp_ogg, "rb") as fo:
ogg_data = fo.read()
compression = (1 - len(ogg_data) / max(len(sample_data), 1)) * 100
logger.info(f"Compressed {len(sample_data)} -> {len(ogg_data)} bytes ({compression:.0f}%)")
ogg_padded = ogg_data if len(ogg_data) % 2 == 0 else ogg_data + b"\x00"
new_smpl_size = len(ogg_data)
ogg_padded = b"".join(ogg_parts)
if len(ogg_padded) % 2 == 1:
ogg_padded += b"\x00"
new_smpl_size = len(ogg_padded)
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
new_padded = len(ogg_padded)
delta = new_padded - old_padded
delta = len(ogg_padded) - old_padded
# Rebuild file: replace smpl chunk and update all sizes
out = bytearray()
out.extend(data[:smpl_head_off]) # up to smpl chunk header
out.extend(data[:smpl_head_off]) # up to smpl chunk header (excl. id)
out.extend(b"smpl") # chunk id (required for valid SF3)
out.extend(struct.pack("<I", new_smpl_size)) # new smpl size
out.extend(ogg_padded) # compressed data (even-padded)
out.extend(data[smpl_head_off + 8 + old_padded:]) # rest of file
out.extend(ogg_padded) # concatenated per-sample OGG streams
rest_off = smpl_head_off + 8 + old_padded
rest = bytearray(data[rest_off:])
# Patch the shdr sample headers inside the pdta copy
shdr_in_rest = shdr_data_off - rest_off
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
logger.warning("shdr not found after smpl chunk")
return False
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
out.extend(rest)
data_out = bytes(out)
# SF3 requires version 3.0 so FluidSynth treats it as an SF3 file
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
data_out = data_out[:ifil_abs] + struct.pack("<HH", 3, 0) + data_out[ifil_abs + 4:]
# Find sdta LIST and update its size
sdta = _find_list_of_type(data_out, b"sdta", 12)
if sdta:
@@ -160,12 +246,11 @@ class SoundFontConverter:
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
# Update RIFF root size
old_riff_size = struct.unpack("<I", data_out[4:8])[0]
new_total = len(data_out) - 8
data_out = _update_size(data_out, 4, new_total)
with open(sf3_path, "wb") as fout:
fout.write(bytes(data_out))
fout.write(data_out)
# Validate: check that RIFF size matches actual size
written = os.path.getsize(sf3_path)
@@ -174,6 +259,7 @@ class SoundFontConverter:
logger.warning(f"Size mismatch: RIFF says {parsed_riff}, actual is {written - 8}")
return False
logger.info(f"Converted {n_samples} samples -> {ogg_bytes} bytes OGG ({100 * (1 - ogg_bytes / max(len(sample_data), 1)):.0f}% smaller)")
return True
except subprocess.TimeoutExpired:
@@ -185,12 +271,44 @@ class SoundFontConverter:
traceback.print_exc()
return False
finally:
for p in [tmp_wav, tmp_ogg]:
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
@staticmethod
def _sf3_plays_audio(path: str) -> bool:
"""Verify a SoundFont actually loads and renders audible audio (guards
against shipping malformed SF3 files that silently play nothing)."""
if not os.path.exists(path):
return False
try:
import fluidsynth
import numpy as np
fl = fluidsynth.Synth()
try:
h = fl.sfload(path)
if h < 0:
return False
fl.program_select(0, h, 0, 0)
fl.noteon(0, 60, 100)
frames = 8820 # 0.2s
buf = np.zeros(frames * 2, dtype=np.float32)
fluidsynth._fl.fluid_synth_write_float(
fl.synth, frames, buf.ctypes.data, 0, 1,
buf.ctypes.data + frames * 4, 0, 1
)
fl.noteoff(0, 60)
rms = float(np.sqrt(np.mean(buf ** 2)))
return rms > 1e-4
finally:
try:
if os.path.exists(p):
os.remove(p)
fl.delete()
except Exception:
pass
except Exception:
return False
@staticmethod
def _find_sf3_converter():
@@ -208,9 +326,18 @@ class SoundFontConverter:
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
# Reuse a working SF3 if it is newer than the SF2 AND actually plays
# audio. Malformed SF3s (e.g. produced by an older converter) are
# re-converted automatically instead of being shipped silently broken.
if os.path.exists(sf3_path) and os.path.getmtime(sf3_path) >= os.path.getmtime(sf2_path):
logger.info(f"SF3 already up-to-date: {sf3_path}")
return sf3_path
if self._sf3_plays_audio(sf3_path):
logger.info(f"SF3 already up-to-date: {sf3_path}")
return sf3_path
logger.warning(f"Existing SF3 does not play audio, re-converting: {sf3_path}")
try:
os.remove(sf3_path)
except Exception:
pass
converter = self._find_sf3_converter()
try:
@@ -228,9 +355,16 @@ class SoundFontConverter:
if converter == "python":
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
return sf3_path
if self._sf3_plays_audio(sf3_path):
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
return sf3_path
# Conversion produced a broken file — never ship it
logger.warning(f"Converted SF3 failed audio verification, removing: {sf3_path}")
try:
os.remove(sf3_path)
except Exception:
pass
logger.warning(f"Python converter failed for {sf2_path}, returning SF2 path")
return sf2_path
@@ -242,6 +376,165 @@ class SoundFontConverter:
logger.error(f"Error converting {sf2_path}: {e}")
return sf2_path
def sf3_to_sf2(self, sf3_path: str, sf2_path: str = None) -> str:
"""Convert an SF3 (Ogg Vorbis samples) soundfont into a playable SF2.
The client FluidSynth WASM cannot decode Ogg Vorbis/SF3 samples, so any
SF3 soundfont (converted or uploaded) plays silence. Decompressing to
SF2 makes every instrument audible again.
"""
if not os.path.exists(sf3_path):
raise FileNotFoundError(f"Source SF3 file not found: {sf3_path}")
try:
with open(sf3_path, "rb") as f:
data = f.read()
except Exception as e:
raise IOError(f"Cannot read {sf3_path}: {e}")
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"sfbk":
raise ValueError(f"Not a valid SoundFont file: {sf3_path}")
smpl = _find_chunk(data, b"smpl")
if not smpl:
raise ValueError("No smpl chunk found")
smpl_head_off, _, smpl_old_size, smpl_data_off = smpl
sample_data = data[smpl_data_off:smpl_data_off + smpl_old_size]
pdta = _find_list_of_type(data, b"pdta", 12)
if not pdta:
raise ValueError("No pdta LIST found")
_, _, pdta_size, pdta_data_off = pdta
shdr = _find_chunk_in_list(data, b"shdr", pdta_data_off, pdta_size - 4)
if not shdr:
raise ValueError("No shdr chunk found")
_, _, shdr_size, shdr_data_off = shdr
if shdr_size <= 0 or shdr_size % 46 != 0:
raise ValueError(f"Invalid shdr size {shdr_size}")
n_samples = shdr_size // 46
ifil_abs = None
info = _find_list_of_type(data, b"INFO", 12)
if info:
_, _, info_size, info_data_off = info
ifil = _find_chunk_in_list(data, b"ifil", info_data_off, info_size - 4)
if ifil:
ifil_abs = ifil[3]
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="sf2conv_")
pcm_parts = []
new_shdr = bytearray()
frame_offset = 0
total_pcm_bytes = 0
try:
for i in range(n_samples):
base = shdr_data_off + i * 46
name = data[base:base + 20]
start, end, loopstart, loopend, rate = struct.unpack("<IIIIi", data[base + 20:base + 40])
sampletype = struct.unpack("<H", data[base + 44:base + 46])[0]
frames = 0
pcm = b""
if end > start and start < len(sample_data):
# FluidSynth reads the OGG region as [start..end-1]
ogg = sample_data[start:min(end, len(sample_data))]
if len(ogg) >= 4 and ogg[:4] == b"OggS":
pcm = self._decode_sample_ogg(ogg, tmp_dir)
frames = len(pcm) // 2
new_start = frame_offset
new_end = frame_offset + frames
# SF3 loop points are relative to the decompressed sample; SF2 needs absolute
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
new_loopend = loopend + new_start if (loopstart or loopend) else 0
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
new_stype = sampletype & ~0x20
new_shdr += name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
new_shdr += data[base + 40:base + 44]
new_shdr += struct.pack("<H", new_stype)
pcm_parts.append(pcm)
frame_offset += frames
if frames:
total_pcm_bytes += frames * 2
if total_pcm_bytes == 0:
logger.warning("SF3 contained no decodable samples")
return sf3_path
new_smpl_size = total_pcm_bytes
if new_smpl_size % 2 == 1:
new_smpl_size += 1
old_padded = smpl_old_size + (1 if smpl_old_size % 2 == 1 else 0)
delta = new_smpl_size - old_padded
out = bytearray()
out.extend(data[:smpl_head_off])
out.extend(b"smpl")
out.extend(struct.pack("<I", new_smpl_size))
for part in pcm_parts:
out.extend(part)
if total_pcm_bytes % 2 == 1:
out.extend(b"\x00")
rest_off = smpl_head_off + 8 + old_padded
rest = bytearray(data[rest_off:])
shdr_in_rest = shdr_data_off - rest_off
if shdr_in_rest < 0 or shdr_in_rest + shdr_size > len(rest):
logger.warning("shdr not found after smpl chunk")
return sf3_path
rest[shdr_in_rest:shdr_in_rest + shdr_size] = new_shdr
out.extend(rest)
data_out = bytes(out)
# Back to SF2 version 2.01
if ifil_abs is not None and ifil_abs + 4 <= len(data_out):
data_out = data_out[:ifil_abs] + struct.pack("<HH", 2, 1) + data_out[ifil_abs + 4:]
sdta = _find_list_of_type(data_out, b"sdta", 12)
if sdta:
lh_off, _, lh_size, ld_off = sdta
data_out = _update_size(data_out, lh_off + 4, lh_size + delta)
data_out = _update_size(data_out, 4, len(data_out) - 8)
sf2_path = sf2_path or (os.path.splitext(sf3_path)[0] + ".sf2")
with open(sf2_path, "wb") as fout:
fout.write(data_out)
logger.info(f"Converted SF3 -> SF2: {sf2_path} ({os.path.getsize(sf2_path) / 1048576:.1f} MB)")
return sf2_path
finally:
try:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
def _decode_sample_ogg(self, ogg: bytes, tmp_dir: str) -> bytes:
"""Decode an Ogg Vorbis stream to mono 16-bit PCM; returns PCM bytes."""
tmp_ogg = os.path.join(tmp_dir, "sample.ogg")
tmp_pcm = os.path.join(tmp_dir, "sample.pcm")
try:
with open(tmp_ogg, "wb") as fo:
fo.write(ogg)
r = subprocess.run(
["ffmpeg", "-y", "-v", "error", "-i", tmp_ogg, "-f", "s16le", "-ac", "1", tmp_pcm],
capture_output=True, timeout=600)
if r.returncode != 0:
logger.warning(f"OGG decode failed: {r.stderr.decode(errors='replace')[:120]}")
return b""
with open(tmp_pcm, "rb") as fp:
return fp.read()
except Exception as e:
logger.warning(f"OGG decode error: {e}")
return b""
finally:
for p in [tmp_ogg, tmp_pcm]:
try:
if os.path.exists(p):
os.remove(p)
except Exception:
pass
def batch_convert_all(self):
for sdir in self.target_dirs:
if not os.path.isdir(sdir):
@@ -249,3 +542,4 @@ class SoundFontConverter:
for fname in sorted(os.listdir(sdir)):
if fname.lower().endswith(".sf2"):
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
+7 -8
View File
@@ -1,4 +1,4 @@
import os, threading
import os
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
@@ -14,8 +14,8 @@ 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.api.v1.media import router as media_router
from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
# Ensure storage directories exist
@@ -53,6 +53,7 @@ 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"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
# Seed admin user on startup
@app.on_event("startup")
@@ -61,12 +62,10 @@ async def startup_seed_admin():
@app.on_event("startup")
async def startup_convert_soundfonts():
def _run():
try:
SoundFontConverter().batch_convert_all()
except Exception as e:
print(f"[Startup] SoundFont conversion error: {e}")
threading.Thread(target=_run, daemon=True).start()
# SF2 -> SF3 conversion is disabled: the client FluidSynth WASM cannot decode
# Ogg Vorbis (SF3) samples, so converted SF3s would play silence. The download
# endpoint serves SF2 when available and converts SF3 -> SF2 on demand instead.
pass
@app.on_event("startup")
async def startup_sf_scanner():
+2477 -115
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -23,6 +23,7 @@
if (!track.midiItems || !track.midiItems.length) continue;
if (track.muted) continue;
var isSameTrack = track.id === targetTrackId;
var trackGhostNotes = [];
for (var j = 0; j < track.midiItems.length; j++) {
@@ -32,7 +33,10 @@
var itemStartBeat = item.startTime / secondsPerBeat;
var itemEndBeat = (item.startTime + item.duration) / secondsPerBeat;
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) continue;
// Same-track items always contribute notes (show whole track); cross-track only when overlapping the window
if (itemStartBeat >= windowEndBeat || itemEndBeat < windowStartBeat) {
if (!isSameTrack) continue;
}
var notes = item.notes || [];
for (var k = 0; k < notes.length; k++) {
@@ -40,7 +44,7 @@
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
if (noteAbsStart >= windowEndBeat) continue;
if (noteAbsStart >= windowEndBeat && !isSameTrack) continue;
trackGhostNotes.push({
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
@@ -48,6 +52,7 @@
relative_start_beat: noteAbsStart - windowStartBeat,
duration_beats: (note.duration_beats || 1),
velocity: note.velocity,
item_id: item.id,
original_track_name: track.name,
original_track_color: track.color || '#888888'
});
@@ -59,6 +64,7 @@
track_id: track.id,
track_name: track.name,
track_color: track.color || '#6b7280',
isSameTrack: isSameTrack,
notes: trackGhostNotes
});
}
+44 -32
View File
@@ -1,6 +1,6 @@
(function () {
const RENDER_BLOCK = 512;
const QUEUE_TARGET = 4;
const QUEUE_TARGET = 16;
let _audioCtx = null;
let _fluidModule = null;
let _synthPtr = null;
@@ -114,9 +114,9 @@
_settingsPtr = _fluidModule._new_fluid_settings();
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.sample-rate", _audioCtx.sampleRate || 44100);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 2.0);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.gain", 1.0);
_fluidModule._fluid_settings_setnum(_settingsPtr, "synth.polyphony", 256);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 1);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.verbose", 0);
_fluidModule._fluid_settings_setint(_settingsPtr, "synth.ladspa.active", 0);
_fluidModule._fluid_settings_setstr(_settingsPtr, "player.timing-source", "audio");
console.log("[SonicSF] FluidSynth settings configured");
@@ -206,16 +206,29 @@
try {
var cache = window.SonicSFStorage;
var buf = cache ? await cache.getBuffer(sfId) : null;
if (!buf) {
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url);
if (!resp.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId);
return false;
if (buf) {
var cachedOk = this._tryLoadSFL(buf, '.sf3');
if (cachedOk === -1) cachedOk = this._tryLoadSFL(buf, '.sf2');
if (cachedOk !== -1) {
_sfHandleMap.set(sfId, cachedOk);
_currentSfId = sfId;
_loadedFonts[sfId] = true;
console.log("[SonicSF] SoundFont loaded from cache:", sfId, "handle:", cachedOk);
return true;
}
buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf);
// Stale/corrupt cache (e.g. old SF3 buffers the WASM can't
// decode) — drop it and re-download from the server.
console.warn("[SonicSF] Cached SoundFont unplayable, re-downloading:", sfId);
try { await cache.saveBuffer(sfId, null); } catch (e2) {}
}
var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now();
var resp = await fetch(url);
if (!resp.ok) {
console.warn("[SonicSF] SoundFont not found:", sfId);
return false;
}
buf = await resp.arrayBuffer();
if (cache) await cache.saveBuffer(sfId, buf);
var sfHandle = this._tryLoadSFL(buf, '.sf3');
if (sfHandle === -1) {
console.warn("[SonicSF] sfload .sf3 failed, trying .sf2 for", sfId);
@@ -419,7 +432,11 @@
}
// Program change at note time, not call time — ensures correct
// instrument for each item regardless of processing order.
if (synthEngine || program !== undefined) {
// Skip if the channel already has this exact instrument (avoids
// per-note soundfont reloads that cause audible crackle/glitches).
var cachedCh = _channels[ch];
var progAlreadySet = cachedCh && cachedCh.program === finalProg && cachedCh.bank === finalBank && cachedCh.sfId === finalSfId;
if ((synthEngine || program !== undefined) && !progAlreadySet) {
var sfHandle = finalSfId ? _sfHandleMap.get(finalSfId) : undefined;
if (sfHandle !== undefined) {
try {
@@ -429,8 +446,11 @@
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, finalBank); } catch (e) {}
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, finalProg); } catch (e) {}
}
if (!_channels[ch]) _channels[ch] = {};
_channels[ch].bank = finalBank;
_channels[ch].program = finalProg;
_channels[ch].sfId = finalSfId;
}
console.log("[SonicSF] noteOn ch:", ch, "pitch:", midiPitch, "vel:", midiVel);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
if (!_activeNotes[noteMapKey]) _activeNotes[noteMapKey] = [];
@@ -446,7 +466,7 @@
if (arr.length === 0) delete _activeNotes[noteMapKey];
}
} catch (e) {}
}, durSec * 1000);
}, durationMs);
}
} catch (e) {
console.warn("[SonicSF] FluidSynth noteOn error:", e);
@@ -518,7 +538,6 @@
if (_initialized && _fluidModule) {
for (var ch = 0; ch < 16; ch++) {
try { _fluidModule._fluid_synth_all_notes_off(_synthPtr, ch); } catch (e) {}
try { _fluidModule._fluid_synth_all_sounds_off(_synthPtr, ch); } catch (e) {}
}
}
while (_scheduledNotes.length > 0) {
@@ -559,10 +578,13 @@
var leftPtr = _leftBufPtr;
var rightPtr = _rightBufPtr;
var block = RENDER_BLOCK;
var queueDepth = 0;
var maxQueue = QUEUE_TARGET;
var queueDepth = 0;
var lastTick = performance.now();
var frameMs = (block / _audioCtx.sampleRate) * 1000;
var _dbgPeak = 0;
// Track consumption by wall-clock time instead of async messages — immune
// to message-latency races that could underrun (silence gaps → crackle).
function pushFrame() {
if (!Module || !synth || !node) return;
try {
@@ -573,18 +595,6 @@
Module._fluid_synth_write_float(synth, block, leftPtr, 0, 1, rightPtr, 0, 1);
var leftArr = new Float32Array(Module.HEAPF32.subarray(lpb, lpb + block));
var rightArr = new Float32Array(Module.HEAPF32.subarray(rpb, rpb + block));
var peak = 0;
var avg = 0;
for (var si = 0; si < leftArr.length; si++) {
var abs = leftArr[si] > 0 ? leftArr[si] : -leftArr[si];
if (abs > peak) peak = abs;
avg += abs;
}
avg /= leftArr.length;
if (!_dbgPeak) {
_dbgPeak = 1;
console.log("[SonicSF] FRAME peak:", peak.toFixed(6), "avg:", avg.toFixed(8), "gain check:", Module._fluid_synth_get_gain ? Module._fluid_synth_get_gain(synth) : 'N/A');
}
node.port.postMessage({ type: 'PCM', L: leftArr, R: rightArr }, [leftArr.buffer, rightArr.buffer]);
queueDepth++;
} catch (e) { console.warn("[SonicSF] pushFrame error:", e); }
@@ -595,14 +605,16 @@
_renderTimer = null;
return;
}
var needed = maxQueue - queueDepth;
var now = performance.now();
queueDepth = Math.max(0, queueDepth - (now - lastTick) / frameMs);
lastTick = now;
var needed = Math.min(maxQueue - queueDepth, maxQueue);
for (var i = 0; i < needed; i++) {
pushFrame();
}
queueDepth = Math.max(0, queueDepth - 1);
}
_renderTimer = setInterval(fillLoop, Math.max(8, (block / _audioCtx.sampleRate) * 1000 * 0.75));
_renderTimer = setInterval(fillLoop, Math.max(4, frameMs * 0.5));
}
function _stopRenderLoop() {
@@ -30,14 +30,6 @@ class FluidSynthBridge extends AudioWorkletProcessor {
if (si >= qL[fi].length) { fi++; si = 0; }
}
if (fi > 0) { this.leftQ.splice(0, fi); this.rightQ.splice(0, fi); }
if (this.called % 50 === 0) {
var pk = 0;
for (var j = 0; j < len; j++) {
var v = out[0][j] > 0 ? out[0][j] : -out[0][j];
if (v > pk) pk = v;
}
if (pk > 0) console.log('[FluidSynth:bridge] process #' + this.called + ' peak:' + pk.toFixed(6) + ' q:' + qL.length);
}
return true;
}
}
+29 -2
View File
@@ -8,6 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
@@ -15,7 +16,7 @@
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202607311050"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
@@ -23,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=202607302132" defer></script>
<script src="/static/js/app.precompiled.js?v=202608022001" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
@@ -380,6 +381,32 @@
border-radius: 2px;
box-shadow: 0 4px 8px rgba(0,0,0,0.9), inset 0 1px 1px rgba(255,255,255,0.9);
}
/* Media Explorer: horizontal volume slider + selected file row */
input[type=range].me-fader-slider {
-webkit-appearance: none;
appearance: none;
background: #111;
height: 6px;
border-radius: 3px;
border: 1px solid #555;
cursor: pointer;
}
input[type=range].me-fader-slider::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 12px;
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
border: 1px solid #000;
border-radius: 2px;
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
cursor: pointer;
}
.file-row-selected {
background-color: #3399ff !important;
color: #ffffff !important;
}
.file-row-selected .file-icon { color: #ffffff !important; }
</style>
</head>
+349
View File
@@ -0,0 +1,349 @@
# DETAILED SPECIFICATION FOR DAW MEDIA EXPLORER PANEL INTERFACE & HTML5 SOURCE CODE
This document details the design structure, features, and executable HTML5 source code for a general-purpose Media Explorer Panel interface styled after the REAPER DAW.
---
## I. DETAILED INTERFACE REGIONS
The interface is divided vertically into 3 primary functional regions, arranged from top to bottom:
### 1. Top Navigation Toolbar
* **Left Navigation Button Group:** **←** (Back), **→** (Forward), **↑** (Up to Parent Directory), **↻** (Refresh).
* **Directory Address Bar:** Displays the current folder path with a drop-down menu for quick-access path selection.
* **Filter / Search Box:** Input field supporting quick file filtering and keyword searching.
* **View Mode Button:** Toggle button for switching file display layouts (*Details* / *List* view).
### 2. Middle Split Panel (Directory Tree + File List)
* **Left Directory Tree View:**
* Hierarchical tree structure displaying project directories, system shortcuts, drives, and audio sample library folders.
* Supports expand/collapse toggle buttons and highlighted background states indicating the actively selected folder.
* **Right File List Table:**
* Displays audio and MIDI files with standard category icons.
* Highlights the currently selected file with a prominent blue row background.
### 3. Bottom Preview & Transport Panel
* **Transport Control Bar & Playback Parameters:**
* **Transport Buttons:** **■** (Stop), **▶** (Play), **❚❚** (Pause), **↻** (Loop/Repeat), **⚡ Auto-Play** (Automatically previews files upon selection).
* **Parameter Controls:** Pitch adjustment (Pitch matching), Rate (Playback speed factor), Volume Slider (Gain adjustment in dB).
* **Media Type Badge:** Label displaying file format classification (*MIDI* / *Audio*).
* **Visualizer Canvas Display & Metadata:**
* **Visualizer Canvas (Left):** Dark display area rendering an overview Piano Roll note grid (for MIDI files) or waveform display (for Audio files), integrated with a timeline/bar ruler and white Playhead cursor.
* **Metadata Text Box (Right):** Information pane displaying detailed file parameters (MIDI event count, duration, sample rate / resolution).
* **Footer Status Bar:**
* Displays current playback timestamp vs. total file length.
* Name of the currently selected/playing file.
* Tempo metadata (BPM) and playback rate scale factor.
---
## II. EXECUTABLE HTML5 & TAILWIND CSS SOURCE CODE
Below is the complete HTML5 source code integrating Web Audio API synthesis, a Canvas visualizer, and real-time interactive file selection and audio preview playback:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DAW Media Explorer Panel Component</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; user-select: none; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
/* REAPER Classic Panel Style */
.reaper-panel {
background: #c0c0c0;
border: 2px solid #ffffff;
border-right-color: #808080;
border-bottom-color: #808080;
}
.reaper-inset {
background: #ffffff;
border: 1px solid #808080;
box-shadow: inset 1px 1px 2px rgba(0,0,0,0.3);
}
.reaper-dark-inset {
background: #181818;
border: 1px solid #3a3a3a;
box-shadow: inset 1px 1px 3px rgba(0,0,0,0.8);
}
.file-row-selected {
background-color: #3399ff !important;
color: #ffffff !important;
}
.file-row-selected .file-icon { color: #ffffff !important; }
/* Scrollbar */
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: #e0e0e0; border-left: 1px solid #a0a0a0; }
::-webkit-scrollbar-thumb { background: #b0b0b0; border: 1px solid #ffffff; border-right-color: #707070; border-bottom-color: #707070; }
::-webkit-scrollbar-thumb:hover { background: #909090; }
/* Volume Slider */
input[type=range].fader-slider {
appearance: none; -webkit-appearance: none;
background: #111; height: 6px; border-radius: 3px; border: 1px solid #555;
}
input[type=range].fader-slider::-webkit-slider-thumb {
-webkit-appearance: none; height: 16px; width: 12px;
background: linear-gradient(180deg, #e2e8f0 0%, #64748b 50%, #1e293b 100%);
border: 1px solid #000; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,0.5); cursor: pointer;
}
</style>
</head>
<body class="bg-slate-950 text-slate-900 h-screen flex items-center justify-center p-2 overflow-hidden">
<!-- MEDIA EXPLORER PANEL CONTAINER -->
<div class="w-full max-w-4xl h-[420px] flex flex-col reaper-panel text-slate-900 overflow-hidden shadow-2xl relative">
<!-- 1. TOP NAVIGATION TOOLBAR -->
<div class="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
<div class="flex items-center gap-1 flex-1 mr-2">
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Back">
<i class="fa-solid fa-arrow-left"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Forward">
<i class="fa-solid fa-arrow-right"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Up Directory">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="w-5 h-5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-[10px]" title="Refresh">
<i class="fa-solid fa-rotate-right"></i>
</button>
<div class="flex-1 flex items-center reaper-inset h-5 px-1 bg-white">
<i class="fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"></i>
<input type="text" id="addressBarInput" value="Root:\Media Library" class="w-full text-xs outline-none bg-transparent font-sans text-slate-800" readonly>
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center reaper-inset h-5 px-1 bg-white w-40">
<input type="text" placeholder="Filter/Search..." class="w-full text-xs outline-none bg-transparent font-sans text-slate-800">
<i class="fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"></i>
</div>
<button class="px-2 py-0.5 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold">
<span>Details</span> <i class="fa-solid fa-caret-down text-[9px]"></i>
</button>
</div>
</div>
<!-- 2. MIDDLE SPLIT VIEW (DIRECTORY TREE + FILE LIST) -->
<div class="flex-1 flex overflow-hidden">
<!-- DIRECTORY TREE (LEFT) -->
<div class="w-60 reaper-inset m-1 mr-0 overflow-y-auto p-1 text-xs select-none bg-white">
<div class="space-y-0.5 font-sans">
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> &lt;Track Templates&gt;</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><span class="w-3"></span> &lt;Project Directory&gt;</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Computer</div>
<!-- Selected Folder -->
<div id="folderMidi" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer font-semibold text-slate-900 bg-slate-300 rounded-sm">
<i class="fa-solid fa-minus text-[9px] text-slate-600"></i>
<i class="fa-solid fa-folder-open text-[#d9a752]"></i> Media Library
</div>
<div class="pl-3 space-y-0.5">
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 cursor-pointer text-slate-800">
<i class="fa-solid fa-plus text-[9px] text-slate-500"></i> <i class="fa-solid fa-folder text-[#d9a752]"></i> Sound Effects
</div>
<div id="folderDigitalJuice" class="flex items-center gap-1 px-1 py-0.5 hover:bg-blue-100 cursor-pointer text-slate-800 pl-4">
<i class="fa-solid fa-folder text-[#d9a752]"></i> MIDI Collections
</div>
</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> Desktop</div>
<div class="flex items-center gap-1 px-1 py-0.5 hover:bg-slate-200 text-slate-700"><i class="fa-solid fa-plus text-[9px] text-slate-500"></i> My Documents</div>
</div>
</div>
<!-- FILE LIST TABLE (RIGHT) -->
<div class="flex-1 reaper-inset m-1 overflow-y-auto relative bg-white">
<table class="w-full text-xs text-left border-collapse">
<thead class="sticky top-0 bg-[#e0e0e0] border-b border-[#a0a0a0] text-slate-800 font-semibold select-none shadow-sm z-10">
<tr><th class="py-1 px-2 border-r border-[#b0b0b0]">File</th></tr>
</thead>
<tbody id="fileListTbody" class="font-sans text-slate-800">
<!-- Dynamic File Rows -->
</tbody>
</table>
</div>
</div>
<!-- 3. BOTTOM PREVIEW CONTROL BAR & VISUALIZER CANVAS -->
<div class="h-32 bg-[#d4d0c8] border-t border-[#808080] p-1.5 flex flex-col justify-between text-xs shrink-0 select-none">
<!-- CONTROLS ROW -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1">
<button id="btnStop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-800"><i class="fa-solid fa-square text-[10px]"></i></button>
<button id="btnPlay" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-emerald-700 font-bold"><i class="fa-solid fa-play text-xs"></i></button>
<button id="btnPause" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-amber-700"><i class="fa-solid fa-pause text-xs"></i></button>
<button id="btnLoop" class="w-6 h-6 bg-[#e0e0e0] hover:bg-[#ffffff] border border-[#707070] rounded-sm flex items-center justify-center text-slate-700"><i class="fa-solid fa-rotate-right text-xs"></i></button>
<button id="btnAutoPlay" class="h-6 px-2 bg-gradient-to-r from-cyan-600 to-emerald-600 text-white border border-slate-700 rounded-sm font-bold text-[10px] flex items-center gap-1 shadow-sm">
<i class="fa-solid fa-bolt text-[9px]"></i> <span>Auto-Play</span>
</button>
</div>
<div class="flex items-center gap-3 font-mono text-[11px]">
<div class="flex items-center gap-1">
<span>Pitch:</span>
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-14">
<input type="number" value="0.00" step="0.5" class="w-full text-xs text-right outline-none bg-transparent">
</div>
</div>
<div class="flex items-center gap-1">
<span>Rate:</span>
<div class="reaper-inset px-1 bg-white h-5 flex items-center w-12">
<input type="number" value="1.0" step="0.1" class="w-full text-xs text-right outline-none bg-transparent">
</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-sans text-slate-700">Volume:</span>
<input id="volSlider" type="range" min="-60" max="12" step="0.5" value="0" class="fader-slider w-28">
<div class="reaper-inset px-1 bg-white h-5 flex items-center justify-center w-14 font-mono text-[11px]">
<span id="volDbText">0.00 dB</span>
</div>
</div>
<div id="badgeType" class="px-2 py-0.5 bg-purple-950 text-purple-300 border border-purple-800 font-mono font-bold text-[10px] rounded-sm">
MIDI
</div>
</div>
<!-- CANVAS & METADATA ROW -->
<div class="flex items-stretch gap-2 my-1 h-16">
<div class="flex-1 reaper-dark-inset relative overflow-hidden">
<canvas id="previewCanvas" class="w-full h-full block cursor-pointer"></canvas>
</div>
<div class="w-56 reaper-dark-inset p-1.5 font-mono text-[10px] text-slate-300 leading-tight overflow-y-auto">
<div id="metadataContent">
76 MIDI events<br>
Length: 16 quarter notes<br>
Length: 0:08.000 (est)<br>
Ticks per quarter note: 480
</div>
</div>
</div>
<!-- BOTTOM STATUS BAR -->
<div class="h-5 bg-[#c0c0c0] border-t border-[#ffffff] flex items-center justify-between text-[11px] font-mono px-1">
<div class="flex items-center gap-3">
<div id="timeRange" class="reaper-inset px-1.5 bg-white text-slate-900 font-bold">0.0000 / 16.0000</div>
<div class="reaper-inset px-1.5 bg-white text-slate-900">0.0000</div>
<div class="reaper-inset px-1.5 bg-white text-slate-900">16.0000</div>
</div>
<div id="statusFileName" class="text-slate-800 font-bold truncate max-w-xs">File_Selected.mid</div>
<div id="statusBpm" class="text-slate-700">130 bpm x0.923</div>
</div>
</div>
</div>
<!-- SCRIPT LOGIC -->
<script>
const sampleDb = {
midi: [
{ name: "MIDI_Loop_01.mid", events: 95, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_02_Bass.mid", events: 48, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_03_Lead.mid", events: 110, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_04.mid", events: 76, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_05_Bass.mid", events: 52, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 },
{ name: "MIDI_Loop_06.mid", events: 88, lengthQn: 16, time: "0:08.000", tpqn: 480, bpm: 130 }
]
};
let selectedFile = sampleDb.midi[3];
const fileListTbody = document.getElementById('fileListTbody');
const previewCanvas = document.getElementById('previewCanvas');
const canvasCtx = previewCanvas.getContext('2d');
function renderFiles() {
fileListTbody.innerHTML = '';
sampleDb.midi.forEach((f) => {
const tr = document.createElement('tr');
tr.className = `cursor-pointer hover:bg-blue-100 ${f.name === selectedFile.name ? 'file-row-selected' : ''}`;
tr.innerHTML = `<td class="py-1 px-2 flex items-center gap-2"><i class="fa-solid fa-music text-purple-600 file-icon"></i><span>${f.name}</span></td>`;
tr.onclick = () => {
selectedFile = f;
renderFiles();
updatePreview();
};
fileListTbody.appendChild(tr);
});
}
function updatePreview() {
document.getElementById('statusFileName').innerText = selectedFile.name;
document.getElementById('metadataContent').innerHTML = `${selectedFile.events} MIDI events<br>Length: ${selectedFile.lengthQn} quarter notes<br>Length: ${selectedFile.time} (est)<br>Ticks per quarter note: ${selectedFile.tpqn}`;
renderCanvas();
}
function renderCanvas() {
previewCanvas.width = previewCanvas.clientWidth;
previewCanvas.height = previewCanvas.clientHeight;
const w = previewCanvas.width, h = previewCanvas.height;
canvasCtx.clearRect(0, 0, w, h);
// Grid
canvasCtx.strokeStyle = '#222';
for (let y = 0; y < h - 14; y += 8) {
canvasCtx.beginPath(); canvasCtx.moveTo(0, y); canvasCtx.lineTo(w, y); canvasCtx.stroke();
}
canvasCtx.strokeStyle = '#333';
for (let b = 0; b <= 16; b += 4) {
const x = (b / 16) * w;
canvasCtx.beginPath(); canvasCtx.moveTo(x, 0); canvasCtx.lineTo(x, h - 14); canvasCtx.stroke();
}
// Notes
canvasCtx.fillStyle = '#9ca3af';
for (let i = 0; i < selectedFile.events; i++) {
const noteX = ((i * 17 + 76) % 95) / 100 * w;
const noteY = ((i * 13 + 76) % (h - 24)) + 4;
canvasCtx.fillRect(noteX, noteY, Math.max(8, (i % 5 + 1) * 12), 3);
}
// Bar Ruler
canvasCtx.fillStyle = '#111';
canvasCtx.fillRect(0, h - 14, w, 14);
canvasCtx.fillStyle = '#888';
canvasCtx.font = '9px JetBrains Mono';
for (let b = 0; b <= 12; b += 4) {
canvasCtx.fillText(b.toString(), (b / 16) * w + 2, h - 3);
}
}
window.onload = () => { renderFiles(); updatePreview(); };
</script>
</body>
</html>
```
+257
View File
@@ -937,3 +937,260 @@
- **Tóm tắt thay đổi:** Thay vì mở modal standalone trong iframe generator, "Lưu thành Preset" giờ postMessage `GENERATOR_PRESET_DATA` lên parent window. React AIPresetModal thêm `message` listener nhận data, auto-populate form (name, category, keywords, bars, bpm, scale, template), set editingPreset='new' và đóng generator modal. Nút "Tạo preset có cấu trúc" trong React form vẫn hoạt động độc lập.
- **Các file ảnh hưởng:** `md/49_AI_PROMPT_GENERATOR.md`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Flow: generator iframe → Lưu thành Preset → form React tự điền → generator modal đóng.
### [2026-07-31 07:37] Task: Fix 3 bugs - panText, cloud project load, auto-restore
- **Tóm tắt thay đổi:**
1. Thêm `const [panText, setPanText] = React.useState('center')` vào MasterStripConsole để fix `Uncaught ReferenceError: panText is not defined`.
2. Fix `restoreLastSessionProject`: xóa bỏ `localStorage.removeItem('sonic_project_id')` trong catch block để không xóa project ID khi API lỗi tạm thời → auto-load trên page reload hoạt động lại.
3. Thêm `.catch()` vào `loadAudioBuffersForTracks` trong `handleOpenProject``restoreLastSessionProject` để tránh unhandled promise rejection gây gián đoạn load project.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Cần reload page và test Open Project + Mixer Panel sau fix.
### [2026-07-31 08:01] Task: Fix MIDI note creation duration & add Ctrl+Shift+Click split
- **Tóm tắt thay đổi:**
1. Thêm `lastNoteDurationRef` để ghi nhớ duration của note vừa tạo → click tạo note mới dùng duration đó thay vì snap duration mặc định.
2. Ctrl+Shift+Click vào giữa MIDI note → tách note đó thành 2 notes tại vị trí click. Ctrl+Shift+Click vào vùng trống → nhân bản các note đang chọn (giữ hành vi cũ).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Kiểm tra: 1) tạo note → thay đổi duration → tạo note mới → duration được kế thừa. 2) Ctrl+Shift+Click giữa note → note bị cắt thành 2.
### [2026-07-31 08:09] Task: Bump precompiled cache version for piano roll split fix
- **Tóm tắt thay đổi:** Cập nhật cache-busting parameter `app.precompiled.js?v=202607310809` để trình duyệt load bản JS mới nhất có fix Ctrl+Shift+Click split MIDI note.
- **Các file ảnh hưởng:** `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Hard reload (Ctrl+F5) để lấy bản JS mới.
### [2026-07-31 08:40] Task: Click track name triggers realtime MIDI playback
- **Tóm tắt thay đổi:** Click vào track name ở cột trái → tự động play ngay MIDI notes của track đó (nếu có piano roll tab). Dùng `schedulePianoRollMidi(prTab, 0)` + `startSubTabPlayback(prTab, 0)` + `stopAllPlayback()`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload để load JS mới. Click track có MIDI notes → nghe thấy phát ngay.
### [2026-07-31 08:50] Task: Piano roll instrument load + multitrack play select
- **Tóm tắt thay đổi:**
1. `handleEditMidiInTab`: khi mở piano roll tab bằng double-click MIDI item → lưu `instrumentId`, `synth_engine` vào tab, fallback `instrumentProgram` từ `synth_engine.soundfont_program`, gọi `SonicSF.selectInstrument` để load nhạc cụ của track.
2. Đổi `activePlayTrackIds` từ single value sang mảng → click nhiều nút tên track ở cột trái piano roll để active nhiều track cùng lúc play multitrack.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Test: double-click MIDI item có nhạc cụ → play đúng nhạc cụ. Click nhiều track name → play multitrack.
### [2026-07-31 09:20] Task: Multitrack play select plays immediately
- **Tóm tắt thay đổi:** Thêm `handlePianoRollRealtimePlay(trackIds)` ở App: khi click nút track name trong piano roll → tính ngay ghost layers cho các track được chọn → `stopAllPlayback()` + `SonicSF.stopAll()``schedulePianoRollMidi(playTab, offset)` → play ngay realtime không cần bấm play.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click track button → nghe ngay. Click thêm/bớt track → re-schedule lại tức thì.
### [2026-07-31 09:32] Task: Drag-drop MIDI file creates new tracks
- **Tóm tắt thay đổi:** Thêm `handleDropMidiToNewTracks(file)` — drop file .mid/.midi vào vùng track container → parse MIDI → tạo track mới cho từng MIDI track và append MIDI item. Container có `onDrop` xử lý MIDI (hoặc audio → load vào selected track); lane drop cũng route MIDI sang hàm mới.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Kéo file .mid vào vùng track → track mới xuất hiện với MIDI.
### [2026-07-31 09:48] Task: Piano roll shows all same-track MIDI items notes as active
- **Tóm tắt thay đổi:** `ghostNoteExtractor.js`: bỏ filter time-overlap cho các MIDI items cùng track (`isSameTrack`) → tất cả notes của mọi MIDI item trong cùng track đều được gộp vào ghost layer. Render: same-track layers vẽ nổi bật (amber, alpha 0.65 + border) = trạng thái active. Effect sync: same-track layers luôn được đưa vào `ghostPlayLayers` → play đủ cả track; cross-track vẫn theo `activePlayTrackIds`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/static/js/services/ghostNoteExtractor.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item của track → thấy tất cả notes của các MIDI item cùng track hiển thị amber nổi bật; play → nghe đủ track.
### [2026-07-31 09:52] Task: Opened MIDI item notes darker than same-track siblings
- **Tóm tắt thay đổi:** Render piano roll: notes của MIDI item đang mở (main layer) đậm hơn — fill `rgba(234,179,8,0.5)`, viền `#f59e0b` (chọn: xanh `rgba(59,130,246,0.5)`). Ghost notes của các MIDI items cùng track nhạt hơn (alpha 0.35, fill amber 0.35) → phân biệt item đang active vs tham chiếu.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item → notes item đó đậm, notes item khác cùng track mờ hơn.
### [2026-07-31 09:55] Task: Focused MIDI item notes brighter
- **Tóm tắt thay đổi:** Notes của MIDI item được focus (đang mở / được chọn / đang play) sáng hơn: fill `rgba(253,224,71,0.6)`, viền `#fde047`; khi đang play sáng hơn nữa `rgba(254,240,138,0.75)` viền `#fef08a`; chọn: xanh `rgba(96,165,250,0.6)`. Ghost siblings cùng track vẫn mờ (alpha 0.35).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở MIDI item → notes sáng; bấm play → sáng hơn; item khác cùng track mờ hơn.
### [2026-07-31 10:02] Task: Per-MIDI-item focus brightness in piano roll
- **Tóm tắt thay đổi:** Mỗi ghost note có `item_id` (ghostNoteExtractor.js). Render piano roll tính `focusedItemId`: selection → item đang mở; đang play → item chứa playhead (`st.currentTime` trong khoảng `startTime..startTime+duration`). Notes của item được focus vẽ sáng (fill vàng `0.6`/`0.7` + viền `#fde047`, đang play `0.75`), notes của các item khác cùng track mờ (alpha 0.3, fill tối `#7a6a10`). Cross-track giữ ghost 0.25.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/static/js/services/ghostNoteExtractor.js`
- **Ghi chú/Test (nếu có):** Hard reload. Mở track 3 MIDI items → play: playhead chạy đến item nào item đó sáng, các item khác mờ.
### [2026-07-31 10:05] Task: Click note focuses its MIDI item
- **Tóm tắt thay đổi:** Thêm state `focusItemId` trong PianoRollTabEditor. Click vào note chính → focus item đang mở (`st.target_id`). Click vào ghost note (note của MIDI item khác cùng track) → focus chuyển sang item đó (không vẽ note mới). Reset focus khi đổi `st.target_id`. Ưu tiên focus: đang play → item theo playhead; có note chọn → item đang mở; còn lại → `focusItemId` (theo click).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click note của item 2 (ghost) → item 2 sáng, item 1&3 mờ.
### [2026-07-31 10:10] Task: All same-track MIDI items selectable to focus
- **Tóm tắt thay đổi:** Cột trái PIANO ROLL: dưới nút tên track (đang mở) hiển thị các nút con cho từng MIDI item trong track đó. Click nút MIDI item → `setFocusItemId(im.id)` → item đó sáng, các item khác cùng track mờ. Nút item đang focus: vàng đậm; item đang mở: viền vàng; còn lại: xám.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click nút MIDI item bất kỳ trong track → focus chuyển sang item đó.
### [2026-07-31 10:10] Task: Revert left-column MIDI item buttons
- **Tóm tắt thay đổi:** Bỏ các nút MIDI items con ở cột trái (không cần). Giữ cơ chế hiện có: các MIDI items cùng track hiển thị sáng/ít sáng theo focus; click vào note mờ (ít sáng) của MIDI item khác → focus chuyển sang item đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload.
### [2026-07-31 10:15] Task: Ctrl+click dim note selects + focuses item
- **Tóm tắt thay đổi:** Trong handleGridMouseDown (ctrl+click, không bấm vào note chính): hit-test trên same-track ghost notes → nếu trúng note mờ → toggle chọn note đó (thêm vào `selectedNoteIds`, vẽ xanh sáng + viền) và `setFocusItemId(item_id)` → active MIDI item chứa note. Empty space vẫn mở marquee như cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Ctrl+click note mờ của MIDI item khác → note chuyển xanh + item đó sáng lên.
### [2026-07-31 10:13] Task: Selected dim note's MIDI item wins focus
- **Tóm tắt thay đổi:** Fix focus resolution khi có selection: duyệt `selectedNoteIds` từ cuối (note chọn gần nhất) — main note → item đang mở; dim note (same-track) → `item_id` của nó. Ưu tiên: selection → playhead → `focusItemId`. GHOST NOTE chỉ là note của MIDI items track khác (alpha 0.25).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Ctrl+click note mờ item 2 → item 2 sáng dù item 1 đang mở.
### [2026-07-31 10:19] Task: Click wait-to-active note switches to editing that item
- **Tóm tắt thay đổi:** Click thường vào note mờ (wait-to-active) của MIDI item khác cùng track → `setFocusItemId` + `handleSwitchMidiItem(item_id)` → item đó trở thành target đang mở (editable), notes load vào editor, các item khác thành mờ. Ctrl+click vẫn giữ select+focus (không switch).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Click note mờ item 2 → item 2 mở ra để chỉnh sửa notes.
### [2026-07-31 10:22] Task: Piano roll dropdown lists tracks only
- **Tóm tắt thay đổi:** Dropdown trên toolbar PIANO ROLL: thay vì liệt kê tất cả MIDI items (`allMidiItems`), chỉ liệt kê các track có MIDI items (dedupe theo track id). Chọn track → `handleSwitchMidiItem` sang MIDI item đầu tiên của track đó. `value` = `st.trackId`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Dropdown chỉ hiện tên các track.
### [2026-07-31 10:26] Task: Fix master strip PWR mastering bypass
- **Tóm tắt thay đổi:** Nút PWR (Bật/Tắt MASTERING PANEL Bypass) ở Master strip: khi bật ON trước đây set `isBypassed: true``toggleMasteringOnMaster(true, true)` giữ routing bypass → âm thanh vẫn bypass. Fix: toggle luôn set `isBypassed: false` + flip `masterConnected` → bật lên là mastering active (EQ→Imager→Maximizer), tắt là disconnect.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Bật PWR → âm thanh qua mastering (không còn bypass); tắt → bypass/disconnect.
### [2026-07-31 10:32] Task: Track VU meter idle when not audible
- **Tóm tắt thay đổi:** Track VU meter loop (line ~18446): tính `isAudible` theo solo/mute (`anySolo ? track.solo : !track.muted`) — nếu track không phát ra âm thanh thực (muted hoặc bị solo out) thì gán `audioPeak=0` và bỏ qua `midiVuActivityRef` flash → VU meter đứng yên thay vì animation giả khi solo track khác.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Solo track 1 → track 2 (MIDI) VU không còn animation.
### [2026-07-31 10:35] Task: Realtime solo toggle during playback
- **Tóm tắt thay đổi:** `toggleTrackSoloEvaluate`: khi toggle solo trong lúc play → stop rồi restart đúng: main timeline gọi `startTrackPlayback(currentTime)` (đã đổi sang đọc `activeTracksRef.current` để nhận solo state mới); piano roll subtab đang play → `schedulePianoRollMidi` + `startSubTabPlayback`. Trước đây restart thiếu lệnh start nên solo không áp dụng realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Bấm SOLO track trong lúc play → chỉ track đó còn nghe, track khác dừng ngay.
### [2026-07-31 10:44] Task: Drop MIDI on existing track loads into that track
- **Tóm tắt thay đổi:** Lane onDrop: drop MIDI/audio lên track có sẵn → `loadFileOnTrack(track.id, f)` (MIDI mở ở track đó; multi-track MIDI: track đầu vào track drop, còn lại tạo track mới). Drop ở vùng trống container vẫn tạo track mới (`handleDropMidiToNewTracks`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Hard reload. Kéo file .mid lên track hiện có → MIDI nạp vào track đó.
### [2026-07-31 10:47] Task: Fix MIDI playback crackling
- **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) skip program change nếu channel đã có đúng instrument (`_channels[ch]` cache) — trước đây mỗi note đều reload soundfont → glitch/crackle; (2) bỏ `console.log` per-note; (3) render loop: worklet gửi `CONSUMED` feedback để `queueDepth` chính xác, `QUEUE_TARGET` 4→8, interval nhanh hơn → hết underrun (silence gap); (4) `stopAll` chỉ dùng `all_notes_off` (release) thay vì `all_sounds_off` (kill tức thì) → hết pop khi stop. `fluidsynth-bridge.js`: post `CONSUMED` sau splice.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js`
- **Ghi chú/Test (nếu có):** Hard reload. Play MIDI note → nghe mượt, hết lụp bụp.
### [2026-07-31 10:50] Task: Fix MIDI crackling v2
- **Tóm tắt thay đổi:** `soundfontPlayer.js`: (1) render loop dùng wall-clock time-based queue accounting thay vì async CONSUMED message (hết race gây underrun); (2) `QUEUE_TARGET` 8→16 (~170ms buffer chống jank main thread); (3) bỏ 400ms release hack → `noteoff` đúng duration (FluidSynth tự release mượt, hết chồng voice); (4) `synth.verbose=0` + `synth.gain=1.0`. `fluidsynth-bridge.js`: bỏ post CONSUMED không còn dùng.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/static/js/worklets/fluidsynth-bridge.js`
- **Ghi chú/Test (nếu có):** Hard reload. Play MIDI với instrument → hết lụp bụp.
### [2026-07-31 11:25] Task: Media Explorer panel (F6) + mutual exclusion with Mixer (F7)
- **Tóm tắt thay đổi:** Tạo `MediaExplorerPanel` component theo spec `md/51_MEDIA_EXPLORER.md` (REAPER-style: toolbar, directory tree + file list, transport/preview với canvas visualizer + metadata + status bar), render ở hàng dưới trên status bar (giống Mixer panel, resize được, lưu `studio_media_explorer_height`). Gán F6 toggle panel với `preventDefault`+`stopPropagation` ở cả keydown capture của App (MAIN SESSION/SECTION-TAB) và `PianoRollTabEditor`. F6/F7 mutual exclusion: bật panel này tự tắt panel kia (`window.__toggleMediaExplorerRef` / `__toggleMixerRef`). Bỏ dock placeholder `media_explorer` cũ. Thêm button status bar "Media Explorer Panel (F6)". Thêm CSS `.me-fader-slider` + `.file-row-selected` vào `index.html`, bump version precompiled.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` + `node --check` OK; smoke test jsdom: F6 hiện Media Explorer & tắt Mixer, F7 ngược lại. Hard reload để test thủ công.
### [2026-07-31 13:05] Task: My Computer tree truy cập filesystem thật
- **Tóm tắt thay đổi:** Backend mới `app/api/v1/media.py` (+ mount trong `app/main.py`): `GET /api/v1/media/computer` liệt kê ổ đĩa/mount point (Windows drive letters / Linux `/proc/mounts`), `GET /api/v1/media/browse?path=` liệt kê thư mục con + file audio/MIDI, `GET /api/v1/media/file?path=` serve file cục bộ để preview (chặn ext không phải media). Frontend `MediaExplorerPanel`: node "My Computer" click → fetch roots, expand/collapse tree (2 cấp con), click thư mục → liệt kê file vào list, preview audio local qua `/media/file`, nút Back/Up/Refresh + address bar hiểu `folder === 'computer'`; metadata hiển thị Local Audio/Local MIDI.
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/main.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test trực tiếp: roots 8 mounts, browse `/home/locpham` 22 dirs; serve wav → content-type audio/wav; chặn `/etc/shadow`. Smoke jsdom: click My Computer → drive root hiển thị → click drive → list `beat.wav`/`loop.mid`, address hiện path. Hard reload để test thủ công.
### [2026-08-02 14:15] Task: Fix Window Explorer API không hoạt động
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `list_computer_roots`: lọc `/proc/mounts` chỉ giữ filesystem thật (`REAL_FS_TYPES`: ext4/xfs/btrfs/ntfs/vfat...), bỏ pseudo/docker/systemd mounts (`/run/credentials/...`, overlay, tmpfs, proc...) trước đó list ra 8 mount rác không duyệt được. Bỏ import `windll` thừa. Frontend `MediaExplorerPanel`: bỏ `setComputerRoots([])` nuốt lỗi — giờ API fail → fallback về root `/` + `showToast` báo lỗi; `browseComputerDir` fail → toast thay vì im lặng; `goComputerParent` sửa edge case Windows root (`C:\`); `fileDuration` match theo `path` cho file local; size MIDI local hiện "MIDI TPQN".
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test router riêng: `/computer` giờ trả đúng `/` + `/boot/efi` (bỏ rác), browse root 21 dirs; smoke jsdom: click My Computer → drive render → browse file OK; API fail → fallback root OK. Lưu ý: cần restart server FastAPI để load router mới + hard reload browser.
### [2026-08-02 14:30] Task: Media Explorer duyệt toàn bộ folder + files
- **Tóm tắt thay đổi:** `app/api/v1/media.py` `/browse`: trả về TẤT CẢ file (bỏ lọc media) kèm `kind` (`midi`/`audio`/`other`). Frontend `MediaExplorerPanel`: (1) tree My Computer đệ quy sâu vô hạn (`renderComputerNode`) — click folder chọn + load files, click +/- expand/collapse ở mọi cấp; (2) file list merge dirs + files, click file chọn/preview, click folder vào thư mục; (3) icon theo loại (`fa-folder`/`fa-music`/`fa-file-audio`/`fa-file`); (4) guard: file `kind==='other'` không preview; metadata Type hiện "Local File".
- **Các file ảnh hưởng:** `app/api/v1/media.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Test backend: browse trả subdir + a.wav(audio)/b.mid(midi)/notes.txt+readme.md(other). Smoke jsdom: root → list folder+file, expand `/data``/data/deep` → list loop.mid OK. Hard reload.
### [2026-08-02 14:45] Task: My Computer mở explorer client local qua File System Access API
- **Tóm tắt thay đổi:** `MediaExplorerPanel` "My Computer" giờ dùng Web API `window.showDirectoryPicker()` (Chrome/Edge) để mở Windows Explorer của MÁY CLIENT chọn thư mục, duyệt qua `FileSystemDirectoryHandle` (`handle.entries()`) — không còn phụ thuộc server/cloud. Tree đệ quy giữ handle mỗi node, `computerMode='client'` + `clientRoot`. Preview audio/MIDI đọc trực tiếp `handle.getFile() → arrayBuffer → decodeAudioData` (helper `readLocalFileBuffer`), không cần URL server. Back/Up/Refresh hoạt động cả 2 mode. Trình duyệt không hỗ trợ FS API → fallback về server API cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom mock `showDirectoryPicker` + handle tree: click My Computer → picker → drive `C:` hiển thị → browse `Users`/`readme.txt` → expand `Users``me` OK. Chỉ hoạt động Chrome/Edge (FS Access API). Hard reload.
### [2026-08-02 14:55] Task: My Computer client liệt kê folder + files bên phải
- **Tóm tắt thay đổi:** Fix file list folder rows gọi `browseComputerDir(f.path)` (chuỗi) → đổi thành `browseComputerDir(f)` (entry object) để client mode duyệt đúng qua handle. Khi nhấn My Computer (client-side, `showDirectoryPicker`): picker chọn thư mục → tree trái hiện root, file list phải merge `dirs + files` (folder `fa-folder` + file theo kind), click folder trong list → vào thư mục.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom mock FS handle: picker → root `C:` → list Users(folder)+readme.txt → click Users row → vào `root/Users` → me hiển thị OK. Hard reload.
### [2026-08-02 16:40] Task: Media Explorer panel — resize cây thư mục
- **Tóm tắt thay đổi:** `MediaExplorerPanel` directory tree bỏ class `w-44` cố định → width động qua state `treeWidth` (default 176px, min 110 max 420, ref `treeWidthRef`). Thêm drag handle `w-1.5 cursor-ew-resize` bên phải tree (mousedown/mousemove/mouseup trên document, `startTreeResize`). File list bên phải tự co giãn theo `flex-1`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: kéo handle +80px → tree width 176→256px OK, clamp 110420. Hard reload.
### [2026-08-02 16:50] Task: Media Explorer preview không cập nhật realtime khi chọn file
- **Tóm tắt thay đổi:** `MediaExplorerPanel`: (1) `handleSelect` giờ clear ngay `setPeaks(null)` + `setAudioBuffer(null)` + `stopMediaPlayback()` khi chọn file mới → canvas xóa dữ liệu cũ ngay; (2) thêm `selectTokenRef` guard async race: `loadWaveform``playSelected` check token sau mỗi await, kết quả cũ (file trước) không ghi đè file mới chọn; (3) gọi `drawCanvas` qua effect phụ thuộc `[peaks, audioBuffer, selected]` → vẽ lại realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: click file → row selected + metadata MIDI events hiển thị; switch nhanh liên tục → file cuối đúng được chọn, không bị race. Hard reload.
### [2026-08-02 17:15] Task: Media Explorer — persist tree width, canvas realtime, Synth instrument cho MIDI
- **Tóm tắt thay đổi:** (1) Tree width lưu vào `localStorage['studio_media_explorer_tree_width']` khi kéo resize (onUp), khởi tạo đọc lại — reload vẫn giữ độ rộng. (2) Canvas cập nhật realtime khi click file: clear `peaks`/`audioBuffer`/`midiNotes` ngay khi chọn + `selectTokenRef` chống race async; MIDI real vẽ piano-roll nốt thật (màu xám theo pitch 48-84). (3) Nút **Synth** cạnh Auto-Play: dropdown chọn SoundFont + preset (load qua `listPlugins`+`listSoundfontInstruments`), lưu vào `localStorage['studio_media_explorer_synth']`; preview MIDI file thật play qua `window.SonicSF.playNote` với instrument đã chọn (`parseMidiFile` expose ra `window.parseMidiFile` vì nằm trong App scope).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: tree resize → localStorage `276`; Synth dropdown hiện Piano/Grand → chọn persisted; click `tune.mid` → SonicSF.playNote gọi với program 0. Hard reload.
### [2026-08-02 17:18] Task: Fix MIDI auto-play không stop khi click file midi khác
- **Tóm tắt thay đổi:** `stopMediaPlayback` (MediaExplorerPanel) giờ gọi `window.SonicSF.stopAll()` trước khi play file mới → hủy timers note-on/note-off đã schedule của file MIDI trước (trước đây chỉ stop audio source + cancel rAF, nốt MIDI cũ vẫn kêu). Đảm bảo khi click file midi khác, bản cũ dừng ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: play a.mid (4 nốt) → click b.mid → stopAll gọi, b.mid play 4 nốt pitch 72, không overlap. Hard reload.
### [2026-08-02 17:24] Task: Fix MIDI auto-play đọc nhầm file cũ khi click file mới
- **Tóm tắt thay đổi:** `playMidiPreview` (MediaExplorerPanel) thiếu re-check token sau `await window.SonicSF.selectInstrument(...)` — khi click file A rồi nhanh tới file B, invocation cũ (A) chờ selectInstrument xong rồi mới schedule notes, ghi đè lên file B mới chọn → auto-play nghe file cũ nhưng canvas hiển thị file mới. Thêm re-check `selectTokenRef.current !== token` ngay sau await (giống pattern đã có ở audio path sau `decodeAudioData`).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản race: (1) A read chậm 200ms + B nhanh 10ms → chỉ B play; (2) A nhanh + B chậm → chỉ B play (A bị stopAll). Canvas luôn = file cuối click. Hard reload.
### [2026-08-02 17:35] Task: Fix canvas "No file selected" lần đầu + Tempo control
- **Tóm tắt thay đổi:** (1) Fix canvas hiển thị sai lần đầu khi click file MIDI: `startCanvasClock` rAF tick giữ closure cũ của `drawCanvas` (đọc `selected` cũ = null) và vẽ đè "No file selected" mỗi frame. Chuyển `drawCanvas`/`fileDuration`/clock sang đọc state mới nhất qua refs (`selectedRef`, `peaksRef`, `audioBufferRef`, `midiNotesRef`, `midiTotalRef`, `isPlayingRef`, `isPausedRef`, `currentTimeRef`, `tempoRef`). (2) Thêm **Tempo** control (BPM, +/- input, min 40 max 300) cạnh nút Synth — ảnh hưởng tốc độ preview MIDI (`playMidiPreview` dùng `tempoRef` thay `synthInst.bpm`), ruler canvas, footer status; lưu `localStorage['studio_media_explorer_tempo']`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: click a.mid lần đầu → canvas vẽ ruler (0,1,2..) không còn "No file selected", audio play; Tempo 120 → +1 → localStorage `121`. Hard reload.
### [2026-08-02 18:05] Task: Canvas scroll + giữ state folder/files khi toggle + restore session
- **Tóm tắt thay đổi:** (1) `drawCanvas` scroll khi content rộng hơn khung: pxPerBeat 42 / pxPerSec 50, playhead di chuyển tới giữa khung (`min(w/2, t*pxPerSec)`) rồi dừng, content scroll trái với offset `max(0, min(contentW-w, t*pxPerSec - w/2))`; ruler scroll đồng bộ. (2) Panel giờ **luôn mounted** (display:none khi ẩn) thay vì `showMediaExplorer &&` → toggle F6 giữ nguyên folder/files/tree/selection. (3) Session persistence `localStorage['studio_media_explorer_session_v1']` (folder, mode, path, roots, tree, files, selected — bỏ handle) + lưu `FileSystemDirectoryHandle` vào IndexedDB `sonicforge_media_explorer/root_handle` để client mode restore được handle thật sau reload; `restoreSession` + re-browse lại folder đã load → cây tree + file list hiển thị đúng thư mục cũ, tên thư mục loaded luôn hiện trong tree.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: toggle F6 off/on → tree+files+selection giữ nguyên; reload (fresh DOM + IndexedDB stub) → folder MyMusic, files a.mid/b.mid, selection restore. Scroll math: t=10s → offset 350, playhead 150 (center), t=29s clamp 1200. Hard reload.
### [2026-08-02 18:50] Task: Media Explorer — bars MIDI, synth realtime, loop, icons, lưu folder path
- **Tóm tắt thay đổi:** (1) `parseMidiFile` trả thêm `totalBeats`/`bars`/`bpm`/`ticksPerBeat` → metadata + footer + ruler canvas hiển thị bars đúng (thay vì suy từ duration). (2) Thêm `synthInstRef``selectSynthInst` re-schedule preview MIDI ngay khi đổi instrument đang phát (realtime). (3) MIDI preview hỗ trợ **loop**: `loopTimerRef` setInterval re-schedule notes mỗi vòng khi `isLooping`; `toggleLoop` cũng re-schedule khi đang preview; `stopMediaPlayback` clear interval. (4) Thêm **FontAwesome CDN** vào index.html → icon Stop/Play/Pause/Loop + Back/Forward/Up/Refresh hiển thị. (5) `openMyComputer` restore thư mục đã mở từ session (click My Computer → hiện content folder trong ô File, không mở picker lại nếu đã có session + handle IndexedDB).
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Smoke jsdom: metadata Bars/Beats/BPM hiển thị, footer "Bar X/Y"; đổi synth Strings→prog 48 re-schedule OK; loop bật→notes 64→96 (re-schedule vòng 2); icons đủ; folder restore khi click My Computer OK. Hard reload.
### [2026-08-02 19:30] Task: Fix canvas audio + drag & drop Media Explorer → timeline
- **Tóm tắt thay đổi:** (1) Fix canvas audio hiển thị sai: waveform server trả `duration` nhưng không lưu → `dur=0` nên ruler/playhead/time sai. Thêm state `audioDuration` (set ở loadWaveform local/server + playSelected, clear ở handleSelect), `fileDuration` ưu tiên dùng; bar width waveform sửa theo `contentW` thay vì `w` để scroll đúng. (2) Drag & drop file từ Media Explorer vào timeline MAIN SESSION + SECTION-TAB: file row `draggable` + `onDragStart` set `window.__mediaExplorerDragFile`; App thêm `resolveMediaExplorerDropFile()` chuyển entry → real File (client handle `getFile()`, server `file_id` fetch download, local `path` fetch `/media/file`); drop handlers trên track lane + wrapper timeline xử lý cả drag ME lẫn drag OS file (MIDI → new track / audio → load vào track).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: wav client → metadata Duration 3.00s (trước là 0); drag file row → payload text/plain=name + `__mediaExplorerDragFile` set → drop vào track lane → resolve thành File + load (uploadToServer gọi). Hard reload.
### [2026-08-02 19:36] Task: Fix canvas audioclip giãn đầy khung preview
- **Tóm tắt thay đổi:** `drawCanvas` branch audio: bỏ `contentW = Math.max(w, dur*pxPerSec)` (giãn đầy frame) → `contentW = max(1, dur*pxPerSec)` với `pxPerBeat=42`, `pxPerSec = 42*bpm/60` (cùng tỉ lệ tempo với MIDI). Clip ngắn hiển thị đúng chiều rộng theo tempo (2s@120 → 168px), clip dài scroll; playhead theo tỉ lệ tempo (`t*pxPerSec`, khóa giữa khi scroll). Ruler cùng scale (bỏ stretch `Math.max(w, ...)`). Thêm `audioDuration` vào deps effect redraw.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Probe drawCanvas: 2s@120 → contentW=168, waveform maxX 167.8 < 300 (không giãn); 10s → 840 scroll; 2s@240 → 336 (rộng hơn theo tempo). Hard reload.
### [2026-08-02 19:56] Task: My Computer auto-load cây + Favorites thư mục
- **Tóm tắt thay đổi:** (1) Khi mở My Computer client-side (pick xong root), tự động browse + expand tối đa 10 thư mục con cấp 1 (`browseComputerDir`/`browseClientDir` giờ trả về `{dirs, files}` để auto-expand) → cây hiển thị cấu trúc ổ đĩa/thư mục ngay. (2) **Favorites**: node "Favorites" ngay dưới My Computer (hiện khi có favorite, icon `fa-star`); chuột phải folder trong cây (hoặc trong Favorites) → context menu "Thêm vào Favorites"/"Gỡ khỏi Favorites" + "Mở thư mục"; lưu vào `localStorage['studio_media_explorer_favorites_v1']` `[{path, name}]`; click favorite mở nhanh folder (tìm handle trong `computerTree`, hoặc walk lại từ `clientRoot` qua `getDirectoryHandle` theo segments path, fallback `browseComputerDir`); node folder có favorite hiện icon star.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: pick root → C: + Users + Program Files auto-load; right-click Users → menu → favorite saved localStorage + node Favorites hiện; click favorite → mở `root/Users`. Hard reload.
### [2026-08-02 19:65] Task: My Computer auto-scan + playhead chạy tới cuối khi hết scroll
- **Tóm tắt thay đổi:** (1) `openMyComputer` bỏ `showDirectoryPicker` khi click node My Computer — giờ tự động quét server API (`/api/v1/media/computer` liệt kê ổ đĩa máy local + `/browse` expand ổ đĩa → expand thư mục con cấp 1), cây hiển thị ngay không cần hộp thoại; vẫn restore session/handle IndexedDB nếu có. (2) Playhead trong `drawCanvas` (MIDI + audio): công thức `max(0, min(w, t*pxPerSec - offset))` — khi content rộng hơn frame, playhead chạy tới giữa rồi khóa ở giữa trong khi content scroll trái; khi offset đạt max (contentW-w) thì playhead tiếp tục chạy với tốc độ play tới cuối canvas.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke: click My Computer → picker NOT called, DATA/HOME + Music/user auto-load; playhead math: big clip t=1→84, t=3→150 (center), t=5→150 locked, t=10→300 (end). Hard reload.
### [2026-08-02 19:75] Task: Fix My Computer client-side tự động quét cây
- **Tóm tắt thay đổi:** `openMyComputer` ưu tiên **client-side**: load root handle từ IndexedDB → tự động quét cây thư mục client (C:, Users, Program Files...) mà KHÔNG gọi `showDirectoryPicker` (đã gỡ hẳn). Fix bug cũ: `if (savedHandle && ... && !computerRoots)` luôn false vì `computerRoots` vừa được set bởi server API → client handle không bao giờ restore được → gây ra hộp thoại picker. Giờ: (1) nếu có client handle → auto-scan client tree + expand; (2) không có → fallback server API `/api/v1/media/computer` tự động quét ổ đĩa máy local. Mọi path đều không mở hộp thoại.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: IndexedDB có client handle `C:` → click My Computer → picker NOT called, cây C:/Users/Program Files auto-load. Không handle → server API Root (/) auto-load, picker NOT called. Hard reload (browser cache cũ có thể vẫn hiện picker). Commit.
### [2026-08-02 19:85] Task: Fix My Computer hiển thị nhầm Favorites thay vì cây hệ thống
- **Tóm tắt thay đổi:** `openMyComputer` bỏ block restore session cũ (đọc `studio_media_explorer_session_v1` từ localStorage ghi đè `computerRoots`/`computerTree` bằng dữ liệu favorites/stale → khi click My Computer hiện thư mục favorite cũ thay vì cây client). Giờ click My Computer luôn: (1) load client root handle từ IndexedDB → auto-scan cây client thật (C:, Users, Program Files...) hoặc (2) fallback server API scan ổ đĩa local. Không còn ghi đè bằng session cũ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: seed session cũ chứa `OLD_FAV` + favorites → click My Computer → picker NOT called, hiện C:/Users/Program Files (client tree), OLD_FAV không hiển thị. Hard reload.
### [2026-08-02 19:95] Task: My Computer luôn hiển thị cây hệ thống client, Favorites node riêng
- **Tóm tắt thay đổi:** `restoreSession` giờ CHỈ khôi phục favorites + folder/mode/rootName — BỎ khôi phục `computerRoots`/`computerPath`/`computerFiles`/`computerTree`/`selected` (nguyên nhân click My Computer vẫn mở thư mục cũ từ session). Mount effect chỉ nối lại FileSystemDirectoryHandle (clientRoot) không browse folder cũ. Click My Computer luôn: load client handle → auto-scan cây hệ thống client (C:, Users, Windows...) hoặc fallback server API scan ổ đĩa. Favorites node hiển thị sát dưới node My Computer (đã có từ trước).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom: seed session OLD_STUFF + favorite Users → click My Computer → picker NOT called, cây client C:/Users/Windows hiển thị, OLD_STUFF không còn, Favorites node + favorite Users có icon star. Hard reload.
### [2026-08-02 20:01] Task: Fix My Computer chỉ hiện 1 thư mục xanh không có ổ đĩa/thư mục
- **Tóm tắt thay đổi:** `openMyComputer`: nếu có client root handle đã lưu (IndexedDB) nhưng quyền đọc bị thu hồi sau reload (handle.entries() throw/empty) thì trước đây vẫn `return` → chỉ hiện node gốc (icon xanh) không có con. Giờ: (1) gọi `queryPermission`/`requestPermission` trước khi quét; (2) chỉ giữ client path nếu `browsed.dirs.length > 0`; (3) nếu quét client lỗi/rỗng → fallback server API scan ổ đĩa + expand thư mục con.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Smoke jsdom 2 kịch bản: (1) handle bị revoke (entries throw, permission denied) → fallback server: 2 drives (fa-hard-drive) + 5 folder, DATA/HOME/Music/Docs hiển thị; (2) handle khỏe (granted) → cây client C: + Users/me/Windows. Hard reload.
### [2026-08-02 20:10] Task: Click My Computer PHẢI hiện cây thư mục ổ đĩa + thư mục client-side
- **Tóm tắt thay đổi:** `openMyComputer` refactor thành helper `useClientRoot`: (1) client handle đã lưu (IndexedDB) + permission granted → auto-scan cây client; (2) KHÔNG có handle/không có quyền → gọi `window.showDirectoryPicker()` để cấp quyền đọc thư mục client rồi hiện cây client (lần đầu sẽ hiện picker 1 lần); (3) không hỗ trợ API/người dùng hủy → fallback server scan `/api/v1/media/computer` (ổ đĩa máy local). Bỏ điều kiện `browsed.dirs.length` để luôn giữ client mode sau khi chọn. Fix cha-con: `browseClientDir` gắn `parent: handle` cho từng thư mục con, `browseComputerDir` giữ `entry.parent` khi node chưa có trong tree → nút Up (↑) điều hướng đúng trong cây client.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công, grep bundle chứa `showDirectoryPicker`/`useClientRoot`/`parent:handle`. Smoke: không handle → click My Computer → picker hiện, chọn thư mục → cây client expand. Có handle → không picker, cây client auto-load.
### [2026-08-02 20:56] Task: Fix "Unable to preventDefault inside passive event listener invocation"
- **Tóm tắt thay đổi:** Media Explorer preview canvas dùng React `onWheel={handleCanvasWheel}``e.preventDefault()` — React gắn `wheel` passive tại root nên Chrome log lỗi "Unable to preventDefault inside passive event listener invocation" và page vẫn scroll. Thay bằng native listener: `useEffect` + `canvasRef` + `addEventListener('wheel', h, { passive: false })` qua `handleCanvasWheelRef` (luôn gọi handler mới nhất), bỏ prop `onWheel` khỏi `<canvas>`. Giờ block scroll thật + hết lỗi console.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` OK; bundle: `handleCanvasWheelRef` có mặt, `onWheel:handleCanvasWheel` đã bỏ. Scroll chuột trên canvas preview (có file selected) → zoom hoạt động, không lỗi passive. Lưu ý: các onWheel tương tự ở MixerStrip/MasterStrip/TrackStripConsole (pan dial, fader) vẫn còn cảnh báo này — chưa sửa.
### [2026-08-02 21:40] Task: Instrument Sonatina soundfont không phát âm thanh
- **Tóm tắt thay đổi:** Nguyên nhân gốc: FluidSynth WASM (soundfontPlayer) KHÔNG decode được mẫu Ogg Vorbis/SF3 — mọi sample có bit OGG_VORBIS bị từ chối ("unknown flags... unsupported compression") → chọn instrument từ soundfont .sf3 (Sonatina đang ở dạng SF3 do startup convert) → câm hoàn toàn. Ngoài ra converter SF2→SF3 cũ còn 2 bug: (1) thiếu chunk id 'smpl' khi dựng lại file → sfload -1; (2) offset đọc shdr sai (đọc [end,loopstart,loopend,rate] thay vì [start,end,...]) → smpl rỗng. Fix: (1) `sf3_to_sf2()` + `_decode_sample_ogg()` chuyển SF3→SF2 (giải nén OGG về PCM, đúng semantics end-exclusive, loop absolute, xóa bit OGG) — đã verify phát âm thanh cả native lẫn WASM; (2) endpoint download ưu tiên .sf2, nếu chỉ có .sf3 thì chuyển SF3→SF2 on-demand (cache vào upload dir); (3) vô hiệu startup convert SF2→SF3 (`main.py`) — client không chơi được SF3; (4) client `loadSoundFont` nếu cache IndexedDB cũ chứa buffer hỏng thì xóa cache + tải lại; (5) sửa `_sf2_to_sf3_python` (smpl id, shdr offset đúng, end-exclusive, version 3.0, bit OGG, loop relative) + gate `_sf3_plays_audio` xóa SF3 không phát được.
- **Các file ảnh hưởng:** `app/core/soundfont_converter.py`, `app/api/v1/plugins.py`, `app/main.py`, `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Test Node/WASM + native: SF2 gốc rms 0.007 (OK); SF3 convert mới sfload=1 nhưng câm (WASM từ chối OGG); SF3→SF2 convert ra file 52.37MB phát rms 0.007-0.02 (native + WASM). Test endpoint mô phỏng: upload SF3-only → download trả SF2 phát được. `pytest tests/test_vst_engine.py` 12 passed. Lưu ý: scipy thiếu nên test_plugin_api không import được (môi trường dev).
### [2026-08-03 07:55] Task: Media Explorer preview - loop liên tục vùng chọn + margin 2px quanh canvas
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.