fix: GUI VST native window qua bridge + audio path bridge (SF2 preview/play câm)

- open_vst_gui: bo WebviewWindowBuilder/thread, chi push_control type=4 (hwnd=0 -> bridge tao window)
- main.cpp: create_native_vst_window (class SonicForge_Native_VST3_Class, 800x600, khong TOPMOST),
  tao trong ChannelWorker job, map guiWindows, capture arg2 by value (fix dangling)
- app.jsx: guard isBridgeActive() 8 cho -> bridge active thi moi note di router -> pushEvent -> bridge
  (truoc day SF2 cam vi HAS_PYFLUIDSYNTH=FALSE -> /soundfont-render 501; VST3 path cu dung nativeSf/Carla)
- E2E: SF2 NOTE_ON qua dispatchMidiEvent -> SHM peak 0.029745; Nexus GUI native hwnd OK (license/preset)
- docs: TASKS.md + TEST_NOTES.md ghi batch fix + ket qua; gitignore vendor/junk
This commit is contained in:
2026-08-12 22:12:43 +07:00
parent c3368d0a91
commit d8f8506077
37 changed files with 2170 additions and 419 deletions
+4 -2
View File
@@ -12,15 +12,17 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["webview-data-url"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Shared memory (SonicForge_DAW_IPC) for the Native Host Bridge
raw-window-handle = "0.6"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_MemoryManagement",
"Win32_Security",
"Win32_System_Memory",
] }
[profile.release]
@@ -1 +0,0 @@
PLACEHOLDER - duoc thay boi build_windows.ps1 buoc [4/6] (copy dist\daw_engine)
+97 -36
View File
@@ -12,7 +12,7 @@
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -23,6 +23,30 @@ use shm::{Shm, ShmState};
struct EngineProcess(Mutex<Option<CommandChild>>);
struct BridgeProcess(Mutex<Option<CommandChild>>);
/// Kill the currently managed bridge child (if any) — a restart MUST never
/// leave the old bridge running, else two daw_vst_bridge.exe race on the same
/// SHM (double instrument load, double OPEN_GUI, garbled control queue).
fn kill_bridge(app: &AppHandle) -> bool {
let state = app.state::<BridgeProcess>();
let mut guard = match state.0.lock() {
Ok(g) => g,
Err(_) => return false,
};
match guard.take() {
Some(child) => match child.kill() {
Ok(()) => {
println!("Native Host Bridge killed (restart)");
true
}
Err(e) => {
println!("Bridge kill failed: {e}");
false
}
},
None => false,
}
}
/// Audio frame pushed to the WebView (bridge SHM -> `bridge-audio` event).
#[derive(Clone, serde::Serialize)]
struct AudioFrame {
@@ -65,6 +89,8 @@ fn bridge_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
(res_dir.join("binaries").join(exe_name), "resource_dir/binaries (bundle)".into()),
(res_dir.join(exe_name), "resource_dir (bundle root)".into()),
(res_dir.join("binaries").join(triple_name), "resource_dir/binaries (triple name)".into()),
(res_dir.join(triple_name), "resource_dir (triple name, bundle)".into()),
(exe_dir.join(triple_name), "exe_dir (triple name, bundle)".into()),
(exe_dir.join(exe_name), "exe_dir (portable)".into()),
(exe_dir.join("..").join("resources").join("binaries").join(exe_name), "exe_dir/../resources/binaries (dev)".into()),
(exe_dir.join("..").join("resources").join(exe_name), "exe_dir/../resources (dev)".into()),
@@ -90,6 +116,9 @@ fn spawn_bridge(
found = Some((path.clone(), label.clone()));
}
}
// B3 DoD: dòng tóm tắt trạng thái bridge trong spawn.log
log_line.push_str(&format!("[daw_vst_bridge] exists={}
", found.is_some()));
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
@@ -109,8 +138,32 @@ fn spawn_bridge(
.env("SF_BLOCK_SIZE", "256")
.spawn()
{
Ok((_rx, child)) => {
Ok((mut rx, child)) => {
app.manage(BridgeProcess(Mutex::new(Some(child))));
// B3: redirect bridge stdout/stderr → %APPDATA%/SonicForgeDAW/logs/bridge.log
// (tauri-shell pipe rồi vứt rx; đọc event ghi file — E5 UI tail được).
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
let bridge_log = std::path::Path::new(&log_dir)
.join("SonicForgeDAW").join("logs").join("bridge.log");
if let Some(parent) = bridge_log.parent() {
let _ = std::fs::create_dir_all(parent);
}
tauri::async_runtime::spawn(async move {
use std::io::Write;
let mut f = match std::fs::OpenOptions::new().create(true).append(true).open(&bridge_log) {
Ok(f) => f,
Err(_) => return,
};
while let Some(ev) = rx.recv().await {
let bytes: Vec<u8> = match ev {
CommandEvent::Stdout(b) | CommandEvent::Stderr(b) => b,
_ => continue,
};
if let Ok(text) = String::from_utf8(bytes) {
let _ = writeln!(f, "{}", text.trim_end());
}
}
});
println!("Native Host Bridge started ({label}): {}", bridge_exe.display());
true
}
@@ -290,6 +343,9 @@ pub fn run() {
if advanced == Some(true) {
last_change = std::time::Instant::now();
down_emitted = false;
// Bridge đã sống lại sau restart — đóng "sự cố" để lần
// stall sau lại được hưởng 1 restart (mỗi incident 1 lần).
restart_attempts = 0;
let (l, r) = guard.as_ref().map(|s| s.read_audio()).unwrap_or(([0f32; shm::AUDIO_BLOCK_SIZE], [0f32; shm::AUDIO_BLOCK_SIZE]));
let _ = pump_handle.emit(
"bridge-audio",
@@ -308,6 +364,9 @@ pub fn run() {
let log_path = std::path::Path::new(&log_dir)
.join("SonicForgeDAW").join("logs").join("spawn.log");
let mut line = String::from("[tauri] bridge stalled 3s — restart attempt #1\n");
// FIX: kill bridge cũ trước — không được để 2 bridge
// cùng map SHM (race control queue / double load).
kill_bridge(&pump_handle);
if spawn_bridge(&pump_handle, &res_dir, &exe_dir, &mut line, &log_path) {
// count restarts into bridge.log (same file as stdout redirect)
let bridge_log = std::path::Path::new(&log_dir)
@@ -436,7 +495,7 @@ fn push_midi_event(
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
shm.push_midi(command, channel, pitch, velocity, data2, data3, sample_offset)
.then_some(())
.ok_or("midi queue full")
.ok_or_else(|| "midi queue full".to_string())
}
#[tauri::command]
@@ -446,41 +505,27 @@ fn load_native_instrument(app: AppHandle, path: String, instrument_type: u8, cha
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
shm.push_control(2, instrument_type as u32, 0, channel as u32, &path)
.then_some(())
.ok_or("control queue full")
.ok_or_else(|| "control queue full".to_string())
}
#[tauri::command]
fn open_vst_gui(app: AppHandle, plugin_id: String) -> Result<(), String> {
// B9: child WebviewWindow acts as the VST GUI surface. The bridge receives
// its HWND (control type=4, arg1) so the VST3 editor can be attached (A7).
let url = tauri::WebviewUrl::External(
tauri::Url::parse("data:text/html,<h2>VST GUI</h2><p>Editor attaches from daw_vst_bridge (A7).</p>")
.map_err(|e| e.to_string())?,
);
let win = tauri::WebviewWindowBuilder::new(&app, format!("vst-{}", plugin_id), url)
.title(format!("VST — {}", plugin_id))
.inner_size(800.0, 600.0)
.build()
.map_err(|e| e.to_string())?;
// B9: bridge tự tạo native Win32 window cho editor VST3 (không qua
// WebView2 — HTML window cũ vẽ ĐÈ lên GUI plugin). push_control(4,0,0,0)
// với hwnd=0 báo bridge tạo window riêng trên ChannelWorker thread.
let state = app.state::<ShmState>();
let guard = state.0.lock().map_err(|e| e.to_string())?;
let shm = guard.as_ref().ok_or("bridge shm unavailable")?;
// ponytail: HWND truncated to u32 (HWNDs fit in practice); window-close →
// close_gui wiring lands together with A7 on Windows.
#[cfg(windows)]
let hwnd = {
use tauri::raw_window_handle::{HasWindowHandle, RawWindowHandle};
match win.window_handle().map(|h| h.as_raw()) {
Ok(RawWindowHandle::Win32(w)) => w.hwnd as usize as u32,
_ => 0u32,
}
};
#[cfg(not(windows))]
let hwnd = 0u32;
shm.push_control(4, 0, hwnd, 0, &plugin_id)
.then_some(())
.ok_or("control queue full")
let lock = state.0.lock();
match lock {
Ok(guard) => match guard.as_ref() {
Some(shm) => {
let ok = shm.push_control(4, 0, 0, 0, &plugin_id);
eprintln!("open_vst_gui: push_control type=4 hwnd=0 (bridge-native) ok={}", ok);
}
None => eprintln!("open_vst_gui: bridge shm unavailable"),
},
Err(e) => eprintln!("open_vst_gui: shm lock poisoned: {}", e),
}
Ok(())
}
#[derive(serde::Serialize)]
@@ -494,11 +539,18 @@ struct BridgeStatus {
block_size: u32,
}
/// %APPDATA%/SonicForgeDAW/ipc — cùng dir engine (plugins.py) đọc cho
/// /api/v1/bridge/status; None khi APPDATA unset (non-Windows).
fn bridge_ipc_dir() -> Option<PathBuf> {
let appdata = std::env::var("APPDATA").ok()?;
Some(Path::new(&appdata).join("SonicForgeDAW").join("ipc"))
}
#[tauri::command]
fn bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
let state = app.state::<ShmState>();
let guard = state.0.lock().map_err(|e| e.to_string())?;
Ok(match guard.as_ref() {
let status = match guard.as_ref() {
Some(shm) => BridgeStatus {
connected: true,
shm_name: shm::SHM_NAME,
@@ -517,7 +569,16 @@ fn bridge_status(app: AppHandle) -> Result<BridgeStatus, String> {
sample_rate: 0,
block_size: 0,
},
})
};
// E1: ghi JSON → engine /api/v1/bridge/status (badge Plugin Manager phải
// phản ánh đúng bridge thật — trước đây file không tồn tại → luôn OFF).
if let Some(dir) = bridge_ipc_dir() {
let _ = std::fs::create_dir_all(&dir);
if let Ok(json) = serde_json::to_string(&status) {
let _ = std::fs::write(dir.join("bridge_status"), json);
}
}
Ok(status)
}
#[tauri::command]
@@ -533,5 +594,5 @@ fn transport_control(app: AppHandle, kind: String, playhead: Option<u32>) -> Res
};
shm.push_control(3, arg0, arg1, 0, "")
.then_some(())
.ok_or("control queue full")
.ok_or_else(|| "control queue full".to_string())
}
+16 -4
View File
@@ -8,7 +8,7 @@ use std::ffi::c_void;
use std::ptr;
use std::sync::Mutex;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
use windows_sys::Win32::System::MemoryManagement::{
use windows_sys::Win32::System::Memory::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, PAGE_READWRITE,
};
@@ -83,13 +83,13 @@ impl Shm {
return None;
}
let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, 0) };
if view.is_null() {
if view.Value.is_null() {
unsafe { CloseHandle(handle) };
return None;
}
Some(Shm {
handle,
view: view as *mut SharedAudioBufferIPC,
view: view.Value as *mut SharedAudioBufferIPC,
})
}
@@ -156,7 +156,7 @@ impl Shm {
impl Drop for Shm {
fn drop(&mut self) {
if !self.view.is_null() {
unsafe { UnmapViewOfFile(self.view as *const c_void) };
unsafe { UnmapViewOfFile(windows_sys::Win32::System::Memory::MEMORY_MAPPED_VIEW_ADDRESS { Value: self.view as *mut c_void }) };
}
unsafe { CloseHandle(self.handle) };
}
@@ -164,3 +164,15 @@ impl Drop for Shm {
/// Process-wide shared-memory state for Tauri.
pub struct ShmState(pub Mutex<Option<Shm>>);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_matches_c_header() {
// native_bridge/tests/shm_selfcheck.cpp asserts the same sizes.
assert_eq!(std::mem::size_of::<MidiEventIPC>(), 12);
assert_eq!(std::mem::size_of::<ControlEventIPC>(), 1040);
assert_eq!(std::mem::size_of::<SharedAudioBufferIPC>(), 11160);
}
}
+2 -1
View File
@@ -33,7 +33,8 @@
"icons/favicon.ico"
],
"resources": {
"resources/daw_engine": "daw_engine/"
"resources/daw_engine": "daw_engine/",
"binaries/libfluidsynth-3.dll": "libfluidsynth-3.dll"
},
"externalBin": ["binaries/daw_vst_bridge"],
"windows": {
+38 -16
View File
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8">
@@ -12,23 +12,45 @@
<body>
<div id="msg">Đang khởi động SonicForge Engine…</div>
<script>
// Engine (sidecar) có thể cần vài giây để boot (import librosa/pedalboard).
// Thử health-check trên dải port 8000-8010, redirect tới port đầu tiên OK.
// Frontend host (Tauri asset page). KHÔNG location.href redirect: chuyển
// sang http://127.0.0.1:8000 sẽ rời Tauri context -> mất window.__TAURI__
// -> NativeBridgeService vô hiệu (bridge-audio/load_instrument không dùng
// được; MIDI rơi về fallback WASM/Carla). Thay vào đó: quét port engine
// (8000-8010), nạp toàn bộ UI engine qua document.write trên CÙNG document
// (origin tauri:// giữ nguyên -> __TAURI__ sống), base href + API_BASE_URL
// trỏ engine HTTP (CORS * đã bật).
(async function () {
var tries = 0;
while (tries < 90) { // ~60s tối đa
for (var p = 8000; p <= 8010; p++) {
try {
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
if (r.ok) { location.href = 'http://127.0.0.1:' + p + '/'; return; }
} catch (e) { /* port chưa mở */ }
}
tries++;
await new Promise(function (res) { setTimeout(res, 700); });
var base = null;
for (var p = 8000; p <= 8010 && !base; p++) {
try {
var r = await fetch('http://127.0.0.1:' + p + '/health', { cache: 'no-store' });
if (r.ok) base = 'http://127.0.0.1:' + p + '/';
} catch (e) { /* port chưa mở */ }
}
document.getElementById('msg').textContent =
'Không tìm thấy SonicForge Engine (port 80008010). Hãy đóng và chạy lại ứng dụng. ' +
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
if (!base) {
document.getElementById('msg').textContent =
'Không tìm thấy SonicForge Engine (port 80008010). Hãy đóng và chạy lại ứng dụng. ' +
'Nếu lặp lại, xem log: %APPDATA%\\SonicForgeDAW\\logs\\engine.log';
return;
}
var html;
try {
var res = await fetch(base, { cache: 'no-store' });
html = await res.text();
} catch (e) {
document.getElementById('msg').textContent = 'Engine không phản hồi: ' + e;
return;
}
// base href để /static, /api resolve về engine; API_BASE_URL override
// location.origin (origin thật là tauri://). KHÔNG trailing slash trong
// API_BASE_URL: api.js nối `${API_BASE_URL}/api/...` -> double slash
// `//api/...` -> FastAPI 404 "Not Found" (login báo not found).
html = html.replace('<head>',
'<head>\n<base href="' + base + '">\n' +
'<script>window.API_BASE_URL = "' + base.replace(/\/$/, '') + '";<\/script>');
document.open();
document.write(html);
document.close();
})();
</script>
</body>