Compare commits
71 Commits
845d1594bb
...
4d10b9485b
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d10b9485b | |||
| adccf6430a | |||
| 73025fc387 | |||
| 9f762c2a78 | |||
| a0b6110fd1 | |||
| e88ce2e3ea | |||
| 827693dfc4 | |||
| d04631c7d3 | |||
| 224fd54a56 | |||
| befc0d35fd | |||
| 5e3a283bd9 | |||
| 4a04bc04ef | |||
| dc384b3102 | |||
| 0b9079632a | |||
| 9be6991222 | |||
| 0853a13f62 | |||
| c09f994ede | |||
| 249e2afea2 | |||
| b65188fcc8 | |||
| ba5883951d | |||
| 7000529d8d | |||
| 1de6bd6b57 | |||
| c435d00897 | |||
| b075a5eca7 | |||
| 36ee2bdf90 | |||
| 5423686c73 | |||
| 4db777eb5d | |||
| 1989bfb105 | |||
| d1995307f5 | |||
| f647291523 | |||
| fdaabe3a08 | |||
| 548ab1258f | |||
| 2c2ee59d7d | |||
| 7e5f406226 | |||
| ad5cda5a40 | |||
| 05e6a467d8 | |||
| 4b43f027ce | |||
| 175bce6743 | |||
| 3380b0e201 | |||
| 619a2484ce | |||
| 24ac703d4d | |||
| c47eb5d718 | |||
| 3a345c3f4e | |||
| 86b304e173 | |||
| 01d02a9d71 | |||
| 2bf9f8bcb8 | |||
| 99a445b789 | |||
| ee39dda5b9 | |||
| be8430fbd9 | |||
| f1b00eb060 | |||
| d81afba93d | |||
| 223254eb07 | |||
| 698fa818e9 | |||
| 60d05e2b4d | |||
| 0ea994fb2f | |||
| eb370319cb | |||
| 9b25766fca | |||
| dfbeb24a33 | |||
| 3c4f106e14 | |||
| 4f5aee6830 | |||
| 42d64f93d8 | |||
| 6c11daccf8 | |||
| 88007dce34 | |||
| 1790f743d6 | |||
| ef6370f92e | |||
| 3cac617aff | |||
| e1550c8ffe | |||
| c60803ee2a | |||
| 9c5507f7b2 | |||
| 47e87c8363 | |||
| 652fb98d2c |
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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> <Track Templates></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> <Project Directory></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>
|
||||
|
||||
```
|
||||
@@ -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` và `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 110–420. 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` và `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}` có `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.
|
||||
|
||||
Reference in New Issue
Block a user