feat: native host bridge integration — C++ bridge, Rust SHM, JS routing, build scripts, docs

- native_bridge/: InstrumentEngineManager multi-channel, sample-accurate, CC/program/pitchbend, transport, Vst3Instrument stub (HAVE_VST3SDK)
- src-tauri: shm.rs, bridge spawn + audio pump + health monitor, open_vst_gui, externalBin, commands
- app: UnifiedMidiRouter, NativeBridgeService, bridgeAudioNode, audioRoutingEngine, Plugin Manager UI, Bridge/WASM indicator, set_position sync
- build: 3 ps1 (force-added, build/ ignored), verify_bundle --check-bridge, CI workflow
- docs: TASKS.md, TEST_NOTES.md (Windows verify checklist), install/report updates
This commit is contained in:
locpham
2026-08-11 23:37:29 +07:00
parent aae0b05473
commit c3368d0a91
37 changed files with 2970 additions and 80 deletions
+5
View File
@@ -17,6 +17,11 @@ 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
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_MemoryManagement",
] }
[profile.release]
strip = true
+305 -1
View File
@@ -10,14 +10,25 @@
// "daw_engine/" — đúng layout lib.rs chờ.
// Ngoài ra lib.rs còn dò THÊM các vị trí fallback (legacy/portable/dev)
// và ghi đầy đủ diagnostic vào %APPDATA%/SonicForgeDAW/logs/spawn.log.
use tauri::Manager;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_dialog::{DialogExt, FilePath};
use tauri_plugin_shell::process::CommandChild;
use tauri_plugin_shell::ShellExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
mod shm;
use shm::{Shm, ShmState};
struct EngineProcess(Mutex<Option<CommandChild>>);
struct BridgeProcess(Mutex<Option<CommandChild>>);
/// Audio frame pushed to the WebView (bridge SHM -> `bridge-audio` event).
#[derive(Clone, serde::Serialize)]
struct AudioFrame {
l: Vec<f32>,
r: Vec<f32>,
}
/// Các vị trí có thể chứa daw_engine, theo thứ tự ưu tiên.
fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
@@ -46,6 +57,78 @@ fn engine_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
]
}
/// Các vị trí có thể chứa daw_vst_bridge (Native Host Bridge sidecar).
fn bridge_candidates(res_dir: &Path, exe_dir: &Path) -> Vec<(PathBuf, String)> {
let exe_name = if cfg!(windows) { "daw_vst_bridge.exe" } else { "daw_vst_bridge" };
let triple_name = if cfg!(windows) { "daw_vst_bridge-x86_64-pc-windows-msvc.exe" } else { exe_name };
vec![
(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()),
(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()),
]
}
/// Spawn daw_vst_bridge.exe sidecar (B3/B8) with SHM + watchdog + audio env.
/// Appends candidate diagnostics to spawn.log; manages `BridgeProcess`.
/// Returns true when the child started.
fn spawn_bridge(
app: &AppHandle,
res_dir: &Path,
exe_dir: &Path,
log_line: &mut String,
spawn_log_path: &Path,
) -> bool {
let candidates = bridge_candidates(res_dir, exe_dir);
let mut found: Option<(PathBuf, String)> = None;
for (path, label) in &candidates {
let exists = path.exists();
log_line.push_str(&format!(" [bridge {label}] {} exists={}\n", path.display(), exists));
if found.is_none() && exists {
found = Some((path.clone(), label.clone()));
}
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(spawn_log_path)
{
use std::io::Write;
let _ = f.write_all(log_line.as_bytes());
}
match found {
Some((bridge_exe, label)) => {
match app
.shell()
.command(&bridge_exe)
.env("SF_SHM_NAME", shm::SHM_NAME)
.env("SF_PARENT_PID", std::process::id().to_string())
.env("SF_SAMPLE_RATE", "44100") // B8: JS resampler handles mismatches (C5)
.env("SF_BLOCK_SIZE", "256")
.spawn()
{
Ok((_rx, child)) => {
app.manage(BridgeProcess(Mutex::new(Some(child))));
println!("Native Host Bridge started ({label}): {}", bridge_exe.display());
true
}
Err(e) => {
println!("Failed to spawn daw_vst_bridge {bridge_exe:?}: {e}");
app.manage(BridgeProcess(Mutex::new(None)));
false
}
}
}
None => {
println!("daw_vst_bridge binary not found — app will use WASM fallback");
app.manage(BridgeProcess(Mutex::new(None)));
false
}
}
}
fn list_dir_snippet(dir: &Path) -> String {
let mut s = String::new();
match std::fs::read_dir(dir) {
@@ -73,6 +156,13 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
push_midi_event,
load_native_instrument,
open_vst_gui,
bridge_status,
transport_control
])
.setup(|app| {
let res_dir = app
.path()
@@ -157,6 +247,91 @@ pub fn run() {
}
}
// ── Native Host Bridge (daw_vst_bridge.exe): SHM + sidecar spawn ──
let shm_created = Shm::create();
println!(
"SharedMemory {}: {}",
shm::SHM_NAME,
if shm_created.is_some() { "created" } else { "unavailable (non-windows or error)" }
);
app.manage(ShmState(Mutex::new(shm_created)));
// Append bridge diagnostics to spawn.log and spawn the sidecar (B3).
let app_handle = app.handle().clone();
let _ = spawn_bridge(&app_handle, &res_dir, &exe_dir, &mut log_line, &spawn_log_path);
// ── Audio pump: bridge SHM -> WebView `bridge-audio` events ──
// B10 health: if bridgeWriteIndex stalls for 3s the bridge is dead —
// respawn once, then emit `bridge-down` so the UI falls back to WASM.
let pump_handle = app.handle().clone();
std::thread::spawn(move || {
let mut last_index: u32 = 0;
let mut last_change = std::time::Instant::now();
let mut down_emitted = false;
let mut restart_attempts = 0u32;
loop {
let state = pump_handle.state::<ShmState>();
let guard = match state.0.lock() {
Ok(g) => g,
Err(_) => {
std::thread::sleep(std::time::Duration::from_millis(10));
continue;
}
};
let advanced = guard.as_ref().map(|shm| {
let idx = shm.write_index();
if idx != last_index {
last_index = idx;
true
} else {
false
}
});
if advanced == Some(true) {
last_change = std::time::Instant::now();
down_emitted = false;
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",
AudioFrame { l: l.to_vec(), r: r.to_vec() },
);
} else if last_change.elapsed() >= std::time::Duration::from_secs(3) && !down_emitted {
down_emitted = true;
if restart_attempts == 0 {
restart_attempts += 1;
let res_dir = pump_handle.path().resource_dir().unwrap_or_default();
let exe_dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
.unwrap_or_default();
let log_dir = std::env::var("APPDATA").unwrap_or_else(|_| ".".into());
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");
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)
.join("SonicForgeDAW").join("logs").join("bridge.log");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true).append(true).open(&bridge_log)
{
use std::io::Write;
let _ = f.write_all(b"[tauri] bridge restarted (attempt 1)\n");
}
last_change = std::time::Instant::now();
down_emitted = false;
} else {
let _ = pump_handle.emit("bridge-down", ());
}
} else {
let _ = pump_handle.emit("bridge-down", ());
}
}
drop(guard);
std::thread::sleep(std::time::Duration::from_millis(5));
}
});
// ── Native folder picker bridge (folder picker cho Plugin Manager) ──
// UI chay tren http://127.0.0.1:8000 (engine) — KHONG co __TAURI__
// (WebView2 cung khong ho tro window.prompt) → engine goi qua file
@@ -226,8 +401,137 @@ pub fn run() {
let _ = child.kill();
println!("daw_engine sidecar terminated.");
}
// Terminate Native Host Bridge khi DAW window dong
let bridge_child = window
.state::<BridgeProcess>()
.0
.lock()
.ok()
.and_then(|mut lock| lock.take());
if let Some(child) = bridge_child {
let _ = child.kill();
println!("daw_vst_bridge sidecar terminated.");
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// ── Native Host Bridge Tauri commands ──────────────────────────────────────
#[tauri::command]
fn push_midi_event(
app: AppHandle,
command: u8,
channel: u8,
pitch: u8,
velocity: u8,
data2: u8,
data3: u8,
sample_offset: u32,
) -> Result<(), String> {
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")?;
shm.push_midi(command, channel, pitch, velocity, data2, data3, sample_offset)
.then_some(())
.ok_or("midi queue full")
}
#[tauri::command]
fn load_native_instrument(app: AppHandle, path: String, instrument_type: u8, channel: u8) -> Result<(), String> {
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")?;
shm.push_control(2, instrument_type as u32, 0, channel as u32, &path)
.then_some(())
.ok_or("control queue full")
}
#[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())?;
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")
}
#[derive(serde::Serialize)]
struct BridgeStatus {
connected: bool,
shm_name: &'static str,
write_index: u32,
block_timestamp: u64,
shm_size_bytes: usize,
sample_rate: u32,
block_size: u32,
}
#[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() {
Some(shm) => BridgeStatus {
connected: true,
shm_name: shm::SHM_NAME,
write_index: shm.write_index(),
block_timestamp: shm.block_timestamp(),
shm_size_bytes: std::mem::size_of::<shm::SharedAudioBufferIPC>(),
sample_rate: 44100,
block_size: shm::AUDIO_BLOCK_SIZE as u32,
},
None => BridgeStatus {
connected: false,
shm_name: shm::SHM_NAME,
write_index: 0,
block_timestamp: 0,
shm_size_bytes: 0,
sample_rate: 0,
block_size: 0,
},
})
}
#[tauri::command]
fn transport_control(app: AppHandle, kind: String, playhead: Option<u32>) -> Result<(), String> {
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")?;
// arg0: 0=STOP (flush), 1=PLAY, 2=SET_POSITION (seek, no flush)
let (arg0, arg1) = match kind.as_str() {
"play" => (1, playhead.unwrap_or(0)),
"set_position" => (2, playhead.unwrap_or(0)),
_ => (0, playhead.unwrap_or(0)), // stop / panic
};
shm.push_control(3, arg0, arg1, 0, "")
.then_some(())
.ok_or("control queue full")
}
+166
View File
@@ -0,0 +1,166 @@
// src-tauri/src/shm.rs
// Shared memory `SonicForge_DAW_IPC` — created by the DAW, opened by the
// C++ bridge (native_bridge). Layout MUST match native_bridge/include/
// SharedMemoryIPC.h (verified by native_bridge/tests/shm_selfcheck.cpp).
#![cfg_attr(not(windows), allow(dead_code))]
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::{
CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, PAGE_READWRITE,
};
pub const SHM_NAME: &str = "SonicForge_DAW_IPC";
pub const AUDIO_BLOCK_SIZE: usize = 256;
pub const MIDI_QUEUE_CAP: usize = 64;
pub const CONTROL_QUEUE_CAP: usize = 8;
pub const CONTROL_PATH_MAX: usize = 1024;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct MidiEventIPC {
pub command: u8,
pub channel: u8,
pub pitch: u8,
pub velocity: u8,
pub data2: u8, // CC value / program / PB LSB
pub data3: u8, // PB MSB (0xE only)
pub reserved: [u8; 2],
pub sample_offset: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ControlEventIPC {
pub ctype: u32,
pub arg0: u32,
pub arg1: u32,
pub channel: u32, // LOAD: MIDI channel to assign (A10)
pub arg2: [u8; CONTROL_PATH_MAX],
}
#[repr(C)]
pub struct SharedAudioBufferIPC {
pub client_read_index: u32,
pub bridge_write_index: u32,
pub master_left: [f32; AUDIO_BLOCK_SIZE],
pub master_right: [f32; AUDIO_BLOCK_SIZE],
pub block_timestamp: u64,
pub midi_queue: [MidiEventIPC; MIDI_QUEUE_CAP],
pub midi_queue_count: u32,
pub control_queue: [ControlEventIPC; CONTROL_QUEUE_CAP],
pub control_queue_count: u32,
}
pub struct Shm {
handle: HANDLE,
view: *mut SharedAudioBufferIPC,
}
unsafe impl Send for Shm {}
impl Shm {
/// Create (or open) the mapping. Returns None on non-Windows (dev fallback).
pub fn create() -> Option<Self> {
if !cfg!(windows) {
return None;
}
let name_wide: Vec<u16> = SHM_NAME.encode_utf16().chain(std::iter::once(0)).collect();
let size = std::mem::size_of::<SharedAudioBufferIPC>() as u64;
let handle = unsafe {
CreateFileMappingW(
HANDLE::default(),
ptr::null(),
PAGE_READWRITE,
(size >> 32) as u32,
size as u32,
name_wide.as_ptr(),
)
};
if handle == HANDLE::default() {
return None;
}
let view = unsafe { MapViewOfFile(handle, FILE_MAP_ALL_ACCESS, 0, 0, 0) };
if view.is_null() {
unsafe { CloseHandle(handle) };
return None;
}
Some(Shm {
handle,
view: view as *mut SharedAudioBufferIPC,
})
}
fn ipc(&self) -> &mut SharedAudioBufferIPC {
unsafe { &mut *self.view }
}
pub fn push_midi(&self, cmd: u8, channel: u8, pitch: u8, velocity: u8, data2: u8, data3: u8, sample_offset: u32) -> bool {
let ipc = self.ipc();
if ipc.midi_queue_count as usize >= MIDI_QUEUE_CAP {
return false;
}
let i = ipc.midi_queue_count as usize;
ipc.midi_queue[i] = MidiEventIPC {
command: cmd,
channel,
pitch,
velocity,
data2,
data3,
reserved: [0u8; 2],
sample_offset,
};
ipc.midi_queue_count += 1;
true
}
pub fn push_control(&self, ctype: u32, arg0: u32, arg1: u32, channel: u32, path: &str) -> bool {
let ipc = self.ipc();
if ipc.control_queue_count as usize >= CONTROL_QUEUE_CAP {
return false;
}
let mut ev = ControlEventIPC {
ctype,
arg0,
arg1,
channel,
arg2: [0u8; CONTROL_PATH_MAX],
};
let bytes = path.as_bytes();
let n = bytes.len().min(CONTROL_PATH_MAX - 1);
ev.arg2[..n].copy_from_slice(&bytes[..n]);
// LOAD relies on C++ side strnlen() — arg1 is free for TRANSPORT playhead.
let i = ipc.control_queue_count as usize;
ipc.control_queue[i] = ev;
ipc.control_queue_count += 1;
true
}
pub fn read_audio(&self) -> ([f32; AUDIO_BLOCK_SIZE], [f32; AUDIO_BLOCK_SIZE]) {
let ipc = self.ipc();
(ipc.master_left, ipc.master_right)
}
pub fn write_index(&self) -> u32 {
self.ipc().bridge_write_index
}
pub fn block_timestamp(&self) -> u64 {
self.ipc().block_timestamp
}
}
impl Drop for Shm {
fn drop(&mut self) {
if !self.view.is_null() {
unsafe { UnmapViewOfFile(self.view as *const c_void) };
}
unsafe { CloseHandle(self.handle) };
}
}
/// Process-wide shared-memory state for Tauri.
pub struct ShmState(pub Mutex<Option<Shm>>);
+1 -1
View File
@@ -35,7 +35,7 @@
"resources": {
"resources/daw_engine": "daw_engine/"
},
"externalBin": [],
"externalBin": ["binaries/daw_vst_bridge"],
"windows": {
"nsis": {
"installerHooks": "hooks.nsh"