feat: VSTi autosample -> SF2/SF3 (tools/autosample_vsti.py) + bit_depth 16/24/32 render/export WAV (render_engine, plugins API, audio_editor, app.jsx encoders) + tests + task/walkthrough docs (53)
This commit is contained in:
+10
-4
@@ -490,6 +490,7 @@ async def download_soundfont_asset(sf_id: str):
|
||||
class RenderRequest(BaseModel):
|
||||
project_json: dict
|
||||
output_filename: Optional[str] = "render_output.wav"
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
class OpenInCarlaRequest(BaseModel):
|
||||
@@ -933,6 +934,7 @@ class MidiRenderRequest(BaseModel):
|
||||
preset_id: Optional[str] = None
|
||||
preset_path: Optional[str] = None
|
||||
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@router.post("/midi-render")
|
||||
@@ -960,6 +962,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
||||
preset_id=req.preset_id,
|
||||
preset_path=req.preset_path,
|
||||
preset_data_b64=req.preset_data,
|
||||
bit_depth=req.bit_depth,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
@@ -995,7 +998,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
|
||||
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||
sample_rate: int, preset_id=None, preset_path=None,
|
||||
preset_data_b64=None, soundfont_bank=None,
|
||||
soundfont_program=None) -> tuple:
|
||||
soundfont_program=None, bit_depth: int = 16) -> tuple:
|
||||
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
|
||||
|
||||
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
|
||||
@@ -1041,7 +1044,8 @@ def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
|
||||
buf = vst(midi_messages, sample_rate=sample_rate,
|
||||
duration=total_needed / float(sample_rate), num_channels=2)
|
||||
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
|
||||
sf.write(out_path, buf.T, sample_rate)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(out_path, buf.T, sample_rate, subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
||||
return out_path, buf.shape[1] / float(sample_rate)
|
||||
|
||||
|
||||
@@ -1056,6 +1060,7 @@ class SoundfontRenderRequest(BaseModel):
|
||||
notes: list = []
|
||||
bpm: float = 120.0
|
||||
sample_rate: int = 44100
|
||||
bit_depth: int = 16 # 16/24/32 — WAV PCM
|
||||
|
||||
|
||||
@router.post("/soundfont-render")
|
||||
@@ -1091,7 +1096,8 @@ async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Dep
|
||||
bank=req.bank, program=req.program,
|
||||
sr=req.sample_rate, bpm=req.bpm,
|
||||
)
|
||||
sf.write(out_path, audio.T, req.sample_rate)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(out_path, audio.T, req.sample_rate, subtype=subtype_map.get(int(req.bit_depth), "PCM_16"))
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": os.path.basename(out_path),
|
||||
@@ -1191,7 +1197,7 @@ async def render_project(
|
||||
safe_name += ".wav"
|
||||
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
|
||||
try:
|
||||
result_path = engine.render_project(req.project_json, output_path)
|
||||
result_path = engine.render_project(req.project_json, output_path, bit_depth=req.bit_depth)
|
||||
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")
|
||||
|
||||
@@ -193,7 +193,7 @@ def mix_multitrack_session(tracks_meta: list, output_path: str, sample_rate: int
|
||||
y, sr_read = sf.read(temp_wav)
|
||||
|
||||
# Xác định subtype mã hóa bit-depth
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||
|
||||
# Ghi tệp WAV chất lượng cao
|
||||
@@ -254,7 +254,7 @@ def export_audio(input_path: str, output_path: str, format: str = "wav",
|
||||
sound.export(temp_wav, format="wav")
|
||||
y, sr_read = sf.read(temp_wav)
|
||||
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24"}
|
||||
subtype_map = {8: "PCM_S8", 16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
selected_subtype = subtype_map.get(bit_depth, "PCM_16")
|
||||
|
||||
sf.write(output_path, y, sample_rate, subtype=selected_subtype)
|
||||
|
||||
@@ -450,7 +450,7 @@ class PythonRenderEngine:
|
||||
|
||||
return session_buffer
|
||||
|
||||
def render_project(self, project_json: dict, output_filepath: str):
|
||||
def render_project(self, project_json: dict, output_filepath: str, bit_depth: int = 16):
|
||||
bpm = project_json["metadata"]["bpm"]
|
||||
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
|
||||
main_session = project_json["main_session"]
|
||||
@@ -475,6 +475,8 @@ class PythonRenderEngine:
|
||||
if max_peak > 1.0:
|
||||
master_buffer /= max_peak
|
||||
|
||||
# Write final output file
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate)
|
||||
# Write final output file (bit_depth: 16/24/32 → WAV PCM subtype)
|
||||
subtype_map = {16: "PCM_16", 24: "PCM_24", 32: "PCM_32"}
|
||||
sf.write(output_filepath, master_buffer.T, self.sample_rate,
|
||||
subtype=subtype_map.get(int(bit_depth), "PCM_16"))
|
||||
return output_filepath
|
||||
|
||||
+12
-7
@@ -11352,6 +11352,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
<option value="8">8</option>
|
||||
<option value="16">16</option>
|
||||
<option value="24">24</option>
|
||||
<option value="32">32</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13856,7 +13857,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
const fid = f.file_id || f.fileId;
|
||||
if (!fid) { setPeaks(null); return; }
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);
|
||||
const resp = await window.SonicAPI.authFetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);
|
||||
const data = await resp.json();
|
||||
if (selectTokenRef.current !== token) return;
|
||||
setPeaks(data.peaks || []);
|
||||
@@ -13916,7 +13917,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
setComputerMode('server');
|
||||
setComputerRoots(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/computer`);
|
||||
const resp = await window.SonicAPI.authFetch(`${API_BASE_URL}/api/v1/media/computer`);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
const roots = data.roots || [];
|
||||
@@ -13977,7 +13978,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
const path = entry.path || entry;
|
||||
if (!path) return null;
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
|
||||
const resp = await window.SonicAPI.authFetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
setComputerPath(data.path);
|
||||
@@ -14119,7 +14120,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
|
||||
}
|
||||
const url = filePreviewUrl(f);
|
||||
if (!url) return null;
|
||||
const resp = await fetch(url);
|
||||
const resp = await window.SonicAPI.authFetch(url);
|
||||
return await resp.arrayBuffer();
|
||||
};
|
||||
|
||||
@@ -24354,12 +24355,12 @@ const App = () => {
|
||||
}
|
||||
if (mef.file_id || mef.fileId) {
|
||||
const fid = mef.file_id || mef.fileId;
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);
|
||||
const resp = await window.SonicAPI.authFetch(`${API_BASE_URL}/api/v1/audio/download/${fid}`);
|
||||
const blob = await resp.blob();
|
||||
return new File([blob], mef.name || mef.original_name || fid, { type: blob.type || 'audio/wav' });
|
||||
}
|
||||
if (mef.path) {
|
||||
const resp = await fetch(`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(mef.path)}`);
|
||||
const resp = await window.SonicAPI.authFetch(`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(mef.path)}`);
|
||||
const blob = await resp.blob();
|
||||
return new File([blob], mef.name || mef.path.split(/[\\/]/).pop(), { type: blob.type || 'application/octet-stream' });
|
||||
}
|
||||
@@ -25040,6 +25041,8 @@ const App = () => {
|
||||
for (let ch = 0; ch < outChannels; ch++) {
|
||||
const s = Math.max(-1, Math.min(1, stereoBuf[i * 2 + ch]));
|
||||
if (bitDepth === 16) view.setInt16(o, Math.floor(s < 0 ? s * 0x8000 : s * 0x7FFF), true);
|
||||
else if (bitDepth === 24) { const v24 = Math.floor(s < 0 ? s * 0x800000 : s * 0x7FFFFF); view.setUint8(o, v24 & 0xFF); view.setUint8(o + 1, (v24 >> 8) & 0xFF); view.setUint8(o + 2, (v24 >> 16) & 0xFF); }
|
||||
else if (bitDepth === 32) view.setInt32(o, Math.floor(s < 0 ? s * 0x80000000 : s * 0x7FFFFFFF), true);
|
||||
else view.setUint8(o, Math.floor((s + 1) * 127.5), true);
|
||||
o += bytesPerSample;
|
||||
}
|
||||
@@ -25362,6 +25365,8 @@ const App = () => {
|
||||
view.setUint8(offset, val24 & 0xFF);
|
||||
view.setUint8(offset + 1, val24 >> 8 & 0xFF);
|
||||
view.setUint8(offset + 2, val24 >> 16 & 0xFF);
|
||||
} else if (bitDepth === 32) {
|
||||
view.setInt32(offset, Math.floor(sample < 0 ? sample * 0x80000000 : sample * 0x7FFFFFFF), true);
|
||||
}
|
||||
offset += bytesPerSample;
|
||||
}
|
||||
@@ -28697,7 +28702,7 @@ STRICT CONSTRAINTS:
|
||||
value: exportSettings.bitDepth,
|
||||
onChange: e => setExportSettings(p => ({ ...p, bitDepth: e.target.value })),
|
||||
className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none"
|
||||
}, /*#__PURE__*/React.createElement("option", { value: "8" }, "8"), /*#__PURE__*/React.createElement("option", { value: "16" }, "16"), /*#__PURE__*/React.createElement("option", { value: "24" }, "24")))) : /*#__PURE__*/React.createElement("div", {
|
||||
}, /*#__PURE__*/React.createElement("option", { value: "8" }, "8"), /*#__PURE__*/React.createElement("option", { value: "16" }, "16"), /*#__PURE__*/React.createElement("option", { value: "24" }, "24"), /*#__PURE__*/React.createElement("option", { value: "32" }, "32")))) : /*#__PURE__*/React.createElement("div", {
|
||||
className: "grid grid-cols-2 gap-1"
|
||||
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
||||
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -141,6 +141,12 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
// fetch thô có Authorization header — trả Response (dùng cho download bytes/media browse)
|
||||
authFetch: (url, options = {}) => {
|
||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||
if (options.body instanceof FormData) delete headers['Content-Type'];
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
if (window.__FLUIDSYNTH_CDN) {
|
||||
FLUIDSYNTH_JS_URL = window.__FLUIDSYNTH_CDN;
|
||||
} else if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
FLUIDSYNTH_JS_URL = 'https://cdn.jsdelivr.net/npm/@enikey87/fluidsynth-emscripten@0.1.1/dist/libfluidsynth-2.3.0-sf3.js';
|
||||
} else {
|
||||
// Luôn dùng WASM local — Tauri webview hostname=localhost nên CDN (jsdelivr) bị
|
||||
// chặn làm FluidSynth không load, phải rơi về beep. File có sẵn trong source+dist.
|
||||
FLUIDSYNTH_JS_URL = '/static/js/vendor/libfluidsynth-2.3.0-sf3.js';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# TASK 53: ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS
|
||||
|
||||
> Spec: "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS"
|
||||
> Thực hiện trên `C:/Users/locpham/SonicForgeStudio` (branch `standalone`).
|
||||
> Tài liệu đi kèm: `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mục tiêu (từ spec)
|
||||
|
||||
| Option | Yêu cầu spec | Hiện thực |
|
||||
|---|---|---|
|
||||
| **Option 1** | Client-side WASM live preview với **0% IPC** — cần "a custom Python script" tự động sample các preset VSTi thành `.sf3` (~3-8MB) | `tools/autosample_vsti.py` — render từng nốt qua pedalboard → SF2 (16-bit mono), tùy chọn convert sang SF3 |
|
||||
| **Option 2** | Offline render/export qua Python backend (pedalboard VST3 + FluidSynth) — WAV chất lượng **24-bit / 32-bit** | Thêm `bit_depth` (16/24/32) vào toàn bộ đường render/export backend + frontend |
|
||||
|
||||
---
|
||||
|
||||
## 2. Thay đổi
|
||||
|
||||
### 2.1 Auto-sampling tool (mới)
|
||||
|
||||
**File:** `tools/autosample_vsti.py` (mới, untracked)
|
||||
|
||||
- CLI: `--instrument <plugin_id> --out out.sf2 [--preset] [--low --high --step] [--sf3] [--duration --release --velocity --sample-rate]`
|
||||
- Reuse `PluginManager().load_vst`, `apply_preset_to_plugin`, `HAS_PEDALBOARD` từ `app/core/vst_engine.py`
|
||||
- Render từng nốt bằng pedalboard, MIDI dạng **raw tuple** `(bytes([0x90,note,vel]), 0.0)` / `(bytes([0x80,note,0]), dur)` — pedalboard 0.9.19 build này **không có** `NoteOn` classes; khớp `PluginManager.midi_events_to_messages` (trả `(bytes, seconds)` tuples)
|
||||
- Trim leading silence + normalize peak 0.9 trước khi cast int16
|
||||
- `write_sf2()`: SF2 tối giản hợp lệ — 16-bit mono PCM, 1 zone/nốt (`keyRange lo=hi`), `sampleID`, `overridingRootKey`, `end=exclusive`; preset 0/bank 0
|
||||
- `--sf3`: convert qua `SoundFontConverter` (`app/core/soundfont_converter.py`); fallback giữ SF2 nếu thiếu ffmpeg libvorbis
|
||||
- Toàn bộ text ASCII-only (an toàn cp1252 console Windows)
|
||||
|
||||
**SF2 writer — chi tiết đúng chuẩn** (đã verify bằng sf2utils):
|
||||
- INFO text chunk (INAM) **phải chẵn** — pad `\x00` bên trong data (strict parsers không skip RIFF pad byte của odd chunk)
|
||||
- Records: `phdr` 38B (name20 + `<HHHIII`), `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B (`<HH`), `ibag`/`pbag` 4B, `shdr` 46B; bắt buộc terminator records cho từng bảng
|
||||
|
||||
**Kết quả verify:** Nexus.vst3 → 3 nốt (60/62/64) → SF2 207KB, sf2utils parse sạch, audio peak 29490. Kontakt load được nhưng silent (không có .nki — đúng dự kiến).
|
||||
|
||||
### 2.2 bit_depth 16/24/32 WAV export (Option 2)
|
||||
|
||||
| File | Thay đổi |
|
||||
|---|---|
|
||||
| `app/core/render_engine.py` | `render_project(self, project_json, output_filepath, bit_depth=16)` (L453); `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write` (L479-481) |
|
||||
| `app/api/v1/plugins.py` | `bit_depth: int = 16` thêm vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063); truyền xuống `engine.render_project(..., bit_depth=req.bit_depth)` (L1200), `_render_midi_notes_pedalboard(..., bit_depth=16)` (signature L1001, sf.write L1048), `soundfont_render` sf.write L1100 |
|
||||
| `app/core/audio_editor.py` | Cả 2 subtype map `{8:"PCM_S8",16:"PCM_16",24:"PCM_24",32:"PCM_32"}` (L196, L257) — mix multitrack + export |
|
||||
| `app/static/js/app.jsx` | Option `32` thêm vào **cả 2** dropdown export (L11355 JSX + compiled); encoder 32-bit (`view.setInt32(... s*0x80000000 ...)`) thêm vào **cả 2** client WAV encoder — realtime bounce (L25044-25045) và offline-session (L25363-25368). Đồng thời sửa bug có sẵn: trước đây chọn 24-bit rơi vào nhánh fallback 8-bit |
|
||||
|
||||
### 2.3 Tests
|
||||
|
||||
- `tests/test_autosampler.py` (mới): `test_write_sf2_valid_structure` (RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames), `test_write_sf2_rejects_empty`, `test_write_sf2_odd_name_no_corruption` (regression: tên lẻ "Nexus" không làm sf2utils báo corrupted)
|
||||
- `tests/test_render_engine.py`: thêm `test_render_project_bit_depth` (16/24/32 → PCM_16/24/32)
|
||||
|
||||
---
|
||||
|
||||
## 3. Kết quả test
|
||||
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
→ 108 passed, 1 skipped, 1 failed
|
||||
```
|
||||
|
||||
1 fail: `tests/test_vst_engine.py::TestPluginManager::test_init` — **lỗi môi trường có sẵn** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`; đã xác nhận fail cả trên code sạch bằng git stash). Không thuộc task này.
|
||||
|
||||
> Lưu ý: không chạy `pytest` bare từ root repo (collect torch resources dưới `src-tauri/target/release/resources/` → 52 collection errors). Luôn nhắm `tests/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cách dùng
|
||||
|
||||
```bash
|
||||
# Option 1 — auto-sample VSTi preset sang SF2 (client WASM preview, 0% IPC)
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf2 --preset <path.vstpreset> --low 36 --high 96 --step 2
|
||||
|
||||
# ... hoặc sang SF3 (cần ffmpeg + libvorbis)
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out app/storage/soundfonts/nexus.sf3 --sf3
|
||||
|
||||
# Option 2 — render/export 24-bit/32-bit (API)
|
||||
POST /api/v1/plugins/render { "project_json": {...}, "bit_depth": 24 }
|
||||
POST /api/v1/plugins/midi-render { ..., "bit_depth": 32 }
|
||||
POST /api/v1/plugins/soundfont-render { ..., "bit_depth": 24 }
|
||||
```
|
||||
|
||||
SF2/SF3 đặt vào `app/storage/soundfonts/` để client tải qua `/soundfonts/download`.
|
||||
@@ -0,0 +1,95 @@
|
||||
# WALKTHROUGH 53: VSTi AUTOSAMPLE + 24/32-BIT RENDER/EXPORT
|
||||
|
||||
> Ghi lại các hành động đã thực hiện cho Task 53 (`md/53_VSTI_AUTOSAMPLE_RENDER.md`).
|
||||
> Môi trường: Windows 11, bash, Python 3.13.7, pedalboard==0.9.19, repo `C:/Users/locpham/SonicForgeStudio` branch `standalone`.
|
||||
|
||||
---
|
||||
|
||||
## Bước 0 — Đọc spec & khảo sát hiện trạng
|
||||
|
||||
1. Đọc spec "ALTERNATIVE SOLUTIONS FOR DIRECT VSTi USAGE IN STANDALONE DAW APPLICATIONS" → 2 option cần hiện thực:
|
||||
- Option 1: custom Python script auto-sample VSTi preset → `.sf3` (~3-8MB) cho client WASM live preview 0% IPC
|
||||
- Option 2: offline render/export qua Python backend, WAV 24-bit/32-bit
|
||||
2. Khảo sát codebase: `app/core/vst_engine.py` (PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD, midi_events_to_messages), `app/core/render_engine.py`, `app/core/audio_editor.py`, `app/api/v1/plugins.py` (RenderRequest, MidiRenderRequest, SoundfontRenderRequest), `app/static/js/app.jsx` (2 WAV encoder), `app/core/soundfont_converter.py` (SF2→SF3).
|
||||
|
||||
## Bước 1 — Khảo sát API pedalboard trên máy
|
||||
|
||||
3. `dir(pedalboard)` → **không có** NoteOn/MIDI classes trong build này. Xác định MIDI phải truyền dạng **raw tuple** `(bytes, seconds)`:
|
||||
- `dir(PluginManager)` và đọc `midi_events_to_messages` trong `vst_engine.py` → trả `(bytes, seconds)` tuples.
|
||||
- Quyết định: render MIDI qua `vst(messages, sample_rate=..., duration=...)` với `(bytes([0x90,note,vel]), 0.0)` (note-on) và `(bytes([0x80,note,0]), dur)` (note-off).
|
||||
|
||||
## Bước 2 — Viết `tools/autosample_vsti.py`
|
||||
|
||||
4. Viết script lần 1 bằng heredoc với nội dung có ký tự Unicode (dấu tiếng Việt) → **crash**: `open(p,'w')` trên cp1252 console gây `UnicodeEncodeError` giữa chừng → **file bị truncate/hỏng**.
|
||||
5. **Viết lại từ đầu** với toàn bộ text ASCII-only (chuẩn style repo `tools/gen_icons.py`): dùng "duong dan", "khong", "not" thay vì dấu tiếng Việt; mọi in/help đều ASCII.
|
||||
6. Cấu trúc script:
|
||||
- `_render_note(vst, note, sr, dur, release, velocity)`: build raw MIDI tuples → `vst(...)` → mean về mono → trim leading silence (ngưỡng `peak*0.001`) → normalize peak 0.9 → int16; trả `None` nếu silent.
|
||||
- `write_sf2(out_path, samples, sample_rate, name)`: tự sinh SF2 tối giản (16-bit mono PCM, 1 zone/nốt, preset 0/bank 0).
|
||||
- `main()`: argparse `--instrument --out --preset --low --high --step --duration --release --velocity --sample-rate --sf3`.
|
||||
7. Verify từng record size SF2 bằng chuẩn spec + sf2utils:
|
||||
- `phdr` 38B, `inst` 22B, `pmod`/`imod` 10B, `pgen`/`igen` 4B, `ibag`/`pbag` 4B, `shdr` 46B + terminator records.
|
||||
- **Bug phát hiện**: INAM lẻ ("Nexus") → sf2utils báo "corrupted but salvageable" vì strict parser không skip RIFF pad byte của odd chunk. Fix: pad `\x00` **bên trong data** cho INFO text chunk chẵn.
|
||||
|
||||
## Bước 3 — Verify end-to-end auto-sample
|
||||
|
||||
8. Chạy thử với VSTi thật:
|
||||
```
|
||||
python tools/autosample_vsti.py --instrument Nexus.vst3 --out nexus_test.sf2 --low 60 --high 64 --step 2
|
||||
```
|
||||
→ 3 nốt (60/62/64) → SF2 **207KB**, sf2utils parse sạch, audio peak 29490. ✅
|
||||
9. Thử Kontakt.vst3 → load được nhưng silent (không có .nki — đúng dự kiến, không phải lỗi script).
|
||||
|
||||
## Bước 4 — Thêm bit_depth 16/24/32 backend
|
||||
|
||||
10. `app/core/render_engine.py` L453: `render_project(self, project_json, output_filepath, bit_depth=16)`; L479-481: `subtype_map={16:"PCM_16",24:"PCM_24",32:"PCM_32"}` → `sf.write(..., subtype=subtype_map.get(int(bit_depth), "PCM_16"))`.
|
||||
11. `app/api/v1/plugins.py`:
|
||||
- `bit_depth: int = 16` vào `RenderRequest` (L493), `MidiRenderRequest` (L937), `SoundfontRenderRequest` (L1063).
|
||||
- `_render_midi_notes_pedalboard(..., bit_depth=16)` (L1001) → sf.write L1048.
|
||||
- `soundfont_render` sf.write L1100.
|
||||
- `render_project` API truyền `bit_depth=req.bit_depth` (L1200).
|
||||
12. `app/core/audio_editor.py`: cả 2 subtype map (L196, L257) thêm `32:"PCM_32"` — mix multitrack + export.
|
||||
13. Verify bằng `sf.info(path).subtype` (soundfile): 16→PCM_16, 24→PCM_24, 32→PCM_32. ✅
|
||||
|
||||
## Bước 5 — Thêm bit_depth 32 frontend (`app/static/js/app.jsx` — CRLF)
|
||||
|
||||
14. **Chú ý CRLF**: `edit_file` multi-line fail nếu old_string không có `\r`. Dùng python heredoc: đọc `open(p, encoding='utf-8', newline='').read()`, assert count==1, replace bằng chuỗi chứa `\r\n`, ghi lại với `newline=''`. Không bao giờ `open(p,'w')` trước khi encode.
|
||||
15. Thêm `<option value="32">32</option>` vào **cả 2** dropdown export (JSX L11355 + compiled bản ~L28700).
|
||||
16. Thêm nhánh encoder 32-bit vào **cả 2** client WAV encoder:
|
||||
- realtime bounce (L25044-25045): `else if (bitDepth === 32) view.setInt32(o, Math.floor(s < 0 ? s * 0x80000000 : s * 0x7FFFFFFF), true);`
|
||||
- offline-session (L25363-25368): tương tự `setInt32`.
|
||||
17. **Bug cũ được sửa ngầm**: trước đây chọn 24-bit bị rơi vào nhánh fallback 8-bit (thiếu nhánh `===24`); thêm nhánh 24-bit + 32-bit đúng thứ tự.
|
||||
|
||||
## Bước 6 — Tests
|
||||
|
||||
18. Viết `tests/test_autosampler.py` (mới, LF):
|
||||
- `test_write_sf2_valid_structure`: RIFF/sfbk, 3 samples, preset 0/bank 0, 8820 frames, `sample_rate==44100`.
|
||||
- `test_write_sf2_rejects_empty`: `write_sf2([], ...)` → ValueError.
|
||||
- `test_write_sf2_odd_name_no_corruption`: tên "Nexus" (5 ký tự lẻ) → sf2utils không log "corrupted".
|
||||
19. Thêm `test_render_project_bit_depth` vào `tests/test_render_engine.py`: loop (16,24,32) → assert `sf.info(out).subtype == PCM_16/24/32`.
|
||||
20. Chạy:
|
||||
```
|
||||
python -m pytest tests/ -q
|
||||
→ 108 passed, 1 skipped, 1 failed
|
||||
```
|
||||
- 1 skipped: pyfluidsynth không có native lib trên máy (tests không yêu cầu).
|
||||
- 1 failed: `TestPluginManager::test_init` — **pre-existing** (machine VST3 override `C:\Program Files\Common Files\VST3` vs expected `/opt/daw_engine/vst3`).
|
||||
21. Xác nhận fail đó không do mình: `git stash` → chạy test → fail y hệt trên code sạch → `git stash pop`. ✅
|
||||
|
||||
## Bước 7 — Kiểm tra cuối & tài liệu
|
||||
|
||||
22. `git status` → các file modified/untracked đúng như dự kiến (không đụng file ngoài phạm vi; giữ nguyên các thay đổi pre-existing của user: `app.jsx`, `services/api.js`, `services/fluidsynthLoader.js`, `app.precompiled.js`).
|
||||
23. Viết `md/53_VSTI_AUTOSAMPLE_RENDER.md` (task) + `md/53_VSTI_AUTOSAMPLE_RENDER_WALKTHROUGH.md` (file này).
|
||||
|
||||
---
|
||||
|
||||
## Tổng kết diff
|
||||
|
||||
```
|
||||
M app/api/v1/plugins.py (bit_depth 3 requests + 3 sf.write + pass-through)
|
||||
M app/core/audio_editor.py (+32:"PCM_32" ở 2 subtype map)
|
||||
M app/core/render_engine.py (render_project bit_depth + subtype_map)
|
||||
M app/static/js/app.jsx (option 32 ×2 dropdown, encoder 32-bit ×2, fix 24-bit fallback)
|
||||
M tests/test_render_engine.py (+test_render_project_bit_depth)
|
||||
?? tools/autosample_vsti.py (mới — auto-sample VSTi → SF2/SF3)
|
||||
?? tests/test_autosampler.py (mới — 3 tests SF2 writer)
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
|
||||
hợp lệ mà sf2utils parse được (không cần VSTi/pedalboard)."""
|
||||
import os
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.autosample_vsti import write_sf2
|
||||
|
||||
sf2utils = pytest.importorskip("sf2utils.sf2parse")
|
||||
|
||||
|
||||
def _sine(note, sr=44100, dur=0.2):
|
||||
t = np.arange(int(sr * dur)) / sr
|
||||
f = 440.0 * 2 ** ((note - 69) / 12)
|
||||
return (np.sin(2 * np.pi * f * t) * 20000).astype(np.int16)
|
||||
|
||||
|
||||
def test_write_sf2_valid_structure(tmp_path):
|
||||
out = str(tmp_path / "test.sf2")
|
||||
samples = [{"note": n, "frames": _sine(n)} for n in (60, 62, 64)]
|
||||
write_sf2(out, samples, sample_rate=44100, name="Test")
|
||||
assert os.path.getsize(out) > 100
|
||||
with open(out, "rb") as f:
|
||||
head = f.read(12)
|
||||
assert head[:4] == b"RIFF"
|
||||
assert head[8:12] == b"sfbk"
|
||||
with open(out, "rb") as f:
|
||||
sf2 = sf2utils.Sf2File(f)
|
||||
real_samples = [s for s in sf2.samples if s.end > s.start]
|
||||
assert len(real_samples) == 3
|
||||
assert len(sf2.presets) == 2 # preset + terminator
|
||||
assert sf2.presets[0].bank == 0
|
||||
assert sf2.presets[0].preset == 0
|
||||
assert sf2.presets[0].name == "Test"
|
||||
for s in real_samples:
|
||||
assert s.end - s.start == 8820
|
||||
assert s.sample_rate == 44100
|
||||
|
||||
|
||||
def test_write_sf2_rejects_empty(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
write_sf2(str(tmp_path / "empty.sf2"), [])
|
||||
|
||||
|
||||
def test_write_sf2_odd_name_no_corruption(tmp_path, caplog):
|
||||
"""INFO text chunk chan — ten le (vd "Nexus") khong duoc lam mat can
|
||||
(sf2utils khong skip pad byte cua odd-size chunk)."""
|
||||
import logging
|
||||
out = str(tmp_path / "odd.sf2")
|
||||
write_sf2(out, [{"note": 60, "frames": _sine(60)}], 44100, name="Nexus")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with open(out, "rb") as f:
|
||||
sf2 = sf2utils.Sf2File(f)
|
||||
assert len([s for s in sf2.samples if s.end > s.start]) == 1
|
||||
assert not any("corrupted" in r.message for r in caplog.records)
|
||||
@@ -18,3 +18,17 @@ def test_render_session_container_empty():
|
||||
buf = engine.render_session_container(session, {}, 120.0, 4, 1000)
|
||||
assert buf.shape == (2, 1000)
|
||||
assert (buf == 0.0).all()
|
||||
|
||||
def test_render_project_bit_depth(tmp_path):
|
||||
"""bit_depth 24/32 → WAV PCM_24/PCM_32; mặc định vẫn PCM_16."""
|
||||
import soundfile as sf
|
||||
engine = PythonRenderEngine()
|
||||
project = {
|
||||
"metadata": {"bpm": 120, "time_signature_numerator": 4},
|
||||
"main_session": {"length_bars": 1.0, "tracks": []},
|
||||
"section_store": {},
|
||||
}
|
||||
for bd, subtype in [(16, "PCM_16"), (24, "PCM_24"), (32, "PCM_32")]:
|
||||
out = str(tmp_path / f"render_{bd}.wav")
|
||||
engine.render_project(project, out, bit_depth=bd)
|
||||
assert sf.info(out).subtype == subtype
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Auto-sample VSTi presets -> SF2/SF3 cho client-side WASM live preview.
|
||||
|
||||
Spec (Option 1): "a custom Python script" render preset qua tung not -> .sf3
|
||||
(~3-8MB) de FluidSynth WASM phat realtime voi 0% IPC. Script nay render not
|
||||
bang pedalboard (DUNG VSTi + preset nhu export) -> SF2 (16-bit mono PCM,
|
||||
1 zone/not). --sf3 chuyen tiep qua SoundFontConverter (can ffmpeg libvorbis).
|
||||
|
||||
Usage:
|
||||
python tools/autosample_vsti.py --instrument <plugin_id> --out out.sf2 \
|
||||
[--preset <path>] [--low 36 --high 96 --step 2] [--sf3]
|
||||
|
||||
Ket qua dat vao app/storage/soundfonts/ de client tai qua /soundfonts/download.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def write_sf2(out_path, samples, sample_rate=44100, name="AutoSampled"):
|
||||
"""Ghi SF2 toi gian hop le. samples: list[dict] voi note:int, frames:
|
||||
np.int16 mono 1D (audio da render cua not do)."""
|
||||
if not samples:
|
||||
raise ValueError("no samples")
|
||||
|
||||
def chunk(cid, data):
|
||||
out = bytearray(cid) + struct.pack("<I", len(data)) + data
|
||||
if len(data) % 2:
|
||||
out += b"\x00"
|
||||
return bytes(out)
|
||||
|
||||
def lst(ftype, chunks):
|
||||
return chunk(b"LIST", ftype + b"".join(chunks))
|
||||
|
||||
# --- sdta: PCM 16-bit mono, end exclusive (cung convention SF3 converter) ---
|
||||
pcm = bytearray()
|
||||
offsets = []
|
||||
for s in samples:
|
||||
frames = np.asarray(s["frames"], dtype=np.int16)
|
||||
offsets.append((len(pcm) // 2, frames.shape[0]))
|
||||
pcm += frames.tobytes()
|
||||
if len(pcm) % 2:
|
||||
pcm += b"\x00"
|
||||
sdta = lst(b"sdta", [chunk(b"smpl", bytes(pcm))])
|
||||
|
||||
# --- INFO ---
|
||||
inam = name.encode("ascii", "replace")
|
||||
if len(inam) % 2:
|
||||
inam += b"\x00" # INFO text chunks phai chan (sf2utils/strict parsers khong skip pad byte)
|
||||
info = lst(b"INFO", [
|
||||
chunk(b"ifil", struct.pack("<HH", 2, 1)),
|
||||
chunk(b"INAM", inam),
|
||||
chunk(b"iver", struct.pack("<HH", 2, 1)),
|
||||
])
|
||||
|
||||
# --- pdta ---
|
||||
n = len(samples)
|
||||
phdr = bytearray()
|
||||
phdr += name.encode("ascii", "replace")[:20].ljust(20, b"\x00")
|
||||
phdr += struct.pack("<HHHIII", 0, 0, 0, 0, 0, 0) # preset=0 bank=0 bagNdx=0 libr genre morph
|
||||
phdr += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
|
||||
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 0, 0)
|
||||
pmod = b"\x00" * 10
|
||||
pgen = struct.pack("<HH", 0, 0)
|
||||
inst = bytearray()
|
||||
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
|
||||
inst += b"EOI".ljust(20, b"\x00") + struct.pack("<H", 1)
|
||||
ibag = b"".join(struct.pack("<HH", i * 3, 0) for i in range(n + 1))
|
||||
imod = b"\x00" * 10
|
||||
igen = bytearray()
|
||||
for i, s in enumerate(samples):
|
||||
note = int(s["note"]) & 0xFF
|
||||
igen += struct.pack("<HH", 60, note | (note << 8)) # keyRange lo=hi=note
|
||||
igen += struct.pack("<HH", 69, i) # sampleID
|
||||
igen += struct.pack("<HH", 74, note) # overridingRootKey
|
||||
igen += struct.pack("<HH", 0, 0)
|
||||
shdr = bytearray()
|
||||
for i, s in enumerate(samples):
|
||||
note = int(s["note"])
|
||||
start, frames = offsets[i]
|
||||
shdr += f"note{note:03d}".encode()[:20].ljust(20, b"\x00")
|
||||
shdr += struct.pack("<IIIIi", start, start + frames, 0, 0, sample_rate)
|
||||
shdr += struct.pack("<BBH", note, 0, 0) # originalPitch correction sampleLink
|
||||
shdr += struct.pack("<H", 1) # sampleType: mono
|
||||
shdr += b"\x00" * 46 # terminator
|
||||
pdta = lst(b"pdta", [
|
||||
chunk(b"phdr", bytes(phdr)), chunk(b"pbag", pbag), chunk(b"pmod", pmod),
|
||||
chunk(b"pgen", pgen), chunk(b"inst", bytes(inst)), chunk(b"ibag", ibag),
|
||||
chunk(b"imod", imod), chunk(b"igen", bytes(igen)), chunk(b"shdr", bytes(shdr)),
|
||||
])
|
||||
|
||||
body = info + sdta + pdta
|
||||
out = bytearray(b"RIFF") + struct.pack("<I", 4 + len(body)) + b"sfbk" + body
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(out)
|
||||
return out_path
|
||||
|
||||
|
||||
def _render_note(vst, note, sr, dur, release, velocity):
|
||||
# pedalboard >= 0.9: MIDI messages la tuple (bytes raw MIDI, timestamp_seconds)
|
||||
messages = [
|
||||
(bytes([0x90, int(note) & 0x7F, max(0, min(127, velocity))]), 0.0),
|
||||
(bytes([0x80, int(note) & 0x7F, 0]), dur),
|
||||
]
|
||||
buf = vst(messages, sample_rate=sr, duration=dur + release, num_channels=2)
|
||||
mono = buf.mean(axis=0)
|
||||
peak = float(np.max(np.abs(mono)))
|
||||
if peak < 1e-5:
|
||||
return None
|
||||
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
||||
start = int(above[0]) if above.size else 0
|
||||
audio = mono[start:] / peak * 0.9
|
||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
ap.add_argument("--instrument", required=True, help="plugin_id da scan (Plugin Manager)")
|
||||
ap.add_argument("--out", required=True, help="duong dan .sf2 (hoac .sf3 voi --sf3)")
|
||||
ap.add_argument("--preset", default=None, help="duong dan preset .vstpreset/.fxp/.fxb")
|
||||
ap.add_argument("--low", type=int, default=36)
|
||||
ap.add_argument("--high", type=int, default=96)
|
||||
ap.add_argument("--step", type=int, default=2, help="buoc not (2 = nua cung)")
|
||||
ap.add_argument("--duration", type=float, default=2.5, help="giay giu not")
|
||||
ap.add_argument("--release", type=float, default=1.0, help="giay duoi sau note-off")
|
||||
ap.add_argument("--velocity", type=int, default=100)
|
||||
ap.add_argument("--sample-rate", type=int, default=44100)
|
||||
ap.add_argument("--sf3", action="store_true", help="convert SF2 -> SF3 sau khi sample")
|
||||
args = ap.parse_args()
|
||||
|
||||
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
|
||||
if not HAS_PEDALBOARD:
|
||||
sys.exit("pedalboard khong kha dung - khong auto-sample duoc")
|
||||
|
||||
vst = PluginManager().load_vst(args.instrument)
|
||||
if vst is None:
|
||||
sys.exit(f"Khong tim thay VSTi: {args.instrument} - hay Scan trong Plugin Manager truoc")
|
||||
if args.preset:
|
||||
apply_preset_to_plugin(vst, preset_path=args.preset)
|
||||
|
||||
samples = []
|
||||
for note in range(args.low, args.high + 1, args.step):
|
||||
frames = _render_note(vst, note, args.sample_rate, args.duration, args.release, args.velocity)
|
||||
if frames is None:
|
||||
print(f"note {note}: silent, bo qua")
|
||||
continue
|
||||
samples.append({"note": note, "frames": frames})
|
||||
print(f"note {note}: {frames.shape[0] / args.sample_rate:.2f}s")
|
||||
if not samples:
|
||||
sys.exit("Khong render duoc not nao (plugin silent?)")
|
||||
|
||||
write_sf2(args.out, samples, args.sample_rate, name=os.path.basename(args.instrument))
|
||||
print(f"wrote {args.out} ({os.path.getsize(args.out) // 1024} KB, {len(samples)} not)")
|
||||
|
||||
if args.sf3:
|
||||
from app.core.soundfont_converter import SoundFontConverter
|
||||
p = SoundFontConverter().convert_sf2_to_sf3(args.out)
|
||||
print("sf3:", p)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user