diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index b6866b874..75d2ea950 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -16,14 +16,17 @@ export function repl({ transpiler, onToggle, editPattern, + createCyclist, }) { - const scheduler = new Cyclist({ + const cyclistParams = { interval, onTrigger: getTrigger({ defaultOutput, getTime }), onError: onSchedulerError, getTime, onToggle, - }); + }; + const scheduler = createCyclist?.(cyclistParams) || new Cyclist(cyclistParams); + const setPattern = (pattern, autostart = true) => { pattern = editPattern?.(pattern) || pattern; scheduler.setPattern(pattern, autostart); diff --git a/packages/core/zyklus.mjs b/packages/core/zyklus.mjs index 3d25b0544..6d6610898 100644 --- a/packages/core/zyklus.mjs +++ b/packages/core/zyklus.mjs @@ -43,7 +43,8 @@ function createClock( clear(); }; const getPhase = () => phase; + const setPhase = (_phase) => (phase = _phase); // setCallback - return { setDuration, start, stop, pause, duration, interval, getPhase, minLatency }; + return { setDuration, start, stop, pause, duration, interval, getPhase, setPhase, minLatency }; } export default createClock; diff --git a/packages/desktopbridge/cyclistbridge.mjs b/packages/desktopbridge/cyclistbridge.mjs new file mode 100644 index 000000000..bb6acf24c --- /dev/null +++ b/packages/desktopbridge/cyclistbridge.mjs @@ -0,0 +1,77 @@ +import { Cyclist } from '@strudel.cycles/core'; +import { logger } from '../core/logger.mjs'; +import { Invoke } from '../../website/src/tauri.mjs'; +import { listen } from '@tauri-apps/api/event'; + +export class CyclistBridge extends Cyclist { + constructor(params) { + super(params); + this.start_timer; + this.abeLinkListener = listen('abelink-event', async (e) => { + const payload = e?.payload; + if (payload == null) { + return; + } + const { started, cps, phase, timestamp } = payload; + + if (cps !== this.cps) { + this.setCps(cps); + } + + // TODO: I'm not sure how to hook this up this phase adjustment in Strudel + // a phase adjustment message is sent every 30 seconds from backend to keep clocks in sync + const phaseDiff = Math.abs(phase - this.clock.getPhase()); + if (phaseDiff > 0.1) { + console.log('set phase from', this.clock.getPhase(), 'to', phase); + // hmmm this seems wrong... + //this.clock.setPhase(phase); + } + + if (this.started !== started && started != null) { + if (started) { + // the time delay in ms that seems to occur when starting the clock. Unsure if this is standard across all clients + const evaluationTime = 140; + + // when start message comes from abelink, delay starting cyclist clock until the start of the next abelink phase + this.start_timer = window.setTimeout(() => { + // TODO: evaluate the code so if another source triggers the play there will not be an error + + logger('[cyclist] start'); + this.clock.start(); + this.setStarted(true); + }, timestamp - Date.now() - evaluationTime); + } else { + this.stop(); + } + } + }); + } + + start() { + if (!this.pattern) { + throw new Error('Scheduler: no pattern set! call .setPattern first.'); + } + const linkmsg = { + cps: this.cps, + started: true, + timestamp: Date.now(), + phase: this.clock.getPhase(), + }; + Invoke('sendabelinkmsg', { linkmsg }); + } + + stop() { + logger('[cyclist] stop'); + this.clock.stop(); + this.lastEnd = 0; + this.setStarted(false); + const linkmsg = { + // TODO: change this to value of "main" clock cps + cps: 0, + started: false, + timestamp: Date.now(), + phase: this.clock.getPhase(), + }; + Invoke('sendabelinkmsg', { linkmsg }); + } +} diff --git a/packages/desktopbridge/index.mjs b/packages/desktopbridge/index.mjs index 591bbe34f..6190558b0 100644 --- a/packages/desktopbridge/index.mjs +++ b/packages/desktopbridge/index.mjs @@ -7,3 +7,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'; +export * from './cyclistbridge.mjs'; diff --git a/packages/react/src/hooks/useStrudel.mjs b/packages/react/src/hooks/useStrudel.mjs index a10998e73..cb154d80b 100644 --- a/packages/react/src/hooks/useStrudel.mjs +++ b/packages/react/src/hooks/useStrudel.mjs @@ -19,6 +19,7 @@ function useStrudel({ drawContext, drawTime = [-2, 2], paintOptions = {}, + createCyclist, // (params) => Cyclist }) { const id = useMemo(() => s4(), []); canvasId = canvasId || `canvas-${id}`; @@ -45,6 +46,7 @@ function useStrudel({ onEvalError?.(err); }, getTime, + createCyclist, drawContext, transpiler, editPattern, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 38fce20bf..9eb9f1057 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -99,6 +99,7 @@ version = "0.1.0" dependencies = [ "midir", "rosc", + "rusty_link", "serde", "serde_json", "tauri", @@ -163,6 +164,28 @@ version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -285,6 +308,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfb" version = "0.7.3" @@ -334,6 +366,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -637,6 +689,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "embed-resource" version = "2.1.1" @@ -1431,12 +1489,28 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.146" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "line-wrap" version = "0.1.1" @@ -1814,6 +1888,12 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.0" @@ -2206,6 +2286,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2235,6 +2321,16 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" +[[package]] +name = "rusty_link" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212e275351a46badd88b67ab8a4a8e17580317c728c361299b245cb51debe81c" +dependencies = [ + "bindgen", + "cmake", +] + [[package]] name = "ryu" version = "1.0.13" @@ -2428,6 +2524,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -3375,6 +3477,17 @@ dependencies = [ "windows-metadata", ] +[[package]] +name = "which" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2441c784c52b289a054b7201fc93253e288f094e2f4be9058343127c4226a269" +dependencies = [ + "either", + "libc", + "once_cell", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 35aab489e..4c517cfba 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri = { version = "1.4.0", features = ["fs-all"] } midir = "0.9.1" tokio = { version = "1.29.0", features = ["full"] } rosc = "0.10.1" +rusty_link = "0.3.6" [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/README.md b/src-tauri/README.md index 4045e1904..ed7bee4de 100644 --- a/src-tauri/README.md +++ b/src-tauri/README.md @@ -4,7 +4,8 @@ Rust source files for building native desktop apps using Tauri ## Usage -Install [Rust](https://rustup.rs/) on your system. +- Install [Rust](https://rustup.rs/) on your system. +- Install `cmake` on your system. OSX: `brew install cmake`, Linux: `sudo apt-get install cmake` From the project root: diff --git a/src-tauri/src/ablelinkbridge.rs b/src-tauri/src/ablelinkbridge.rs new file mode 100644 index 000000000..41f409042 --- /dev/null +++ b/src-tauri/src/ablelinkbridge.rs @@ -0,0 +1,175 @@ +use std::time::{ Duration, SystemTime, UNIX_EPOCH }; +use rusty_link::{ AblLink, SessionState }; +use std::sync::Arc; +use tokio::sync::Mutex; +use serde::Deserialize; +use std::thread::sleep; + +use crate::loggerbridge::Logger; + +use tauri::Window; + +fn bpm_to_cps(bpm: f64) -> f64 { + let cpm = bpm / 4.0; + return cpm / 60.0; +} + +fn cps_to_bpm(cps: f64) -> f64 { + let cpm = cps * 60.0; + return cpm * 4.0; +} + +fn current_unix_time() -> Duration { + let current_unix_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + return current_unix_time; +} + +#[derive(Deserialize, Clone, serde::Serialize)] +pub struct LinkMsg { + pub started: bool, + pub cps: f64, + pub timestamp: u64, + pub phase: f64, +} + +pub struct AbeLinkStateContainer { + pub abelink: Arc>, +} +pub struct AbeLinkState { + pub link: AblLink, + pub session_state: SessionState, + pub running: bool, + pub quantum: f64, + pub window: Arc, +} + +impl AbeLinkState { + pub fn new(window: Arc) -> Self { + Self { + link: AblLink::new(120.0), + session_state: SessionState::new(), + running: true, + quantum: 4.0, + window, + } + } + + pub fn unix_time_at_next_phase(&self) -> u64 { + let link_time_stamp = self.link.clock_micros(); + let quantum = self.quantum; + let beat = self.session_state.beat_at_time(link_time_stamp, quantum); + let phase = self.session_state.phase_at_time(link_time_stamp, quantum); + let internal_time_at_next_phase = self.session_state.time_at_beat(beat + (quantum - phase), quantum); + let time_offset = Duration::from_micros((internal_time_at_next_phase - link_time_stamp) as u64); + let current_unix_time = current_unix_time(); + let unix_time_at_next_phase = (current_unix_time + time_offset).as_millis(); + return unix_time_at_next_phase as u64; + } + + pub fn cps(&self) -> f64 { + let bpm = self.session_state.tempo(); + let cps = bpm_to_cps(bpm); + return cps; + } + + pub fn capture_app_state(&mut self) { + self.link.capture_app_session_state(&mut self.session_state); + } + + pub fn commit_app_state(&mut self) { + self.link.commit_app_session_state(&self.session_state); + } + + pub fn send(&self, payload: LinkMsg) { + let _ = self.window.emit("abelink-event", payload); + } + + pub fn send_started(&self) { + let cps = self.cps(); + let started = self.session_state.is_playing(); + let payload = LinkMsg { + cps, + started, + timestamp: self.unix_time_at_next_phase(), + phase: 0.0, + }; + self.send(payload); + } + + pub fn send_cps(&self) { + let cps = self.cps(); + let started = self.session_state.is_playing(); + let phase = self.session_state.phase_at_time(self.link.clock_micros(), self.quantum); + let payload = LinkMsg { + cps, + started, + timestamp: current_unix_time().as_millis() as u64, + phase, + }; + self.send(payload); + } + + pub fn send_phase(&self) { + self.send_started(); + } +} + +pub fn init(_logger: Logger, abelink: Arc>) { + tauri::async_runtime::spawn(async move { + let mut prev_is_started = false; + let mut prev_cps = 0.0; + + let mut time_since_last_phase_send = 0; + let sleep_time = 10; + /* ....................................................................... + Evaluate Abelink State and send messages back to JS side when needed. + ........................................................................*/ + loop { + let mut state = abelink.lock().await; + state.capture_app_state(); + if state.link.is_enabled() == false { + state.link.enable(true); + state.link.enable_start_stop_sync(true); + } + + let started = state.session_state.is_playing(); + + if started != prev_is_started { + state.send_started(); + prev_is_started = started; + } else if state.cps() != prev_cps && state.cps() != 0.0 { + state.send_cps(); + prev_cps = state.cps(); + // a phase sync message needs to be sent to strudel every 30 seconds to keep clock drift at bay + } else if time_since_last_phase_send > 30000 { + state.send_phase(); + time_since_last_phase_send = 0; + } + + drop(state); + sleep(Duration::from_millis(sleep_time)); + time_since_last_phase_send = time_since_last_phase_send + sleep_time; + } + }); +} + +// Called from JS +#[tauri::command] +pub async fn sendabelinkmsg(linkmsg: LinkMsg, state: tauri::State<'_, AbeLinkStateContainer>) -> Result<(), String> { + let mut abelink = state.abelink.lock().await; + abelink.capture_app_state(); + let started = abelink.session_state.is_playing(); + let time_stamp = abelink.link.clock_micros(); + let quantum = abelink.quantum; + let linkmsg_bpm = cps_to_bpm(linkmsg.cps); + + if linkmsg.started != started { + abelink.session_state.set_is_playing_and_request_beat_at_time(linkmsg.started, time_stamp as u64, 0.0, quantum); + } + if linkmsg_bpm != abelink.session_state.tempo() && linkmsg_bpm != 0.0 { + abelink.session_state.set_tempo(linkmsg_bpm, time_stamp); + } + abelink.commit_app_state(); + drop(abelink); + Ok(()) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 6f97b8c98..e4288b83b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,8 +4,10 @@ mod midibridge; mod oscbridge; mod loggerbridge; +mod ablelinkbridge; use std::sync::Arc; +use ablelinkbridge::AbeLinkState; use loggerbridge::Logger; use tauri::Manager; use tokio::sync::mpsc; @@ -21,6 +23,7 @@ fn main() { 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 { @@ -29,17 +32,31 @@ fn main() { .manage(oscbridge::AsyncInputTransmit { inner: Mutex::new(async_input_transmitter_osc), }) - .invoke_handler(tauri::generate_handler![midibridge::sendmidi, oscbridge::sendosc]) + .invoke_handler(tauri::generate_handler![midibridge::sendmidi, oscbridge::sendosc, ablelinkbridge::sendabelinkmsg]) .setup(|app| { let window = Arc::new(app.get_window("main").unwrap()); - let logger = Logger { window }; + let logger = Logger { window: window.clone() }; + midibridge::init( logger.clone(), async_input_receiver_midi, async_output_receiver_midi, async_output_transmitter_midi ); - oscbridge::init(logger, async_input_receiver_osc, async_output_receiver_osc, async_output_transmitter_osc); + oscbridge::init( + logger.clone(), + async_input_receiver_osc, + async_output_receiver_osc, + async_output_transmitter_osc + ); + + // This state must be declared in the setup so it can be shared between invoked commands and the initialized function + let abelink = Arc::new(Mutex::new(AbeLinkState::new(window))); + app.manage(ablelinkbridge::AbeLinkStateContainer { + abelink: abelink.clone(), + }); + ablelinkbridge::init(logger.clone(), abelink); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/website/src/repl/Repl.jsx b/website/src/repl/Repl.jsx index 8fe43c6d2..04f5002c2 100644 --- a/website/src/repl/Repl.jsx +++ b/website/src/repl/Repl.jsx @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -import { cleanupDraw, cleanupUi, controls, evalScope, getDrawContext, logger } from '@strudel.cycles/core'; +import { cleanupDraw, cleanupUi, controls, evalScope, getDrawContext, logger, Cyclist } from '@strudel.cycles/core'; import { CodeMirror, cx, flash, useHighlighting, useStrudel, useKeydown } from '@strudel.cycles/react'; import { getAudioContext, initAudioOnFirstClick, resetLoadedSounds, webaudioOutput } from '@strudel.cycles/webaudio'; import { createClient } from '@supabase/supabase-js'; @@ -23,6 +23,7 @@ import { settingPatterns } from '../settings.mjs'; import { code2hash, hash2code } from './helpers.mjs'; import { isTauri } from '../tauri.mjs'; import { useWidgets } from '@strudel.cycles/react/src/hooks/useWidgets.mjs'; +import { CyclistBridge } from '@strudel/desktopbridge/cyclistbridge.mjs'; const { latestCode } = settingsMap.get(); @@ -51,6 +52,7 @@ if (isTauri()) { import('@strudel/desktopbridge/loggerbridge.mjs'), import('@strudel/desktopbridge/midibridge.mjs'), import('@strudel/desktopbridge/oscbridge.mjs'), + import('@strudel/desktopbridge/cyclistbridge.mjs'), ]); } else { modules = modules.concat([import('@strudel.cycles/midi'), import('@strudel.cycles/osc')]); @@ -163,6 +165,7 @@ export function Repl({ embedded = false }) { drawContext, // drawTime: [0, 6], paintOptions, + createCyclist: (p) => (isTauri() ? new CyclistBridge(p) : new Cyclist(p)), }); // init code