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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user