Reorganize top-level directories with clearer naming convention

app -> app-desktop-macos, presets -> app-presets, server -> remote-server,
daw-config-reaper -> osc-config-daw, plugin-reaper-realearn -> plugin-reaper-relearn.
Updated run.py and presets.py path references accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-15 17:51:05 -04:00
parent e58f06d9fa
commit 7ecc718f5d
2256 changed files with 11 additions and 5 deletions
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "base"
version = "0.1.0"
authors = ["Benjamin Klum <benjamin.klum@helgoboss.org>"]
edition = "2021"
publish = false
[dependencies]
# Own
reaper-high.workspace = true
reaper-medium.workspace = true
reaper-low.workspace = true
reaper-rx.workspace = true
helgobox-api.workspace = true
# 3rd-party
serde.workspace = true
serde_json.workspace = true
xxhash-rust.workspace = true
crossbeam-channel.workspace = true
futures-timer.workspace = true
once_cell.workspace = true
tracing.workspace = true
metrics.workspace = true
ascii.workspace = true
enigo.workspace = true
# For getting current mouse state
device_query.workspace = true
derive_more.workspace = true
either.workspace = true
logos.workspace = true
anyhow.workspace = true
thiserror.workspace = true
camino.workspace = true
indexmap.workspace = true
futures.workspace = true
tokio.workspace = true
fragile.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
# For not letting device_query panic when macOS accessibility permissions not granted
macos-accessibility-client.workspace = true
[lints.clippy]
enum_glob_use = "deny"
@@ -0,0 +1,59 @@
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
/// An approximate floating-point type that uses the same epsilon for comparison as the == operator in
/// [EEL2](https://www.cockos.com/EEL2/):
/// Two values are considered equal if the difference is less than 0.00001 (1/100000), 0 if not.
pub type AudioF64 = ApproxF64<100000>;
/// Simple newtype that allows for approximate comparison of 64-bit floating-point numbers.
///
/// The const type parameter `E` ("epsilon") defines how tolerant floating-point comparison is. Two values are considered
/// equal if the difference is less than 1/E.
#[derive(Copy, Clone, Debug, Default)]
pub struct ApproxF64<const E: u32>(pub f64);
impl<const E: u32> ApproxF64<E> {
const EPSILON: f64 = 1.0 / E as f64;
pub fn new(raw: f64) -> Self {
Self(raw)
}
fn difference_is_neglectable(&self, other: &Self) -> bool {
(self.0 - other.0).abs() < Self::EPSILON
}
}
impl<const E: u32> PartialOrd for ApproxF64<E> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.difference_is_neglectable(other) {
return Some(Ordering::Equal);
}
self.0.partial_cmp(&other.0)
}
}
impl<const E: u32> PartialEq for ApproxF64<E> {
fn eq(&self, other: &Self) -> bool {
self.difference_is_neglectable(other)
}
}
impl<const E: u32> Display for ApproxF64<E> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basics() {
assert_eq!(AudioF64::new(0.75), AudioF64::new(0.75));
assert_ne!(AudioF64::new(0.00001), AudioF64::new(0.00002));
assert_eq!(AudioF64::new(0.000001), AudioF64::new(0.000002));
}
}
@@ -0,0 +1,114 @@
//! We have a raw MIDI pattern in helgoboss-learn already (raw MIDI source), however this is more
//! complicated than this one as it also allows single bits to be variable.
use logos::{Lexer, Logos};
use std::num::ParseIntError;
use std::str::FromStr;
#[derive(Clone, Eq, PartialEq, Hash, Debug)]
pub struct BytePattern {
bytes: Vec<PatternByte>,
}
impl BytePattern {
pub const fn new(bytes: Vec<PatternByte>) -> Self {
Self { bytes }
}
pub fn matches(&self, bytes: &[u8]) -> bool {
use PatternByte as B;
let mut byte_iter = bytes.iter();
let mut last_was_multi = false;
for pattern_byte in &self.bytes {
let matches = match pattern_byte {
B::Fixed(expected_byte) => {
if last_was_multi {
// Last pattern byte was multi
last_was_multi = false;
// Greedily consume any follow-up actual bytes until we meet the expected
// byte. If we don't meet it, no match!
byte_iter.any(|b| b == expected_byte)
} else {
// Last pattern byte was single or fixed
byte_iter.next().is_some_and(|b| b == expected_byte)
}
}
B::Single => {
last_was_multi = false;
// We need to have an actual byte but it doesn't matter which one!
byte_iter.next().is_some()
}
B::Multi => {
last_was_multi = true;
// Match even if no actual byte left!
true
}
};
if !matches {
return false;
}
}
byte_iter.next().is_none() || last_was_multi
}
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Logos)]
#[logos(skip r"[ \t\n\f]+")]
#[logos(error = ParseBytePatternError)]
pub enum PatternByte {
#[regex(r"[0-9a-fA-F][0-9a-fA-F]?", parse_as_byte)]
Fixed(u8),
#[token("?")]
Single,
#[token("*")]
Multi,
}
#[derive(Clone, PartialEq, Debug, Default, thiserror::Error)]
#[error("{msg}")]
pub struct ParseBytePatternError {
msg: &'static str,
}
impl From<ParseIntError> for ParseBytePatternError {
fn from(_: ParseIntError) -> Self {
Self {
msg: "problem parsing fixed byte",
}
}
}
fn parse_as_byte(lex: &mut Lexer<PatternByte>) -> Result<u8, ParseIntError> {
u8::from_str_radix(lex.slice(), 16)
}
impl FromStr for BytePattern {
type Err = ParseBytePatternError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lex: Lexer<PatternByte> = PatternByte::lexer(s);
let entries: Result<Vec<_>, _> = lex.collect();
Ok(BytePattern::new(entries?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basics() {
// Given
let pattern: BytePattern = "F0 7E ? 06 02 * F7".parse().unwrap();
// When
assert!(!pattern.matches(&[]));
assert!(!pattern.matches(&[0xF0]));
assert!(!pattern.matches(&[0xF0, 0x7E]));
assert!(!pattern.matches(&[0xF0, 0x7E, 0x00]));
assert!(!pattern.matches(&[0xF0, 0x7E, 0x00, 0x06]));
assert!(pattern.matches(&[0xF0, 0x7E, 0x00, 0x06, 0x02, 0xF7]));
assert!(pattern.matches(&[0xF0, 0x7E, 0x01, 0x06, 0x02, 0xF7]));
assert!(pattern.matches(&[0xF0, 0x7E, 0xFF, 0x06, 0x02, 0xFF, 0x60, 0xF7]));
assert!(!pattern.matches(&[0xF0, 0x7E, 0xFF, 0x06, 0x02, 0xFF, 0x60, 0xF7, 0xF7]));
}
}
+376
View File
@@ -0,0 +1,376 @@
use crossbeam_channel::{Receiver, Sender, TryRecvError, TrySendError};
use reaper_high::Reaper;
use std::error::Error;
use std::fmt;
use std::fmt::{Debug, Display, Formatter};
use std::sync::atomic::{AtomicBool, Ordering};
pub trait NamedChannelSender {
type Msg;
/// Sends the given message if the channel still has space and the receiver is still
/// connected, otherwise does nothing.
fn send_if_space(&self, msg: Self::Msg);
/// Sends the given message if the channel still has space, otherwise panics.
///
/// If the receiver is disconnected, does nothing.
fn send_complaining(&self, msg: Self::Msg);
}
/// A channel intended to send important messages from a real-time thread to a normal (non-real-time) thread.
///
/// The way this currently works is that it uses 2 senders: One that has an initial capacity and is normally used.
/// And another one that is unbounded (and can therefore allocate) that is only used if the initial one is full.
///
/// TODO-medium Find a channel library that allows pre-allocated unbounded channels (with a high initial capacity).
pub struct ImportantSenderFromRtToNormalThread<T> {
channel_name: &'static str,
bounded_normal_sender: Sender<T>,
unbounded_emergency_sender: Sender<T>,
}
impl<T> ImportantSenderFromRtToNormalThread<T> {
pub fn new(
channel_name: &'static str,
capacity: usize,
) -> (Self, ImportantReceiverFromRtToNormalThread<T>) {
// Main sender should belong to a bounded channel pre-allocated for normal usage.
//
// Emergency sender should belong to an unbounded channel and is only used if sending to the main channel would
// block because it's full. Better allocate than block or discard the event.
let (bounded_normal_sender, bounded_normal_receiver) = crossbeam_channel::bounded(capacity);
let (unbounded_emergency_sender, unbounded_emergency_receiver) =
crossbeam_channel::unbounded();
(
ImportantSenderFromRtToNormalThread {
channel_name,
bounded_normal_sender,
unbounded_emergency_sender,
},
ImportantReceiverFromRtToNormalThread {
channel_name,
bounded_normal_receiver,
unbounded_emergency_receiver,
},
)
}
}
impl<T> ImportantSenderFromRtToNormalThread<T> {
/// Returns `false` if receiver gone.
pub fn send(&self, msg: T) -> bool {
if let Err(e) = self.bounded_normal_sender.try_send(msg) {
match e {
TrySendError::Full(msg) => {
tracing::warn!(
msg = "Main sequence channel was full, using emergency channel (may allocate)!",
%self.channel_name,
);
let _ = self.unbounded_emergency_sender.send(msg);
true
}
TrySendError::Disconnected(_) => false,
}
} else {
true
}
}
}
impl<T> Clone for ImportantSenderFromRtToNormalThread<T> {
fn clone(&self) -> Self {
Self {
channel_name: self.channel_name,
bounded_normal_sender: self.bounded_normal_sender.clone(),
unbounded_emergency_sender: self.unbounded_emergency_sender.clone(),
}
}
}
pub struct ImportantReceiverFromRtToNormalThread<T> {
channel_name: &'static str,
bounded_normal_receiver: Receiver<T>,
unbounded_emergency_receiver: Receiver<T>,
}
impl<T> ImportantReceiverFromRtToNormalThread<T> {
pub fn try_recv(&self) -> Result<T, TryRecvError> {
self.bounded_normal_receiver
.try_recv()
.or_else(|_| self.unbounded_emergency_receiver.try_recv())
}
}
impl<T> Debug for ImportantReceiverFromRtToNormalThread<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("ImportantReceiverFromRtToNormalThread")
.field("channel_name", &self.channel_name)
.field("bounded_normal_receiver", &self.bounded_normal_receiver)
.field(
"unbounded_emergency_receiver",
&self.unbounded_emergency_receiver,
)
.finish()
}
}
impl<T> Debug for ImportantSenderFromRtToNormalThread<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("ImportantSenderFromRtToNormalThread")
.field("channel_name", &self.channel_name)
.field("bounded_normal_sender", &self.bounded_normal_sender)
.field(
"unbounded_emergency_sender",
&self.unbounded_emergency_sender,
)
.finish()
}
}
/// A channel intended to send messages to a normal (non-real-time) thread.
///
/// - Either unbounded (should only be used if the sender is also a normal thread).
/// - Or bounded (can also be used if the sender is a real-time thread).
///
/// If you need an unbounded one that is okay to use from a real-time thread, look into
/// [`ImportantSenderFromRtToNormalThread`].
pub struct SenderToNormalThread<T> {
channel_name: &'static str,
sender: Sender<T>,
complained_already: AtomicBool,
}
impl<T> Debug for SenderToNormalThread<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("SenderToNormalThread")
.field("channel_name", &self.channel_name)
.field("sender", &self.sender)
.finish()
}
}
impl<T> NamedChannelSender for SenderToNormalThread<T> {
type Msg = T;
fn send_if_space(&self, msg: T) {
let _ = self.send_internal(msg);
}
fn send_complaining(&self, msg: T) {
let result = self.send_internal(msg);
if !receiver_is_disconnected(&result)
&& !self.complained_already.swap(true, Ordering::Relaxed)
{
// Complain
result.unwrap();
}
}
}
fn receiver_is_disconnected<T>(result: &Result<(), NamedChannelTrySendError<T>>) -> bool {
if let Err(e) = &result {
matches!(e.try_send_error, TrySendError::Disconnected(_))
} else {
false
}
}
impl<T> SenderToNormalThread<T> {
/// Creates a bounded channel.
///
/// - **Pro:** Never allocates when sending and is therefore safe to use from real-time threads.
/// - **Con:** We can get "channel full" errors on load spikes if the capacity is not high
/// enough. Choosing an extremely high capacity to avoid this is not a good idea either
/// because it consumes memory that's almost never going to be used.
pub fn new_bounded_channel(name: &'static str, capacity: usize) -> (Self, Receiver<T>) {
let (sender, receiver) = crossbeam_channel::bounded(capacity);
(
Self {
channel_name: name,
sender,
complained_already: AtomicBool::new(false),
},
receiver,
)
}
/// Creates an unbounded channel.
///
/// - **Pro:** We don't get "channel full" errors on load spikes.
/// - **Con:** This can allocate when sending, so don't use this if the sender is used in
/// real-time threads! If you still do so, it will complain in debug mode because we forbid
/// allocation in real-time threads.
///
/// We set a (very high) upper limit even for unbounded channels just to avoid memory exhaustion
/// if the channel grows endlessly because of another error. This limit is not ensured by
/// pre-allocating the channel with a certain capacity but by checking the current number
/// of messages in the channel before sending.
pub fn new_unbounded_channel(name: &'static str) -> (Self, Receiver<T>) {
let (sender, receiver) = crossbeam_channel::unbounded();
(
Self {
channel_name: name,
sender,
complained_already: AtomicBool::new(false),
},
receiver,
)
}
pub fn try_to_send(&self, msg: T) -> bool {
self.sender.try_send(msg).is_ok()
}
pub fn is_bounded(&self) -> bool {
self.sender.capacity().is_some()
}
fn send_internal(&self, msg: T) -> Result<(), NamedChannelTrySendError<T>> {
if !self.is_bounded() {
// The channel is not bounded but we still want to panic if the number of messages
// in the channel is extremely high, to prevent memory exhaustion.
let msg_count = self.sender.len();
if msg_count > 1_000_000 {
panic!(
"Unbounded channel {} is extremely full ({} messages). \
Not accepting new messages in order to prevent memory exhaustion.",
self.channel_name, msg_count
);
}
}
try_send_on_named_channel(&self.sender, self.channel_name, msg)
}
}
impl<T> Clone for SenderToNormalThread<T> {
fn clone(&self) -> Self {
Self {
channel_name: self.channel_name,
sender: self.sender.clone(),
complained_already: AtomicBool::new(false),
}
}
}
/// A channel intended to send messages to real-time threads.
///
/// It has special logic which makes sure the queue doesn't run full when audio is not running.
#[derive(Debug)]
pub struct SenderToRealTimeThread<T> {
channel_name: &'static str,
sender: Sender<T>,
complained_already: AtomicBool,
}
impl<T> Clone for SenderToRealTimeThread<T> {
fn clone(&self) -> Self {
Self {
channel_name: self.channel_name,
sender: self.sender.clone(),
complained_already: AtomicBool::new(false),
}
}
}
impl<T> NamedChannelSender for SenderToRealTimeThread<T> {
type Msg = T;
fn send_if_space(&self, msg: T) {
let _ = self.send_internal(msg);
}
fn send_complaining(&self, msg: T) {
let result = self.send_internal(msg);
if !receiver_is_disconnected(&result)
&& !self.complained_already.swap(true, Ordering::Relaxed)
{
// Complain
result.unwrap();
}
}
}
impl<T> SenderToRealTimeThread<T> {
pub fn new_channel(name: &'static str, capacity: usize) -> (Self, Receiver<T>) {
let (sender, receiver) = crossbeam_channel::bounded(capacity);
(
Self {
channel_name: name,
sender,
complained_already: AtomicBool::new(false),
},
receiver,
)
}
fn send_internal(&self, msg: T) -> Result<(), NamedChannelTrySendError<T>> {
if Reaper::get().audio_is_running() {
// Audio is running so sending should always work. If not, it's an unexpected error and
// we must return it.
try_send_on_named_channel(&self.sender, self.channel_name, msg)
} else {
// Audio is not running. Maybe this is just a very temporary outage or a short initial
// non-running state.
if self.channel_still_has_some_headroom() {
// Channel still has some headroom, so we send the task in order to support a
// temporary outage. This should not fail unless another sender has exhausted the
// channel in the meanwhile. Even then, so what. See "else" branch.
let _ = self.sender.try_send(msg);
Ok(())
} else {
// Channel has already accumulated lots of tasks. Don't send!
// It's not bad if we don't send this task because the real-time processor will
// not be able to process it anyway at the moment (it's not going to be called
// because the audio engine is stopped). Fear not, ReaLearn's audio hook has logic
// that detects a "rebirth" - the moment when the audio cycle starts again. In this
// case it will request a full resync of everything so nothing should get lost
// in theory.
Ok(())
}
}
}
fn channel_still_has_some_headroom(&self) -> bool {
self.sender.len() <= self.sender.capacity().unwrap() / 2
}
}
fn try_send_on_named_channel<T>(
sender: &Sender<T>,
channel_name: &'static str,
msg: T,
) -> Result<(), NamedChannelTrySendError<T>> {
sender.try_send(msg).map_err(|e| NamedChannelTrySendError {
channel_name,
try_send_error: e,
})
}
#[derive(Copy, Clone, Eq, PartialEq)]
struct NamedChannelTrySendError<T> {
channel_name: &'static str,
try_send_error: TrySendError<T>,
}
impl<T> Debug for NamedChannelTrySendError<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"Channel [{}]: {:?}",
self.channel_name, self.try_send_error
)
}
}
impl<T> Display for NamedChannelTrySendError<T> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(
f,
"Channel [{}]: {}",
self.channel_name, self.try_send_error
)
}
}
impl<T: Send> Error for NamedChannelTrySendError<T> {}
@@ -0,0 +1,38 @@
use serde::{Deserialize, Deserializer};
pub fn is_default<T: Default + PartialEq>(v: &T) -> bool {
v == &T::default()
}
pub fn bool_true() -> bool {
true
}
pub fn is_bool_true(v: &bool) -> bool {
*v
}
/// Should only be used when the deserialization checks the data version number because only that
/// way it can check if `None` represents the old default or the new one! (That is, if there's
/// even a difference between `None` and `Some(default())`, otherwise it doesn't matter).
pub fn is_none_or_some_default<T: Default + PartialEq>(v: &Option<T>) -> bool {
if let Some(i) = v {
i == &T::default()
} else {
true
}
}
/// Makes sure that JSON `null` is treated the same as omitting a property.
///
/// Use as `#[serde(deserialize_with = "deserialize_null_default")]`.
///
/// See https://github.com/serde-rs/serde/issues/1098#issuecomment-760711617.
pub fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
T: Default + Deserialize<'de>,
D: Deserializer<'de>,
{
let opt = Option::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
@@ -0,0 +1,36 @@
use crate::hash_util::PersistentHash;
use std::ffi::OsStr;
pub fn is_hidden(file_name: &OsStr) -> bool {
file_name
.to_str()
.map(|s| s.starts_with('.'))
.unwrap_or(false)
}
/// Converts a persistent hash number to something like
/// "a9/4a/8fe5ccb19ba61c4c0873d391e987.RfxChain" for the purpose to not get too many
/// files in one directory.
pub fn convert_hash_to_dir_structure(hash: PersistentHash, suffix: &str) -> String {
let hash = hash.get();
let first_byte = hash.rotate_left(8) & 0xff;
let second_byte = hash.rotate_left(16) & 0xff;
// Remaining: 112 bits = 14 bytes = 28 hex chars
let remaining = hash & 0xffffffffffffffffffffffffffff;
format!("{first_byte:02x}/{second_byte:02x}/{remaining:028x}{suffix}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hash_util;
#[test]
fn hash_to_dir_structure_simple() {
let hash = hash_util::calculate_persistent_non_crypto_hash_one_shot("test".as_bytes());
assert_eq!(
convert_hash_to_dir_structure(hash, ".RfxChain"),
"6c/78/e0e3bd51d358d01e758642b85fb8.RfxChain".to_string()
);
}
}
@@ -0,0 +1,5 @@
use std::time::Duration;
pub async fn millis(amount: u64) {
futures_timer::Delay::new(Duration::from_millis(amount)).await;
}
+123
View File
@@ -0,0 +1,123 @@
use crossbeam_channel::{Receiver, Sender};
use fragile::Fragile;
use reaper_high::{
FutureMiddleware, FutureSupport, MainTaskMiddleware, MainThreadTask, TaskSupport,
DEFAULT_MAIN_THREAD_TASK_BULK_SIZE,
};
use reaper_rx::{ActionRx, ActionRxProvider, ControlSurfaceRx, MainRx};
use std::sync::LazyLock;
static INSTANCE: LazyLock<Global> = LazyLock::new(Global::default);
/// Spawns the given future in the main thread.
///
/// This only works if the future support is already running (= if the backbone shell is already woken up).
pub fn spawn_in_main_thread(
future: impl std::future::Future<Output = Result<(), Box<dyn std::error::Error>>> + 'static,
) {
Global::future_support().spawn_in_main_thread_from_main_thread(future);
}
pub struct Global {
main_rx: Fragile<MainRx>,
task_support: TaskSupport,
future_support: FutureSupport,
task_sender: Sender<MainThreadTask>,
task_receiver: Receiver<MainThreadTask>,
send_future_executor: reaper_high::run_loop_executor::RunLoopExecutor,
non_send_future_executor: reaper_high::local_run_loop_executor::RunLoopExecutor,
}
impl Default for Global {
fn default() -> Self {
// It's important that all of the below channels are unbounded. It's not just that they
// can run full and then panic, it's worse. If sending and receiving happens on the same
// thread (which we use quite often in order to schedule/spawn something on the main
// thread) and the channel is full, we will get a deadlock! It's okay that they allocate
// on sending because `Global` can't be used from a real-time thread.
// See https://github.com/helgoboss/helgobox/issues/875.
let (task_sender, task_receiver) = crossbeam_channel::unbounded();
let (send_future_spawner, send_future_executor) =
reaper_high::run_loop_executor::new_spawner_and_executor(
DEFAULT_MAIN_THREAD_TASK_BULK_SIZE,
);
let (non_send_future_spawner, non_send_future_executor) =
reaper_high::local_run_loop_executor::new_spawner_and_executor(
DEFAULT_MAIN_THREAD_TASK_BULK_SIZE,
);
Self {
main_rx: Default::default(),
task_support: TaskSupport::new(task_sender.clone()),
future_support: FutureSupport::new(send_future_spawner, non_send_future_spawner),
task_sender,
task_receiver,
send_future_executor,
non_send_future_executor,
}
}
}
impl Global {
pub fn get() -> &'static Self {
assert!(
!reaper_high::Reaper::get()
.medium_reaper()
.is_in_real_time_audio(),
"this function must not be called in a real-time thread"
);
&INSTANCE
}
// This is kept static just for allowing easy observable subscription from everywhere. For
// pushing to the subjects, static access is not necessary.
// Don't use from real-time thread!
pub fn control_surface_rx() -> &'static ControlSurfaceRx {
Global::get().main_rx.get().control_surface()
}
// This really needs to be kept static for pushing to the subjects because hook commands can't
// take user data.
//
// Don't use from real-time thread!
pub fn action_rx() -> &'static ActionRx {
Global::get().main_rx.get().action()
}
/// Allows you to schedule tasks for execution on the main thread from anywhere.
///
/// Important: Don't use this to schedule tasks from a real-time thread! This is backed by an
/// unbounded channel now because of https://github.com/helgoboss/helgobox/issues/875, so
/// sending can allocate!
pub fn task_support() -> &'static TaskSupport {
&Global::get().task_support
}
/// Allows you to spawn futures from anywhere.
///
/// Important: Don't use this to spawn futures from a real-time thread! This is backed by an
/// unbounded channel now because of https://github.com/helgoboss/helgobox/issues/875, so
/// sending can allocate!
pub fn future_support() -> &'static FutureSupport {
&Global::get().future_support
}
/// Creates the middleware that drives the task support.
pub fn create_task_support_middleware(&self) -> MainTaskMiddleware {
MainTaskMiddleware::new(self.task_sender.clone(), self.task_receiver.clone())
}
/// Creates the middleware that drives the future support.
pub fn create_future_support_middleware(&self) -> FutureMiddleware {
FutureMiddleware::new(
self.send_future_executor.clone(),
self.non_send_future_executor.clone(),
)
}
}
impl ActionRxProvider for Global {
fn action_rx() -> &'static ActionRx {
Global::action_rx()
}
}
@@ -0,0 +1,30 @@
/// Use only where absolutely necessary because of static-only FFI stuff!
#[macro_export]
macro_rules! make_available_globally_in_main_thread_on_demand {
($instance_struct:path) => {
static INSTANCE: std::sync::OnceLock<fragile::Fragile<$instance_struct>> =
std::sync::OnceLock::new();
impl $instance_struct {
pub fn make_available_globally(create_instance: impl FnOnce() -> $instance_struct) {
if INSTANCE.get().is_some() {
return;
}
let _ = INSTANCE.set(fragile::Fragile::new(create_instance()));
}
/// Whether this instance is (already/still) loaded.
pub fn is_loaded() -> bool {
INSTANCE.get().is_some()
}
/// Panics if not in main thread.
pub fn get() -> &'static $instance_struct {
INSTANCE
.get()
.expect("call `make_available_globally()` before using `get()`")
.get()
}
}
};
}
+117
View File
@@ -0,0 +1,117 @@
use indexmap::{IndexMap, IndexSet};
use std::collections::{HashMap, HashSet};
use std::hash::{BuildHasher, Hash, Hasher};
use xxhash_rust::xxh3::{Xxh3Default, Xxh3DefaultBuilder};
/// The default choice for hashing in Helgobox.
pub type NonCryptoHashBuilder = Xxh3DefaultBuilder;
/// The default choice for hashing in Helgobox.
pub type NonCryptoHasher = Xxh3Default;
/// The default choice for hash maps in Helgobox.
pub type NonCryptoHashMap<K, V> = HashMap<K, V, NonCryptoHashBuilder>;
/// The default choice for hash sets in Helgobox.
pub type NonCryptoHashSet<T> = HashSet<T, NonCryptoHashBuilder>;
/// The default choice for index maps in Helgobox.
pub type NonCryptoIndexMap<K, V> = IndexMap<K, V, NonCryptoHashBuilder>;
/// The default choice for index sets in Helgobox.
pub type NonCryptoIndexSet<T> = IndexSet<T, NonCryptoHashBuilder>;
pub fn clone_to_other_hash_map<
K: Eq + Hash + Clone,
V: Clone,
S1: BuildHasher,
S2: BuildHasher + Default,
>(
non_crypto: &HashMap<K, V, S1>,
) -> HashMap<K, V, S2> {
non_crypto
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
pub fn convert_into_other_hash_map<K: Eq + Hash, V, S1: BuildHasher, S2: BuildHasher + Default>(
non_crypto: HashMap<K, V, S1>,
) -> HashMap<K, V, S2> {
non_crypto.into_iter().collect()
}
pub fn convert_into_other_hash_set<K: Eq + Hash, S1: BuildHasher, S2: BuildHasher + Default>(
non_crypto: HashSet<K, S1>,
) -> HashSet<K, S2> {
non_crypto.into_iter().collect()
}
/// Calculates a 64-bit non-crypto hash directly from the given bytes.
///
/// A bit faster than the streaming version.
pub fn calculate_non_crypto_hash_one_shot(payload: &[u8]) -> u64 {
xxhash_rust::xxh3::xxh3_64(payload)
}
/// Calculates a 128-bit non-crypto hash directly from the given bytes suitable for persistence.
///
/// This implementation must not change!
pub fn calculate_persistent_non_crypto_hash_one_shot(payload: &[u8]) -> PersistentHash {
// Don't change the hash function! It's used e.g. for file names.
PersistentHash(xxhash_rust::xxh3::xxh3_128(payload))
}
/// Calculates a 64-bit non-crypto hash from the given hashable type.
///
/// If you already have a slice of bytes, use the one-shot version instead.
pub fn calculate_non_crypto_hash<T: Hash>(t: &T) -> u64 {
let mut hasher = create_non_crypto_hasher();
t.hash(&mut hasher);
hasher.finish()
}
/// Creates a hasher for calculating a 64-bit non-crypto hash.
pub fn create_non_crypto_hasher() -> impl Hasher {
NonCryptoHasher::new()
}
/// Creates a builder for a hasher for calculating a 64-bit non-crypto hash.
pub fn create_non_crypto_hash_builder() -> NonCryptoHashBuilder {
NonCryptoHashBuilder::new()
}
/// This newtype should be used whenever it matters to keep a stable hash function, for example
/// when the hashes are going to be persisted.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub struct PersistentHash(u128);
impl PersistentHash {
pub fn get(&self) -> u128 {
self.0
}
}
#[derive(Default)]
pub struct PersistentHasher(Xxh3Default);
impl PersistentHasher {
pub fn new() -> Self {
// Don't change the wrapped hasher! It's used e.g. for file names.
Self::default()
}
pub fn digest_128(&self) -> PersistentHash {
PersistentHash(self.0.digest128())
}
}
impl Hasher for PersistentHasher {
fn finish(&self) -> u64 {
self.0.finish()
}
fn write(&mut self, bytes: &[u8]) {
self.0.write(bytes)
}
}
+49
View File
@@ -0,0 +1,49 @@
#[macro_use]
mod regex_util;
#[macro_use]
pub mod tracing_util;
#[macro_use]
mod global_macros;
mod mouse;
pub use mouse::*;
mod global;
pub use global::*;
pub mod default_util;
pub mod hash_util;
mod channels;
pub use channels::*;
mod mutex_util;
pub use mutex_util::*;
pub mod file_util;
pub mod future_util;
pub mod metrics_util;
mod small_ascii_string;
pub use small_ascii_string::*;
mod sound_player;
pub use sound_player::*;
pub mod validation_util;
pub mod peak_util;
pub mod byte_pattern;
pub mod serde_json_util;
mod approx_f64;
pub use approx_f64::*;
pub mod replenishment_channel;
@@ -0,0 +1,131 @@
use std::sync::mpsc::{Receiver, SyncSender};
use std::sync::OnceLock;
use std::thread;
use std::time::{Duration, Instant};
/// This will contain the metrics sender for async metrics recording if metrics are enabled.
static METRICS_SENDER: OnceLock<SyncSender<MetricsRecorderCommand>> = OnceLock::new();
#[derive(Debug)]
pub struct MetricsHook {
sender: SyncSender<MetricsRecorderCommand>,
}
impl Drop for MetricsHook {
fn drop(&mut self) {
// This prevents the metrics recorder thread from lurking around after the library
// is unloaded (which is of importance if "Allow complete unload of VST plug-ins"
// is enabled in REAPER for Windows). Ideally, we would just destroy the sender to achieve
// the same effect. But the sender is in a static variable which already has been
// initialized once and therefore can't be set to `None`. Unloading the library will just
// free the memory without triggering the drop, so that wouldn't work either.
let _ = self.sender.try_send(MetricsRecorderCommand::Finish);
// Joining the thread here somehow leads to a deadlock. Not sure why. It doesn't
// seem to be necessary anyway. The thread will end no matter what.
}
}
impl MetricsHook {
/// Initializes metrics recording if the env variable `HELGOBOX_METRICS` is set.
///
/// This starts a dedicated metrics recording thread, which is responsible for actually
/// recording certain metrics (e.g. durations), which is especially important when measuring
/// stuff from real-time threads. It avoids allocation and doesn't slow down real-time
/// processing (with the exception of measuring the duration itself).
///
/// This should be called only once within the lifetime of the loaded shared library! On Linux
/// and macOS, this means it must only be called once within the lifetime of REAPER because once
/// a shared library is loaded, it's not unloaded anymore. On Windows, it can be called again
/// after REAPER unloaded the library via `FreeLibrary` and reloaded it again.
///
/// The returned metrics hook must be dropped before the library is unloaded, otherwise the
/// metrics thread sticks around and that can't be good.
pub fn init() -> Option<Self> {
std::env::var("HELGOBOX_METRICS").ok()?;
let (sender, receiver) = std::sync::mpsc::sync_channel(5000);
thread::Builder::new()
.name(String::from("Helgobox metrics"))
.spawn(move || {
keep_recording_metrics(receiver);
})
.expect("Helgobox metrics thread couldn't be created");
METRICS_SENDER
.set(sender.clone())
.expect("attempting to initializing metrics hook more than once");
let hook = Self { sender };
Some(hook)
}
}
/// A simple function that doesn't expose anything to the metrics endpoint but warns if a
/// threshold is exceeded. Doesn't do anything in release builds (except executing the function).
pub fn warn_if_takes_too_long<R>(label: &'static str, max: Duration, f: impl FnOnce() -> R) -> R {
#[cfg(debug_assertions)]
{
let before = Instant::now();
let r = f();
let elapsed = before.elapsed();
if elapsed > max {
tracing::warn!(
"Operation took too long: \"{label}\" ({})ms",
elapsed.as_millis()
);
}
r
}
#[cfg(not(debug_assertions))]
{
let _ = (label, max);
f()
}
}
/// Synchronously records the occurrence of the given event.
pub fn record_occurrence(id: &'static str) {
if !metrics_are_enabled() {
return;
}
metrics::counter!(id).increment(1);
}
/// Asynchronously measures and records the time of the given operation and exposes it at the
/// metrics endpoint.
pub fn measure_time<R>(id: &'static str, f: impl FnOnce() -> R) -> R {
if !metrics_are_enabled() {
return f();
}
let start = Instant::now();
let result = f();
record_duration(id, start.elapsed());
result
}
/// Records the given duration into a histogram.
pub fn record_duration(id: &'static str, delta: Duration) {
if let Some(sender) = METRICS_SENDER.get() {
let task = MetricsRecorderCommand::Histogram { id, delta };
if sender.try_send(task).is_err() {
tracing::debug!("Helgobox metrics channel is full");
}
}
}
pub fn metrics_are_enabled() -> bool {
METRICS_SENDER.get().is_some()
}
enum MetricsRecorderCommand {
Finish,
Histogram { id: &'static str, delta: Duration },
}
fn keep_recording_metrics(receiver: Receiver<MetricsRecorderCommand>) {
while let Ok(task) = receiver.recv() {
match task {
MetricsRecorderCommand::Finish => break,
MetricsRecorderCommand::Histogram { id, delta } => {
metrics::histogram!(id).record(delta);
}
}
}
}
@@ -0,0 +1,41 @@
use helgobox_api::persistence::{Axis, MouseButton};
pub trait Mouse {
fn axis_size(&self, axis: Axis) -> u32;
fn cursor_position(&self) -> Result<MouseCursorPosition, &'static str>;
fn set_cursor_position(&mut self, new_pos: MouseCursorPosition) -> Result<(), &'static str>;
/// Moves the mouse cursor relatively to its current position.
///
/// - On the x axis, positive delta scrolls right and negative left.
/// - On the y axis, positive delta scrolls down and negative up (because it's natural for
/// screens to consider the top-left as zero).
fn adjust_cursor_position(&mut self, x_delta: i32, y_delta: i32) -> Result<(), &'static str>;
/// Invokes the scroll wheel.
///
/// - On the x axis, positive delta scrolls right and negative left.
/// - On the y axis, positive delta scrolls up and negative down (because it's natural for
/// knobs and especially faders to increase when scrolling up).
fn scroll(&mut self, axis: Axis, delta: i32) -> Result<(), &'static str>;
fn press(&mut self, button: MouseButton) -> Result<(), &'static str>;
fn release(&mut self, button: MouseButton) -> Result<(), &'static str>;
fn is_pressed(&self, button: MouseButton) -> Result<bool, &'static str>;
}
#[derive(Copy, Clone, Debug)]
pub struct MouseCursorPosition {
pub x: u32,
pub y: u32,
}
impl MouseCursorPosition {
pub fn new(x: u32, y: u32) -> Self {
Self { x, y }
}
}
@@ -0,0 +1,159 @@
use crate::{Mouse, MouseCursorPosition};
use device_query::DeviceState;
use enigo::{Enigo, MouseControllable};
use helgobox_api::persistence::{Axis, MouseButton};
use std::fmt::Debug;
#[derive(Debug)]
pub struct EnigoMouse {
enigo: Enigo,
device_state: Option<DeviceState>,
}
impl Default for EnigoMouse {
fn default() -> Self {
Self::new()
}
}
impl EnigoMouse {
pub fn new() -> Self {
Self {
enigo: Default::default(),
device_state: create_device_state(),
}
}
}
fn create_device_state() -> Option<DeviceState> {
#[cfg(target_os = "macos")]
{
let trusted =
macos_accessibility_client::accessibility::application_is_trusted_with_prompt();
if trusted {
Some(DeviceState::new())
} else {
reaper_high::Reaper::get().show_console_msg("This Helgobox feature only works if Helgobox can access the state of your mouse. For this, it needs macOS accessibility permissions. Please grant REAPER the accessibility permission in the macOS system settings and restart it!\n\n");
None
}
}
#[cfg(not(target_os = "macos"))]
{
Some(DeviceState::new())
}
}
unsafe impl Send for EnigoMouse {}
impl Clone for EnigoMouse {
fn clone(&self) -> Self {
Self::new()
}
}
impl PartialEq for EnigoMouse {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl Eq for EnigoMouse {}
impl Mouse for EnigoMouse {
fn axis_size(&self, axis: Axis) -> u32 {
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
let (width, height) = Enigo::main_display_size();
let axis_size = match axis {
Axis::X => width,
Axis::Y => height,
};
axis_size as u32
}
#[cfg(target_os = "linux")]
{
let index = match axis {
Axis::X => reaper_low::raw::SM_CXSCREEN,
Axis::Y => reaper_low::raw::SM_CYSCREEN,
};
reaper_low::Swell::get().GetSystemMetrics(index) as _
}
}
fn cursor_position(&self) -> Result<MouseCursorPosition, &'static str> {
#[cfg(any(target_os = "windows", target_os = "macos"))]
let (x, y) = Enigo::mouse_location();
#[cfg(target_os = "linux")]
let (x, y) = {
let device_state = self
.device_state
.as_ref()
.expect("DeviceState should always work on Linux")
.query_pointer();
(device_state.coords.0, device_state.coords.1)
};
Ok(MouseCursorPosition::new(x.max(0) as u32, y.max(0) as u32))
}
fn set_cursor_position(&mut self, new_pos: MouseCursorPosition) -> Result<(), &'static str> {
self.enigo.mouse_move_to(new_pos.x as _, new_pos.y as _);
Ok(())
}
fn adjust_cursor_position(&mut self, x_delta: i32, y_delta: i32) -> Result<(), &'static str> {
self.enigo.mouse_move_relative(x_delta, y_delta);
Ok(())
}
fn scroll(&mut self, axis: Axis, delta: i32) -> Result<(), &'static str> {
match axis {
Axis::X => self.enigo.mouse_scroll_x(delta),
Axis::Y => {
// Handle https://github.com/enigo-rs/enigo/issues/117
let final_delta = if cfg!(windows) { delta } else { -delta };
self.enigo.mouse_scroll_y(final_delta)
}
}
Ok(())
}
fn press(&mut self, button: MouseButton) -> Result<(), &'static str> {
self.enigo.mouse_down(convert_button_to_enigo(button));
Ok(())
}
fn release(&mut self, button: MouseButton) -> Result<(), &'static str> {
self.enigo.mouse_up(convert_button_to_enigo(button));
Ok(())
}
fn is_pressed(&self, button: MouseButton) -> Result<bool, &'static str> {
let mouse_state = self
.device_state
.as_ref()
.ok_or("macOS accessibility permissions not granted")?
.query_pointer();
let button_index = convert_button_to_device_query(button);
let pressed = mouse_state
.button_pressed
.get(button_index)
.ok_or("couldn't get button")?;
Ok(*pressed)
}
}
fn convert_button_to_device_query(button: MouseButton) -> usize {
match button {
MouseButton::Left => 1,
MouseButton::Middle => 3,
MouseButton::Right => 2,
}
}
fn convert_button_to_enigo(button: MouseButton) -> enigo::MouseButton {
match button {
MouseButton::Left => enigo::MouseButton::Left,
MouseButton::Middle => enigo::MouseButton::Middle,
MouseButton::Right => enigo::MouseButton::Right,
}
}
@@ -0,0 +1,4 @@
mod api;
pub use api::*;
pub mod enigo;
@@ -0,0 +1,90 @@
use crate::metrics_util::warn_if_takes_too_long;
use std::sync::{Arc, Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
use std::time::Duration;
/// Attempts to lock the given mutex.
///
/// Returns the guard even if mutex is poisoned.
///
/// # Panics
///
/// Panics in debug builds if already locked (blocks in release builds).
pub fn non_blocking_lock<'a, T>(
mutex: &'a Mutex<T>,
description: &'static str,
) -> MutexGuard<'a, T> {
// TODO-high-performance This panics when pressing play. Check it out!
// #[cfg(debug_assertions)]
// match mutex.try_lock() {
// Ok(g) => g,
// Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
// Err(std::sync::TryLockError::WouldBlock) => {
// panic!("locking mutex would block: {}", description)
// }
// }
// #[cfg(not(debug_assertions))]
blocking_lock(mutex, description)
}
/// Locks the given mutex.
///
/// Returns the guard even if mutex is poisoned.
pub fn blocking_lock_arc<'a, T>(
mutex: &'a Arc<Mutex<T>>,
description: &'static str,
) -> MutexGuard<'a, T> {
blocking_lock(&**mutex, description)
}
/// Locks the given mutex.
///
/// Returns the guard even if mutex is poisoned.
pub fn blocking_lock<'a, T>(mutex: &'a Mutex<T>, description: &'static str) -> MutexGuard<'a, T> {
warn_if_takes_too_long(description, MAX_MUTEX_LOCK_DURATION, || {
match mutex.lock() {
Ok(g) => g,
Err(e) => e.into_inner(),
}
})
}
/// Locks the given rw lock.
///
/// Returns the guard even if mutex is poisoned.
pub fn blocking_read_lock<'a, T>(
rw_lock: &'a RwLock<T>,
description: &'static str,
) -> RwLockReadGuard<'a, T> {
warn_if_takes_too_long(description, MAX_MUTEX_LOCK_DURATION, || {
match rw_lock.read() {
Ok(g) => g,
Err(e) => e.into_inner(),
}
})
}
/// Returns `None` if access would block.
pub fn non_blocking_try_read_lock<T>(rw_lock: &RwLock<T>) -> Option<RwLockReadGuard<T>> {
match rw_lock.try_read() {
Ok(g) => Some(g),
Err(std::sync::TryLockError::Poisoned(e)) => Some(e.into_inner()),
Err(std::sync::TryLockError::WouldBlock) => None,
}
}
/// Locks the given rw lock.
///
/// Returns the guard even if mutex is poisoned.
pub fn blocking_write_lock<'a, T>(
rw_lock: &'a RwLock<T>,
description: &'static str,
) -> RwLockWriteGuard<'a, T> {
warn_if_takes_too_long(description, MAX_MUTEX_LOCK_DURATION, || {
match rw_lock.write() {
Ok(g) => g,
Err(e) => e.into_inner(),
}
})
}
const MAX_MUTEX_LOCK_DURATION: Duration = Duration::from_millis(30);
@@ -0,0 +1,36 @@
use either::Either;
use reaper_high::{Reaper, Track};
use reaper_medium::{MediaTrack, ReaperVolumeValue, SoloMode, TrackAttributeKey};
use std::iter;
/// Returns whether the peaks should better be hidden even they are available.
///
/// This is for the case if another track is soloed, reporting the peak would be misleading then.
pub fn peaks_should_be_hidden(track: &Track) -> bool {
let is_master = track.is_master_track();
(is_master && track.is_muted())
|| (!is_master && track.project().any_solo() && track.solo_mode() == SoloMode::Off)
}
/// Returns the track's peaks as iterator.
///
/// This takes VU mode / channel count intricacies into account. It returns peaks even if another
/// track is soloed! See [`peaks_should_be_hidden`].
pub fn get_track_peaks(track: MediaTrack) -> impl ExactSizeIterator<Item = ReaperVolumeValue> {
let reaper = Reaper::get().medium_reaper();
let vu_mode =
unsafe { reaper.get_media_track_info_value(track, TrackAttributeKey::VuMode) as i32 };
let channel_count = if matches!(vu_mode, 2 | 8) {
// These VU modes have multi-channel support.
unsafe { reaper.get_media_track_info_value(track, TrackAttributeKey::Nchan) as i32 }
} else {
// Other VU modes always use stereo.
2
};
if channel_count <= 0 {
return Either::Left(iter::empty());
}
let iter =
(0..channel_count).map(move |ch| unsafe { reaper.track_get_peak_info(track, ch as u32) });
Either::Right(iter)
}
@@ -0,0 +1,7 @@
#[macro_export]
macro_rules! regex {
($re:literal $(,)?) => {{
static RE: once_cell::sync::OnceCell<regex::Regex> = once_cell::sync::OnceCell::new();
RE.get_or_init(|| regex::Regex::new($re).unwrap())
}};
}
@@ -0,0 +1,49 @@
use futures::future::BoxFuture;
use tokio::sync::mpsc::Receiver;
use tracing::debug;
/// An orchestration (task and receiver) to be used to supply the receiver with spare parts that might or might not be
/// necessary.
///
/// This is usually used in scenarios where the consumer lives in a thread that is not allowed to allocate
/// (for example, real-time threads).
pub struct ReplenishmentOrchestration<T, F> {
pub task: F,
pub receiver: ReplenishmentReceiver<T>,
}
/// Creates an orchestration.
///
/// The capacity should be very low, depending on how many spare items you want to create.
pub fn orchestrate_replenishment<T>(
capacity: usize,
mut create_next_item: impl FnMut() -> T + Send + 'static,
) -> ReplenishmentOrchestration<T, BoxFuture<'static, ()>>
where
T: Send + 'static,
{
let (sender, receiver) = tokio::sync::mpsc::channel::<T>(capacity);
let task = async move {
while let Ok(permit) = sender.reserve().await {
debug!("Replenishment channel has capacity. Create next item.");
let item = create_next_item();
permit.send(item);
}
};
ReplenishmentOrchestration {
receiver: ReplenishmentReceiver { receiver },
task: Box::pin(task),
}
}
#[derive(Debug)]
pub struct ReplenishmentReceiver<T> {
receiver: Receiver<T>,
}
impl<T> ReplenishmentReceiver<T> {
/// Returns the next available item if one is available.
pub fn request_item(&mut self) -> Option<T> {
self.receiver.try_recv().ok()
}
}
@@ -0,0 +1,18 @@
use serde_json::Value;
/// https://stackoverflow.com/a/54118457
pub fn merge(a: &mut Value, b: Value) {
if let Value::Object(a) = a {
if let Value::Object(b) = b {
for (k, v) in b {
if v.is_null() {
a.remove(&k);
} else {
merge(a.entry(k).or_insert(Value::Null), v);
}
}
return;
}
}
*a = b;
}
@@ -0,0 +1,84 @@
use ascii::{AsciiChar, AsciiStr, AsciiString, ToAsciiChar};
use core::fmt;
/// String with a maximum of 32 ASCII characters.
///
/// It's useful in the audio thread because it can be cheaply copied and doesn't need allocation.
/// If you are okay with allocation and need cheap cloning, you could just as well use an
/// `Rc<String>`.
pub type SmallAsciiString = LimitedAsciiString<32>;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)]
pub struct LimitedAsciiString<const N: usize> {
length: u8,
content: [u8; N],
}
impl<const N: usize> LimitedAsciiString<N> {
pub const MAX_LENGTH: usize = N;
/// Crops the string if necessary.
pub fn from_ascii_str_cropping(ascii_str: &AsciiStr) -> Self {
let short =
AsciiString::from(&ascii_str.as_slice()[..Self::MAX_LENGTH.min(ascii_str.len())]);
Self::from_ascii_str(&short)
}
/// Returns an error if the given string is not completely ASCII or is too long.
pub fn try_from_str(value: &str) -> Result<Self, &'static str> {
let ascii_string: Result<AsciiString, _> =
value.chars().map(|c| c.to_ascii_char()).collect();
let ascii_string = ascii_string.map_err(|_| "value contains non-ASCII characters")?;
Self::try_from_ascii_str(&ascii_string)
}
/// Returns an error if the given string is too long.
pub fn try_from_ascii_str(ascii_str: &AsciiStr) -> Result<Self, &'static str> {
if ascii_str.len() > Self::MAX_LENGTH {
return Err("ASCII string too large");
}
Ok(Self::from_ascii_str(ascii_str))
}
/// Panics if the given string is too long.
fn from_ascii_str(ascii_str: &AsciiStr) -> Self {
let mut content = [0u8; N];
content[..ascii_str.len()].copy_from_slice(ascii_str.as_bytes());
Self {
content,
length: ascii_str.len() as u8,
}
}
pub fn as_ascii_str(&self) -> &AsciiStr {
AsciiStr::from_ascii(self.as_slice()).unwrap()
}
pub fn as_slice(&self) -> &[u8] {
&self.content[..(self.length as usize)]
}
}
pub fn convert_to_identifier(text: &str) -> Result<SmallAsciiString, &'static str> {
let ascii_string: AsciiString = text
.chars()
// Remove all non-ASCII schars
.filter_map(|c| c.to_ascii_char().ok())
// Allow only letters, digits and underscore
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == AsciiChar::UnderScore)
// Skip leading digits
.skip_while(|ch| ch.is_ascii_digit())
// No uppercase
.map(|ch| ch.to_ascii_lowercase())
.collect();
if ascii_string.is_empty() {
return Err("empty tag");
}
Ok(SmallAsciiString::from_ascii_str_cropping(&ascii_string))
}
impl<const N: usize> fmt::Display for LimitedAsciiString<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.as_ascii_str().fmt(f)
}
}
@@ -0,0 +1,92 @@
use anyhow::{ensure, Context};
use camino::Utf8Path;
use reaper_high::Reaper;
use reaper_low::raw;
use reaper_medium::{
FlexibleOwnedPcmSource, Handle, MeasureAlignment, MidiImportBehavior, OwnedPreviewRegister,
PositionInSeconds, ReaperMutex, ReaperMutexGuard, ReaperVolumeValue,
};
use std::cell::Cell;
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct SoundPlayer {
preview_register: Arc<ReaperMutex<OwnedPreviewRegister>>,
play_handle: Cell<Option<Handle<raw::preview_register_t>>>,
}
unsafe impl Send for SoundPlayer {}
impl Default for SoundPlayer {
fn default() -> Self {
Self::new()
}
}
impl SoundPlayer {
pub fn new() -> Self {
let mut register = OwnedPreviewRegister::new();
register.set_volume(ReaperVolumeValue::ZERO_DB);
let preview_register = Arc::new(ReaperMutex::new(register));
Self {
preview_register,
play_handle: Cell::new(None),
}
}
pub fn load_file(&mut self, path_to_file: &Utf8Path) -> anyhow::Result<()> {
ensure!(path_to_file.exists(), "sound file doesn't exist");
let source = Reaper::get()
.medium_reaper()
.pcm_source_create_from_file_ex(path_to_file, MidiImportBehavior::UsePreference)?;
self.load_pcm_source(FlexibleOwnedPcmSource::Reaper(source))
}
pub fn load_pcm_source(&mut self, source: FlexibleOwnedPcmSource) -> anyhow::Result<()> {
let mut preview_register = self.lock_preview_register()?;
preview_register.set_src(Some(source));
Ok(())
}
pub fn volume(&self) -> anyhow::Result<ReaperVolumeValue> {
let preview_register = self.lock_preview_register()?;
Ok(preview_register.volume())
}
pub fn set_volume(&self, volume: ReaperVolumeValue) -> anyhow::Result<()> {
let mut preview_register = self.lock_preview_register()?;
preview_register.set_volume(volume);
Ok(())
}
pub fn play(&self) -> anyhow::Result<()> {
if self.play_handle.get().is_some() {
// Is playing already. Simply rewind.
let mut preview_register = self.lock_preview_register()?;
preview_register.set_cur_pos(PositionInSeconds::ZERO);
} else {
// Is not yet playing. Start playing.
let handle = Reaper::get().medium_session().play_preview_ex(
self.preview_register.clone(),
Default::default(),
MeasureAlignment::PlayImmediately,
)?;
self.play_handle.set(Some(handle));
}
Ok(())
}
pub fn stop(&self) -> anyhow::Result<()> {
let play_handle = self.play_handle.take().context("not playing")?;
Reaper::get().medium_session().stop_preview(play_handle)?;
self.lock_preview_register()?
.set_cur_pos(PositionInSeconds::ZERO);
Ok(())
}
fn lock_preview_register(&self) -> anyhow::Result<ReaperMutexGuard<OwnedPreviewRegister>> {
self.preview_register
.lock()
.context("couldn't acquire preview register lock in sound player")
}
}
@@ -0,0 +1,19 @@
use std::error::Error;
use std::fmt::Display;
pub fn ok_or_log_as_warn<T, E: Display>(result: Result<T, E>) -> Option<T> {
match result {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!("{e}");
None
}
}
}
pub fn log_if_error<T>(result: Result<T, impl AsRef<dyn Error>>) {
if let Err(error) = result {
let error = error.as_ref();
tracing::error!(msg = "Error", error);
}
}
@@ -0,0 +1,42 @@
use crate::hash_util::NonCryptoHashSet;
use std::error::Error;
use std::fmt::Display;
use std::hash::Hash;
#[derive(Debug, derive_more::Display)]
pub struct ValidationError(String);
impl Error for ValidationError {}
#[allow(clippy::unnecessary_filter_map)]
pub fn ensure_no_duplicate<T>(list_label: &str, iter: T) -> Result<(), ValidationError>
where
T: IntoIterator,
T::Item: Eq + Hash + Display,
{
use std::fmt::Write;
let mut uniq = NonCryptoHashSet::default();
let duplicates: NonCryptoHashSet<_> = iter
.into_iter()
.filter_map(|d| {
if uniq.contains(&d) {
Some(d)
} else {
uniq.insert(d);
None
}
})
.collect();
if duplicates.is_empty() {
Ok(())
} else {
let mut s = format!("Found the following duplicate {list_label}: ");
for (i, d) in duplicates.into_iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
let _ = write!(&mut s, "{d}");
}
Err(ValidationError(s))
}
}