diff --git a/packages/desktopbridge/index.mjs b/packages/desktopbridge/index.mjs index 2cc9db3d..591bbe34 100644 --- a/packages/desktopbridge/index.mjs +++ b/packages/desktopbridge/index.mjs @@ -6,3 +6,4 @@ This program is free software: you can redistribute it and/or modify it under th export * from './midibridge.mjs'; export * from './utils.mjs'; +export * from './oscbridge.mjs'; diff --git a/packages/desktopbridge/oscbridge.mjs b/packages/desktopbridge/oscbridge.mjs new file mode 100644 index 00000000..135a2b02 --- /dev/null +++ b/packages/desktopbridge/oscbridge.mjs @@ -0,0 +1,43 @@ +import { parseNumeral, Pattern } from '@strudel.cycles/core'; +import { Invoke } from './utils.mjs'; + +Pattern.prototype.osc = function () { + return this.onTrigger(async (time, hap, currentTime, cps = 1) => { + hap.ensureObjectValue(); + const cycle = hap.wholeOrPart().begin.valueOf(); + const delta = hap.duration.valueOf(); + const controls = Object.assign({}, { cps, cycle, delta }, hap.value); + // make sure n and note are numbers + controls.n && (controls.n = parseNumeral(controls.n)); + controls.note && (controls.note = parseNumeral(controls.note)); + + const params = []; + + const timestamp = Math.round(Date.now() + (time - currentTime) * 1000); + + Object.keys(controls).forEach((key) => { + const val = controls[key]; + const value = typeof val === 'number' ? val.toString() : val; + + if (value == null) { + return; + } + params.push({ + name: key, + value, + valueisnumber: typeof val === 'number', + }); + }); + + const messagesfromjs = []; + if (params.length) { + messagesfromjs.push({ target: '/dirt/play', timestamp, params }); + } + + if (messagesfromjs.length) { + setTimeout(() => { + Invoke('sendosc', { messagesfromjs }); + }); + } + }); +}; diff --git a/packages/desktopbridge/package.json b/packages/desktopbridge/package.json index bdeda2d9..c2acba9e 100644 --- a/packages/desktopbridge/package.json +++ b/packages/desktopbridge/package.json @@ -1,7 +1,7 @@ { "name": "@strudel/desktopbridge", "version": "0.1.0", - "description": "send midi messages between the JS and Tauri (Rust) sides of the Studel desktop app", + "description": "tools/shims for communicating between the JS and Tauri (Rust) sides of the Studel desktop app", "main": "index.mjs", "type": "module", "repository": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90767fec..38fce20b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -98,6 +98,7 @@ name = "app" version = "0.1.0" dependencies = [ "midir", + "rosc", "serde", "serde_json", "tauri", @@ -1557,6 +1558,12 @@ dependencies = [ "windows 0.43.0", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -1629,6 +1636,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2173,6 +2190,16 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +[[package]] +name = "rosc" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e63d9e6b0d090be1485cf159b1e04c3973d2d3e1614963544ea2ff47a4a981" +dependencies = [ + "byteorder", + "nom", +] + [[package]] name = "rustc-demangle" version = "0.1.23" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 22170cac..35aab489 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ serde = { version = "1.0", features = ["derive"] } tauri = { version = "1.4.0", features = ["fs-all"] } midir = "0.9.1" tokio = { version = "1.29.0", features = ["full"] } +rosc = "0.10.1" [features] # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0643cefd..c91e37df 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,20 +2,27 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod midibridge; +mod oscbridge; use tokio::sync::mpsc; use tokio::sync::Mutex; fn main() { - let (async_input_transmitter, async_input_receiver) = mpsc::channel(1); - let (async_output_transmitter, async_output_receiver) = mpsc::channel(1); + let (async_input_transmitter_midi, async_input_receiver_midi) = mpsc::channel(1); + let (async_output_transmitter_midi, async_output_receiver_midi) = mpsc::channel(1); + let (async_input_transmitter_osc, async_input_receiver_osc) = mpsc::channel(1); + let (async_output_transmitter_osc, async_output_receiver_osc) = mpsc::channel(1); tauri::Builder ::default() .manage(midibridge::AsyncInputTransmit { - inner: Mutex::new(async_input_transmitter), + inner: Mutex::new(async_input_transmitter_midi), }) - .invoke_handler(tauri::generate_handler![midibridge::sendmidi]) + .manage(oscbridge::AsyncInputTransmit { + inner: Mutex::new(async_input_transmitter_osc), + }) + .invoke_handler(tauri::generate_handler![midibridge::sendmidi, oscbridge::sendosc]) .setup(|_app| { - midibridge::init(async_input_receiver, async_output_receiver, async_output_transmitter); + midibridge::init(async_input_receiver_midi, async_output_receiver_midi, async_output_transmitter_midi); + oscbridge::init(async_input_receiver_osc, async_output_receiver_osc, async_output_transmitter_osc); Ok(()) }) .run(tauri::generate_context!()) diff --git a/src-tauri/src/oscbridge.rs b/src-tauri/src/oscbridge.rs new file mode 100644 index 00000000..c8a1ee09 --- /dev/null +++ b/src-tauri/src/oscbridge.rs @@ -0,0 +1,150 @@ +use rosc::{ encoder, OscTime }; +use rosc::{ OscMessage, OscPacket, OscType, OscBundle }; +use std::net::UdpSocket; + +use std::time::Duration; +use std::sync::Arc; +use tokio::sync::{ mpsc, Mutex }; +use serde::Deserialize; +use std::thread::sleep; +pub struct OscMsg { + pub msg_buf: Vec, + pub timestamp: u64, +} + +pub struct AsyncInputTransmit { + pub inner: Mutex>>, +} + +const UNIX_OFFSET: u64 = 2_208_988_800; // 70 years in seconds +const TWO_POW_32: f64 = (u32::MAX as f64) + 1.0; // Number of bits in a `u32` +const NANOS_PER_SECOND: f64 = 1.0e9; +const SECONDS_PER_NANO: f64 = 1.0 / NANOS_PER_SECOND; + +pub fn init( + async_input_receiver: mpsc::Receiver>, + mut async_output_receiver: mpsc::Receiver>, + async_output_transmitter: mpsc::Sender> +) { + tauri::async_runtime::spawn(async move { async_process_model(async_input_receiver, async_output_transmitter).await }); + let message_queue: Arc>> = Arc::new(Mutex::new(Vec::new())); + /* ........................................................... + Listen For incoming messages and add to queue + ............................................................*/ + let message_queue_clone = Arc::clone(&message_queue); + tauri::async_runtime::spawn(async move { + loop { + if let Some(package) = async_output_receiver.recv().await { + let mut message_queue = message_queue_clone.lock().await; + let messages = package; + for message in messages { + (*message_queue).push(message); + } + } + } + }); + + let message_queue_clone = Arc::clone(&message_queue); + tauri::async_runtime::spawn(async move { + /* ........................................................... + Open OSC Ports + ............................................................*/ + let sock = UdpSocket::bind("127.0.0.1:57122").unwrap(); + let to_addr = String::from("127.0.0.1:57120"); + sock.set_nonblocking(true).unwrap(); + sock.connect(to_addr).expect("could not connect to OSC address"); + + /* ........................................................... + Process queued messages + ............................................................*/ + + loop { + let mut message_queue = message_queue_clone.lock().await; + + message_queue.retain(|message| { + let result = sock.send(&message.msg_buf); + if result.is_err() { + println!("OSC Message failed to send, the server might no longer be available"); + } + return false; + }); + + sleep(Duration::from_millis(1)); + } + }); +} + +pub async fn async_process_model( + mut input_reciever: mpsc::Receiver>, + output_transmitter: mpsc::Sender> +) -> Result<(), Box> { + while let Some(input) = input_reciever.recv().await { + let output = input; + output_transmitter.send(output).await?; + } + Ok(()) +} + +#[derive(Deserialize)] +pub struct Param { + name: String, + value: String, + valueisnumber: bool, +} +#[derive(Deserialize)] +pub struct MessageFromJS { + params: Vec, + timestamp: u64, + target: String, +} +// Called from JS +#[tauri::command] +pub async fn sendosc( + messagesfromjs: Vec, + state: tauri::State<'_, AsyncInputTransmit> +) -> Result<(), String> { + let async_proc_input_tx = state.inner.lock().await; + let mut messages_to_process: Vec = Vec::new(); + for m in messagesfromjs { + let mut args = Vec::new(); + for p in m.params { + args.push(OscType::String(p.name)); + if p.valueisnumber { + args.push(OscType::Float(p.value.parse().unwrap())); + } else { + args.push(OscType::String(p.value)); + } + } + + let duration_since_epoch = Duration::from_millis(m.timestamp) + Duration::new(UNIX_OFFSET, 0); + + let seconds = u32 + ::try_from(duration_since_epoch.as_secs()) + .map_err(|_| "bit conversion failed for osc message timetag")?; + + let nanos = duration_since_epoch.subsec_nanos() as f64; + let fractional = (nanos * SECONDS_PER_NANO * TWO_POW_32).round() as u32; + + let timetag = OscTime::from((seconds, fractional)); + + let packet = OscPacket::Message(OscMessage { + addr: m.target, + args, + }); + + let bundle = OscBundle { + content: vec![packet], + timetag, + }; + + let msg_buf = encoder::encode(&OscPacket::Bundle(bundle)).unwrap(); + + let message_to_process = OscMsg { + msg_buf, + timestamp: m.timestamp, + }; + messages_to_process.push(message_to_process); + } + + async_proc_input_tx.send(messages_to_process).await.map_err(|e| e.to_string()) +} diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 4434ed7a..6cb4eb7d 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -37,10 +37,10 @@ const modules = [ import('@strudel.cycles/core'), import('@strudel.cycles/tonal'), import('@strudel.cycles/mini'), - isTauri() ? import('@strudel/desktopbridge') : import('@strudel.cycles/midi'), + isTauri() ? import('@strudel/desktopbridge/midibridge.mjs') : import('@strudel.cycles/midi'), import('@strudel.cycles/xen'), import('@strudel.cycles/webaudio'), - import('@strudel.cycles/osc'), + isTauri() ? import('@strudel/desktopbridge/oscbridge.mjs') : import('@strudel.cycles/osc'), import('@strudel.cycles/serial'), import('@strudel.cycles/soundfonts'), import('@strudel.cycles/csound'),