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:
@@ -113,6 +113,11 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
getSoundfontCatalog: () => apiRequest('/api/v1/plugins/soundfonts/catalog', { method: 'GET' }),
|
||||
listDefaultSoundfonts: () => apiRequest('/api/v1/plugins/default-soundfonts', { method: 'GET' }),
|
||||
listSoundfontInstruments: (sfId) => apiRequest(`/api/v1/plugins/soundfont-instruments/${sfId}`, { method: 'GET' }),
|
||||
// Native Host Bridge (daw_vst_bridge C++): trạng thái + load asset +
|
||||
// tail bridge.log (E1/E2/E5).
|
||||
bridgeStatus: () => apiRequest('/api/v1/bridge/status', { method: 'GET' }),
|
||||
bridgeLoad: (payload) => apiRequest('/api/v1/bridge/load', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
bridgeLog: (lines = 100) => apiRequest(`/api/v1/bridge/log?lines=${lines}`, { method: 'GET' }),
|
||||
getAIPresets: () => apiRequest('/api/v1/ai/presets', { method: 'GET' }),
|
||||
saveAIPreset: (preset) => apiRequest('/api/v1/ai/presets', { method: 'POST', body: JSON.stringify(preset) }),
|
||||
deleteAIPreset: (presetId) => apiRequest(`/api/v1/ai/presets/${presetId}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// app/static/js/services/audioRoutingEngine.js
|
||||
// Routes the native bridge audio node into the DAW graph:
|
||||
// bridge node -> track.sfEntry (FX rack chain) -> fader/pan -> masterBus
|
||||
// Falls back to masterBus.input when no per-track sfEntry exists.
|
||||
(function () {
|
||||
var engine = {
|
||||
_connected: false,
|
||||
_trackId: null,
|
||||
|
||||
isConnected: function () { return this._connected; },
|
||||
|
||||
/** bridgeNode = window.BridgeAudioNode; trackCtx = track node ({ sfEntry, gainNode }). */
|
||||
connect: function (bridgeNode, trackCtx, trackId) {
|
||||
if (!bridgeNode || !bridgeNode.getOutputNode) return false;
|
||||
this.disconnect();
|
||||
var dest = null;
|
||||
if (trackCtx && trackCtx.sfEntry) dest = trackCtx.sfEntry;
|
||||
else if (trackCtx && trackCtx.gainNode) dest = trackCtx.gainNode;
|
||||
else if (window.masterBus && window.masterBus.input) dest = window.masterBus.input;
|
||||
if (!dest) {
|
||||
console.warn('[AudioRoutingEngine] no destination (no trackCtx / masterBus)');
|
||||
return false;
|
||||
}
|
||||
if (!bridgeNode.isReady()) bridgeNode.init();
|
||||
bridgeNode.connect(dest);
|
||||
this._connected = true;
|
||||
this._trackId = trackId || null;
|
||||
console.log('[AudioRoutingEngine] bridge audio -> ' + (trackId || 'masterBus'));
|
||||
return true;
|
||||
},
|
||||
|
||||
disconnect: function () {
|
||||
if (window.BridgeAudioNode) window.BridgeAudioNode.disconnect();
|
||||
this._connected = false;
|
||||
this._trackId = null;
|
||||
}
|
||||
};
|
||||
|
||||
window.AudioRoutingEngine = engine;
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
// app/static/js/services/bridgeAudioNode.js
|
||||
// Audio sink: consumes 'bridge-audio' PCM frames (native bridge) and plays them
|
||||
// through a ScriptProcessorNode into the WebAudio graph (track FX / master bus).
|
||||
(function () {
|
||||
var RING_DEPTH = 8; // 8 blocks * 256 samples ~= 46ms anti-underrun
|
||||
var SP_BUFFER = 4096; // ScriptProcessor chunk (16 bridge blocks)
|
||||
var _queue = [];
|
||||
var _spn = null;
|
||||
var _gainNode = null;
|
||||
var _ctx = null;
|
||||
var _initialized = false;
|
||||
|
||||
function _getCtx() {
|
||||
if (_ctx) return _ctx;
|
||||
if (typeof getAudioContext === 'function') _ctx = getAudioContext();
|
||||
else if (window.__sharedAudioCtx) _ctx = window.__sharedAudioCtx;
|
||||
else _ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
function init(audioCtx) {
|
||||
if (_initialized) return;
|
||||
_ctx = audioCtx || _getCtx();
|
||||
_gainNode = _ctx.createGain();
|
||||
_gainNode.gain.value = 1.0;
|
||||
_spn = _ctx.createScriptProcessor(SP_BUFFER, 0, 2);
|
||||
_spn.onaudioprocess = function (e) {
|
||||
var L = e.outputBuffer.getChannelData(0);
|
||||
var R = e.outputBuffer.getChannelData(1);
|
||||
L.fill(0); R.fill(0);
|
||||
if (!_queue.length) return;
|
||||
var f = _queue.shift();
|
||||
L.set(f.l); R.set(f.r);
|
||||
};
|
||||
_spn.connect(_gainNode);
|
||||
_initialized = true;
|
||||
console.log('[BridgeAudioNode] initialized (ring depth ' + RING_DEPTH + ')');
|
||||
}
|
||||
|
||||
function onAudio(l, r) {
|
||||
if (!_initialized) init();
|
||||
_queue.push({ l: new Float32Array(l), r: new Float32Array(r) });
|
||||
if (_queue.length > RING_DEPTH) _queue.shift(); // drop oldest on overrun
|
||||
}
|
||||
|
||||
function flush() { _queue = []; }
|
||||
|
||||
function getOutputNode() { return _gainNode; }
|
||||
|
||||
function disconnect() {
|
||||
flush();
|
||||
if (_spn && _gainNode) {
|
||||
try { _spn.disconnect(); } catch (e) {}
|
||||
try { _gainNode.disconnect(); } catch (e) {}
|
||||
}
|
||||
// Reset state so a later init() can rebuild (AudioRoutingEngine re-connect).
|
||||
_initialized = false;
|
||||
_spn = null;
|
||||
_gainNode = null;
|
||||
_ctx = null;
|
||||
}
|
||||
|
||||
window.BridgeAudioNode = {
|
||||
init: init,
|
||||
onAudio: onAudio,
|
||||
flush: flush,
|
||||
getOutputNode: getOutputNode,
|
||||
connect: function (dest) { if (_gainNode) _gainNode.connect(dest); },
|
||||
disconnect: disconnect,
|
||||
isReady: function () { return _initialized; }
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,150 @@
|
||||
// app/static/js/services/nativeBridgeService.js
|
||||
// JS client for the C++ Native Host Bridge (daw_vst_bridge.exe).
|
||||
// All IPC goes through Tauri commands (Rust writes the shared memory) —
|
||||
// WebView2 JS cannot map Windows shared memory directly.
|
||||
(function () {
|
||||
// Must match native_bridge/include/INativeInstrument.h enum InstrumentType
|
||||
var INSTRUMENT_TYPE = { VST3: 0, VST2: 1, SF2: 2, SF3: 2, SFZ: 3 };
|
||||
|
||||
var service = {
|
||||
isBridgeConnected: false,
|
||||
activeInstrumentType: 'VST3',
|
||||
_audioCb: null,
|
||||
_statusCb: null,
|
||||
_tauri: function () { return window.__TAURI__; },
|
||||
|
||||
/** D10: UI subscribes to bridge connection changes. cb({connected}) or null to clear. */
|
||||
onStatusChange: function (cb) { this._statusCb = cb; },
|
||||
_notifyStatus: function (connected) {
|
||||
this.isBridgeConnected = !!connected;
|
||||
if (this._statusCb) this._statusCb({ connected: !!connected });
|
||||
},
|
||||
|
||||
_init: function () {
|
||||
if (!this._tauri()) {
|
||||
// Dev mode (Linux / plain browser): no Tauri -> log-only, app uses SonicSF.
|
||||
console.log('[BridgeService] Dev mode: no __TAURI__, native bridge disabled.');
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
try {
|
||||
window.__TAURI__.event.listen('bridge-audio', function (e) {
|
||||
if (self._audioCb) self._audioCb(e.payload.l, e.payload.r);
|
||||
});
|
||||
window.__TAURI__.event.listen('bridge-down', function () {
|
||||
self._notifyStatus(false);
|
||||
if (window.SonicMidiRouter) window.SonicMidiRouter.setBridgeConnected(false);
|
||||
if (window.AudioRoutingEngine) window.AudioRoutingEngine.disconnect();
|
||||
console.warn('[BridgeService] bridge-down event received.');
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] event listen failed:', e);
|
||||
}
|
||||
this.queryStatus();
|
||||
},
|
||||
|
||||
queryStatus: async function () {
|
||||
if (!this._tauri()) return { connected: false };
|
||||
try {
|
||||
var s = await window.__TAURI__.core.invoke('bridge_status');
|
||||
this._notifyStatus(!!s.connected);
|
||||
return s;
|
||||
} catch (e) {
|
||||
this._notifyStatus(false);
|
||||
return { connected: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 1. LOAD NEW INSTRUMENT INTO BRIDGE (VST3 / VST2 / SF2 / SF3 / SFZ)
|
||||
* instrumentType: 'VST3' | 'VST2' | 'SF2' | 'SF3' | 'SFZ'
|
||||
* channel: MIDI channel to assign this instrument to (A10 multi-instance).
|
||||
*/
|
||||
loadInstrument: async function (filePath, instrumentType, channel) {
|
||||
this.activeInstrumentType = instrumentType;
|
||||
console.log('[BridgeService] Loading ' + instrumentType + ' asset: ' + filePath + ' ch=' + channel);
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('load_native_instrument', {
|
||||
path: filePath,
|
||||
instrumentType: INSTRUMENT_TYPE[instrumentType] !== undefined ? INSTRUMENT_TYPE[instrumentType] : 0,
|
||||
channel: channel === undefined ? 0 : channel
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] loadInstrument failed:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 2. UNIFIED MIDI EVENT DISPATCH
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* velocity 0..1; sampleOffset in samples within the current audio block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB.
|
||||
*/
|
||||
dispatchMidiEvent: function (cmd, channel, pitch, velocity, sampleOffset, data2, data3) {
|
||||
if (!this._tauri()) return false;
|
||||
var safeVelocity = Math.floor(Math.min(1.0, Math.max(0.0, velocity)) * 127);
|
||||
var byteCmd;
|
||||
switch (cmd) {
|
||||
case 'CC': byteCmd = 0xB; break;
|
||||
case 'PROGRAM': byteCmd = 0xC; break;
|
||||
case 'PITCH_BEND': byteCmd = 0xE; break;
|
||||
default: byteCmd = cmd === 'NOTE_ON' ? 0x9 : 0x8; break;
|
||||
}
|
||||
window.__TAURI__.core.invoke('push_midi_event', {
|
||||
command: byteCmd,
|
||||
channel: channel,
|
||||
pitch: pitch,
|
||||
velocity: safeVelocity,
|
||||
data2: data2 === undefined ? 0 : data2,
|
||||
data3: data3 === undefined ? 0 : data3,
|
||||
sampleOffset: sampleOffset || 0
|
||||
}).catch(function (e) { console.warn('[BridgeService] push_midi_event:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 3. OPEN FLOATING CHILD WINDOW NATIVE GUI
|
||||
*/
|
||||
openNativeGUI: async function (pluginId) {
|
||||
if (!this._tauri()) return false;
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('open_vst_gui', { pluginId: pluginId });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[BridgeService] open_vst_gui:', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 4. TRANSPORT CONTROL ('play' | 'stop' | 'panic' | 'set_position')
|
||||
* playhead: sample position (A13) — used by 'play' and 'set_position'.
|
||||
*/
|
||||
transport: function (kind, playhead) {
|
||||
if (!this._tauri()) return false;
|
||||
var args = { kind: kind };
|
||||
if (playhead !== undefined) args.playhead = playhead;
|
||||
window.__TAURI__.core.invoke('transport_control', args)
|
||||
.catch(function (e) { console.warn('[BridgeService] transport_control:', e); });
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 5. AUDIO SINK: register consumer of PCM frames from the bridge.
|
||||
* cb(l: Float32Array, r: Float32Array)
|
||||
*/
|
||||
onAudio: function (cb) {
|
||||
this._audioCb = cb;
|
||||
}
|
||||
};
|
||||
|
||||
window.NativeBridgeService = service;
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { service._init(); });
|
||||
} else {
|
||||
service._init();
|
||||
}
|
||||
})();
|
||||
@@ -74,6 +74,9 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null
|
||||
window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
shouldRoute: function (synthEngine, isArmed) {
|
||||
try {
|
||||
// D4: bridge active → VSTi do native bridge host, KHÔNG route Carla
|
||||
// (tránh kép âm).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
if (!isArmed) return false;
|
||||
@@ -85,6 +88,8 @@ window.SonicCarlaMidi = window.SonicCarlaMidi || {
|
||||
// user đã chủ động bấm Play trên item đó).
|
||||
shouldRoutePlayback: function (synthEngine) {
|
||||
try {
|
||||
// D4: bridge active → KHÔNG route Carla (bridge host VSTi).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) return false;
|
||||
var c = window.SonicRuntime && window.SonicRuntime.capabilities;
|
||||
if (!c || !c.features || !c.features.carla_local) return false;
|
||||
var se = synthEngine || {};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// app/static/js/services/unifiedMidiRouter.js
|
||||
// Single MIDI entry point for Web MIDI keyboard + timeline playback.
|
||||
// bridge connected -> NativeBridgeService.dispatchMidiEvent (Rust SHM -> C++ bridge)
|
||||
// bridge down -> onFallback callback (app.jsx wires SonicSF/Carla path).
|
||||
(function () {
|
||||
var MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; // skip 9 (percussion)
|
||||
|
||||
var router = {
|
||||
bridgeConnected: false,
|
||||
onFallback: null, // function(cmd, channel, pitch, velocity) — set by app.jsx
|
||||
forceWasm: false, // debug: D10 "Ép dùng WASM"
|
||||
_channels: {}, // trackId -> { ch, percussion }
|
||||
_nextMelodicIdx: 0,
|
||||
|
||||
setBridgeConnected: function (flag) {
|
||||
this.bridgeConnected = !!flag;
|
||||
},
|
||||
|
||||
setForceWasm: function (flag) { this.forceWasm = !!flag; },
|
||||
|
||||
isBridgeActive: function () {
|
||||
return this.bridgeConnected && !this.forceWasm && !!window.NativeBridgeService;
|
||||
},
|
||||
|
||||
/** trackId -> MIDI channel; percussion (bank 128) -> ch 9. */
|
||||
allocateChannel: function (trackId, isPercussion) {
|
||||
if (this._channels[trackId]) return this._channels[trackId].ch;
|
||||
var ch;
|
||||
if (isPercussion) {
|
||||
ch = 9;
|
||||
} else {
|
||||
ch = MELODIC_CHANNELS[this._nextMelodicIdx % MELODIC_CHANNELS.length];
|
||||
this._nextMelodicIdx++;
|
||||
}
|
||||
this._channels[trackId] = { ch: ch, percussion: !!isPercussion };
|
||||
return ch;
|
||||
},
|
||||
|
||||
resetChannels: function () {
|
||||
this._channels = {};
|
||||
this._nextMelodicIdx = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* cmd: 'NOTE_ON' | 'NOTE_OFF' | 'CC' | 'PROGRAM' | 'PITCH_BEND'
|
||||
* channel: MIDI channel (or trackId -> allocateChannel first)
|
||||
* velocity: 0..1 (normalized); sampleOffset: samples within current block.
|
||||
* data2/data3 (A12): CC value / program / PB LSB|MSB — passed to bridge only.
|
||||
*/
|
||||
pushEvent: function (opts) {
|
||||
var cmd = opts.cmd, ch = opts.channel, pitch = opts.pitch;
|
||||
var vel = (opts.velocity === undefined ? 1.0 : opts.velocity);
|
||||
var sampleOffset = opts.sampleOffset || 0;
|
||||
if (typeof ch === 'string') ch = this.allocateChannel(ch, opts.percussion);
|
||||
if (ch === undefined || ch === null) ch = 0;
|
||||
if (this.isBridgeActive()) {
|
||||
window.NativeBridgeService.dispatchMidiEvent(cmd, ch, pitch, vel, sampleOffset, opts.data2, opts.data3);
|
||||
return;
|
||||
}
|
||||
if (this.onFallback) this.onFallback(cmd, ch, pitch, vel);
|
||||
},
|
||||
|
||||
/** Stop/panic -> bridge transport panic (flush all notes) + fallback local stopAll. */
|
||||
panic: function () {
|
||||
if (this.isBridgeActive() && window.NativeBridgeService.transport) {
|
||||
window.NativeBridgeService.transport('panic');
|
||||
}
|
||||
if (this.onFallback) this.onFallback('PANIC', 0, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
window.SonicMidiRouter = router;
|
||||
})();
|
||||
Reference in New Issue
Block a user