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:
2026-08-11 12:09:02 +07:00
parent f8cb2c6a5e
commit 7293d7ac7e
12 changed files with 2266 additions and 18 deletions
+10 -4
View File
@@ -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)}")
+2 -2
View File
@@ -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)
+5 -3
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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 });
}
};
})();
+2 -2
View File
@@ -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';
}