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
+48
View File
@@ -0,0 +1,48 @@
[package]
name = "pot"
version = "0.1.0"
authors = ["Benjamin Klum <benjamin.klum@helgoboss.org>"]
edition = "2021"
publish = false
[dependencies]
# Own
base.workspace = true
reaper-high.workspace = true
reaper-medium.workspace = true
helgobox-api.workspace = true
rppxml-parser.workspace = true
# 3rd-party
enumset.workspace = true
itertools.workspace = true
wildmatch.workspace = true
serde.workspace = true
derive_more.workspace = true
tokio.workspace = true
enum-map.workspace = true
once_cell.workspace = true
lexical-sort.workspace = true
strum.workspace = true
splitty.workspace = true
rust-ini.workspace = true
walkdir.workspace = true
either.workspace = true
riff-io.workspace = true
rusqlite = { workspace = true, features = ["bundled"] }
rmp-serde.workspace = true
serde_json.workspace = true
dirs.workspace = true
futures.workspace = true
derivative.workspace = true
tempfile.workspace = true
sanitize-filename.workspace = true
tracing.workspace = true
regex.workspace = true
nanoid.workspace = true
chrono.workspace = true
anyhow.workspace = true
camino.workspace = true
[lints.clippy]
enum_glob_use = "deny"
+710
View File
@@ -0,0 +1,710 @@
use crate::plugins::ProductKind;
use crate::provider_database::{
DatabaseId, FIL_HAS_PREVIEW_TRUE, FIL_IS_FAVORITE_TRUE, FIL_IS_USER_PRESET_FALSE,
FIL_IS_USER_PRESET_TRUE,
};
use crate::{FilterItem, PotPreset};
use enum_map::EnumMap;
use enumset::EnumSet;
use helgobox_api::persistence::PotFilterKind;
use once_cell::sync::Lazy;
use std::collections::HashSet;
use base::hash_util::{NonCryptoHashMap, NonCryptoHashSet};
use std::fmt;
use std::fmt::{Display, Formatter, Write};
use std::str::FromStr;
use strum::IntoEnumIterator;
/// An ID for uniquely identifying a preset along with its corresponding database.
///
/// This ID is stable only at runtime and only until the database is refreshed.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct PresetId {
pub database_id: DatabaseId,
pub preset_id: InnerPresetId,
}
impl PresetId {
pub fn new(database_id: DatabaseId, preset_id: InnerPresetId) -> Self {
Self {
database_id,
preset_id,
}
}
}
/// An ID for uniquely identifying a preset within a certain database.
///
/// This ID is stable only at runtime and only until the database is refreshed.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct InnerPresetId(pub u32);
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Default)]
pub struct FilterItemId(pub Option<Fil>);
impl FilterItemId {
pub const NONE: Self = Self(None);
}
/// Filter value.
///
/// These can be understood as possible types of a filter item kind. Not all types make sense
/// for a particular filter item kind.
///
/// Many of these types are not suitable for persistence because their values are not stable.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub enum Fil {
/// A typical integer filter value to refer to a filter item in a specific Komplete database.
///
/// Makes sense for all filter kinds supported by Komplete.
///
/// This needs a pot filter item kind and a specific Komplete database to make full sense.
/// The integers are not suited for being persisted because different Komplete scans can yield
/// different integers! So they should only be used at runtime and translated to something
/// more stable for persistence.
Komplete(u32),
/// Refers to a specific pot database.
///
/// Only makes sense for the pot filter kind "Database".
///
/// Only valid at runtime, not suitable for persistence.
Database(DatabaseId),
/// Refers to something that can be true of false, e.g. "favorite" or "not favorite"
/// or "available" or "not available".
///
/// Makes sense for all boolean filter kinds.
///
/// Suitable for persistence.
Boolean(bool),
/// Refers to a kind of product.
///
/// Only makes sense for the pot filter kind "Product kind".
ProductKind(ProductKind),
/// Refers to a product.
///
/// Only makes sense for the pot filter kind "Product".
///
/// Not suitable for persistence because product IDs are created at runtime.
Product(ProductId),
/// Refers to a project.
///
/// Only makes sense for the pot filter kind "Project".
///
/// Not suitable for persistence because project IDs are created at runtime.
Project(ProjectId),
}
/// Runtime ID for a [`Product`].
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, derive_more::Display)]
pub struct ProductId(pub u32);
/// Runtime ID for a REAPER project.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, derive_more::Display)]
pub struct ProjectId(pub u32);
pub type FilterItemCollections = GenericFilterItemCollections<FilterItem>;
#[derive(Debug)]
pub struct GenericFilterItemCollections<T>(EnumMap<PotFilterKind, Vec<T>>);
pub trait HasFilterItemId {
fn id(&self) -> FilterItemId;
}
impl HasFilterItemId for FilterItem {
fn id(&self) -> FilterItemId {
self.id
}
}
impl<T> Default for GenericFilterItemCollections<T> {
fn default() -> Self {
Self(enum_map::enum_map! { _ => vec![] })
}
}
impl<T> GenericFilterItemCollections<T> {
pub fn empty() -> Self {
Default::default()
}
pub fn get(&self, kind: PotFilterKind) -> &[T] {
&self.0[kind]
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (PotFilterKind, &mut Vec<T>)> {
self.0.iter_mut()
}
pub fn set(&mut self, kind: PotFilterKind, items: Vec<T>) {
self.0[kind] = items;
}
pub fn extend(&mut self, kind: PotFilterKind, items: impl Iterator<Item = T>) {
self.0[kind].extend(items);
}
pub fn are_filled_already(&self) -> bool {
// Just take any of of the constant filters that should be filled.
!self.get(PotFilterKind::IsFavorite).is_empty()
}
}
impl<T> IntoIterator for GenericFilterItemCollections<T> {
type Item = (PotFilterKind, Vec<T>);
type IntoIter = <EnumMap<PotFilterKind, Vec<T>> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl<T: HasFilterItemId> GenericFilterItemCollections<T> {
pub fn narrow_down(&mut self, kind: PotFilterKind, includes: &NonCryptoHashSet<FilterItemId>) {
self.0[kind].retain(|item| includes.contains(&item.id()))
}
}
/// `Some` means a filter is set (can also be the `<None>` filter).
/// `None` means no filter is set (`<Any>`).
pub type OptFilter = Option<FilterItemId>;
#[derive(Copy, Clone, Debug, Default)]
pub struct Filters(EnumMap<PotFilterKind, OptFilter>);
impl Filters {
pub fn empty() -> Self {
Self::default()
}
pub fn wants_preview(&self) -> Option<bool> {
if let Some(FilterItemId(Some(fil))) = self.get(PotFilterKind::HasPreview) {
Some(fil == FIL_HAS_PREVIEW_TRUE)
} else {
None
}
}
pub fn database_matches(&self, db_id: DatabaseId) -> bool {
self.matches(PotFilterKind::Database, Fil::Database(db_id))
}
pub fn wants_user_presets_only(&self) -> bool {
self.wants_only(PotFilterKind::IsUser, FIL_IS_USER_PRESET_TRUE)
}
pub fn wants_factory_presets_only(&self) -> bool {
self.wants_only(PotFilterKind::IsUser, FIL_IS_USER_PRESET_FALSE)
}
pub fn wants_favorites_only(&self) -> bool {
self.wants_only(PotFilterKind::IsFavorite, FIL_IS_FAVORITE_TRUE)
}
pub fn any_unsupported_filter_is_set_to_concrete_value(
&self,
supported_advanced_kinds: EnumSet<PotFilterKind>,
) -> bool {
let supported_kinds = supported_advanced_kinds.union(PotFilterKind::core_kinds());
supported_kinds
.complement()
.iter()
.any(|k| self.is_set_to_concrete_value(k))
}
/// To be used with filter kinds where `<None>` is **not** a valid filter value. In this case,
/// <None> is considered an invalid value and it never matches (no reason to panic but almost).
pub fn matches(&self, kind: PotFilterKind, fil: Fil) -> bool {
match self.get(kind) {
None => true,
Some(FilterItemId(None)) => false,
Some(FilterItemId(Some(wanted_fil))) => fil == wanted_fil,
}
}
pub fn favorite_matches(
&self,
favorites: &NonCryptoHashSet<InnerPresetId>,
preset_id: InnerPresetId,
) -> bool {
match self.get(PotFilterKind::IsFavorite) {
None => true,
Some(FilterItemId(None)) => false,
Some(FilterItemId(Some(fil))) => {
if fil == FIL_IS_FAVORITE_TRUE {
favorites.contains(&preset_id)
} else {
!favorites.contains(&preset_id)
}
}
}
}
/// To be used with filter kinds where `<None>` is a valid filter value.
pub fn matches_optional(&self, kind: PotFilterKind, fil: Option<Fil>) -> bool {
match self.get(kind) {
// <Any>
None => true,
// <None> or a specific value
Some(FilterItemId(wanted_fil)) => fil == wanted_fil,
}
}
fn wants_only(&self, kind: PotFilterKind, fil: Fil) -> bool {
self.get(kind) == Some(FilterItemId(Some(fil)))
}
/// Returns `false` if set to `None`
pub fn is_set_to_concrete_value(&self, kind: PotFilterKind) -> bool {
matches!(self.0[kind], Some(FilterItemId(Some(_))))
}
pub fn get(&self, kind: PotFilterKind) -> OptFilter {
self.0[kind]
}
pub fn get_ref(&self, kind: PotFilterKind) -> &OptFilter {
&self.0[kind]
}
pub fn set(&mut self, kind: PotFilterKind, value: OptFilter) {
self.0[kind] = value;
}
pub fn effective_sub_bank(&self) -> &OptFilter {
self.effective_sub_item(PotFilterKind::Bank, PotFilterKind::SubBank)
}
pub fn clear_excluded_ones(&mut self, exclude_list: &PotFilterExcludes) {
for kind in PotFilterKind::iter() {
if let Some(id) = self.0[kind] {
if exclude_list.contains(kind, id) {
self.0[kind] = None;
}
}
}
}
pub fn clear_if_not_available_anymore(
&mut self,
affected_kinds: EnumSet<PotFilterKind>,
collections: &FilterItemCollections,
) {
for kind in affected_kinds {
if let Some(id) = self.0[kind] {
let valid_items = collections.get(kind);
if !valid_items.iter().any(|item| item.id == id) {
self.0[kind] = None;
}
}
}
}
pub fn iter(&self) -> impl Iterator<Item = (PotFilterKind, OptFilter)> {
self.0.into_iter()
}
pub fn effective_sub_category(&self) -> &OptFilter {
self.effective_sub_item(PotFilterKind::Category, PotFilterKind::SubCategory)
}
pub fn clear_this_and_dependent_filters(&mut self, kind: PotFilterKind) {
self.set(kind, None);
for dependent_kind in kind.dependent_kinds() {
self.set(dependent_kind, None);
}
}
fn effective_sub_item(
&self,
parent_kind: PotFilterKind,
sub_kind: PotFilterKind,
) -> &OptFilter {
let category = &self.0[parent_kind];
if category == &Some(FilterItemId::NONE) {
category
} else {
&self.0[sub_kind]
}
}
}
#[derive(Debug, Default)]
pub struct PotFavorites {
favorites: NonCryptoHashMap<DatabaseId, NonCryptoHashSet<InnerPresetId>>,
}
impl PotFavorites {
pub fn is_favorite(&self, preset_id: PresetId) -> bool {
if let Some(db_favorites) = self.favorites.get(&preset_id.database_id) {
db_favorites.contains(&preset_id.preset_id)
} else {
false
}
}
pub fn toggle_favorite(&mut self, preset_id: PresetId) {
let db_favorites = self.favorites.entry(preset_id.database_id).or_default();
if db_favorites.contains(&preset_id.preset_id) {
db_favorites.remove(&preset_id.preset_id);
} else {
db_favorites.insert(preset_id.preset_id);
}
}
pub fn db_favorites(&self, db_id: DatabaseId) -> &NonCryptoHashSet<InnerPresetId> {
static EMPTY_HASH_SET: Lazy<NonCryptoHashSet<InnerPresetId>> = Lazy::new(HashSet::default);
self.favorites.get(&db_id).unwrap_or(&EMPTY_HASH_SET)
}
}
#[derive(Clone, Debug, Default)]
pub struct PotFilterExcludes {
exluded_items: EnumMap<PotFilterKind, NonCryptoHashSet<FilterItemId>>,
}
impl PotFilterExcludes {
pub fn contains(&self, kind: PotFilterKind, id: FilterItemId) -> bool {
self.exluded_items[kind].contains(&id)
}
pub fn remove(&mut self, kind: PotFilterKind, id: FilterItemId) {
self.exluded_items[kind].remove(&id);
}
pub fn add(&mut self, kind: PotFilterKind, id: FilterItemId) {
self.exluded_items[kind].insert(id);
}
pub fn is_empty(&self, kind: PotFilterKind) -> bool {
self.exluded_items[kind].is_empty()
}
pub fn contains_database(&self, db_id: DatabaseId) -> bool {
self.contains(
PotFilterKind::Database,
FilterItemId(Some(Fil::Database(db_id))),
)
}
pub fn contains_product(&self, product_id: Option<ProductId>) -> bool {
self.contains(
PotFilterKind::Bank,
FilterItemId(product_id.map(Fil::Product)),
)
}
pub fn normal_excludes_by_kind(&self, kind: PotFilterKind) -> impl Iterator<Item = &Fil> + '_ {
self.exluded_items[kind]
.iter()
.filter_map(|id| id.0.as_ref())
}
pub fn contains_none(&self, kind: PotFilterKind) -> bool {
self.exluded_items[kind].contains(&FilterItemId::NONE)
}
}
#[derive(Debug)]
pub struct CurrentPreset {
pub preset: PotPreset,
pub macro_param_banks: Vec<MacroParamBank>,
}
#[derive(Debug)]
pub struct MacroParamBank {
params: Vec<MacroParam>,
}
impl MacroParamBank {
pub fn new(params: Vec<MacroParam>) -> Self {
Self { params }
}
pub fn name(&self) -> String {
let mut name = String::with_capacity(32);
for p in &self.params {
if let Some(section) = &p.section {
name += " / ";
name += section;
}
}
name
}
/// Returns the index of the section to which the parameter in the given slot belongs.
pub fn resolve_param_section_index(&self, slot_index: u32) -> Option<u32> {
self.params[0..=slot_index as usize]
.iter()
.filter(|param| param.section.is_some())
.enumerate()
.map(|(i, _)| i as u32)
.last()
}
/// Returns the section to which the parameter in the given slot belongs.
///
/// The parameter itself only carries its section name if it's the beginning of a new section.
pub fn resolve_param_section(&self, slot_index: u32) -> Option<&str> {
// Search from right to left
self.params[0..=slot_index as usize]
.iter()
.rev()
.find_map(|param| Some(param.section.as_ref()?.as_str()))
}
pub fn params(&self) -> &[MacroParam] {
&self.params
}
pub fn params_mut(&mut self) -> &mut [MacroParam] {
&mut self.params
}
pub fn find_macro_param_at(&self, slot_index: u32) -> Option<&MacroParam> {
self.params.get(slot_index as usize)
}
/// Returns slot index and param.
pub fn find_first_param_with_fx_param_index(&self, index: u32) -> Option<(u32, &MacroParam)> {
self.params.iter().enumerate().find_map(|(i, p)| {
let resolved_index = p.fx_param?.resolved_param_index?;
if index == resolved_index {
Some((i as u32, p))
} else {
None
}
})
}
pub fn param_count(&self) -> u32 {
self.params.len() as _
}
}
#[derive(Clone, Debug)]
pub struct MacroParam {
pub name: String,
/// The parameter itself only carries its section name if it's the beginning of a new section.
pub section: Option<String>,
pub fx_param: Option<PotFxParam>,
}
#[derive(Copy, Clone, Debug)]
pub struct PotFxParam {
pub param_id: PotFxParamId,
/// Only resolved on demand (when the FX is available).
pub resolved_param_index: Option<u32>,
}
#[derive(Copy, Clone, Debug, derive_more::Display)]
pub enum PotFxParamId {
/// Positional/index ID.
///
/// Some plug-in standards such as VST2 only support positional IDs.
Index(u32),
/// Real ID, independent of the position.
///
/// Supported by VST3 plug-ins.
Id(u32),
}
impl CurrentPreset {
pub fn preset(&self) -> &PotPreset {
&self.preset
}
pub fn find_macro_param_bank_at(&self, bank_index: u32) -> Option<&MacroParamBank> {
self.macro_param_banks.get(bank_index as usize)
}
/// Finds macro parameters across all banks, assuming a bank size of exactly 8.
pub fn find_macro_param_at(&self, slot_index: u32) -> Option<&MacroParam> {
let bank_index = slot_index / 8;
let bank_slot_index = slot_index % 8;
self.find_bank_macro_param_at(bank_index, bank_slot_index)
}
pub fn find_bank_macro_param_at(
&self,
bank_index: u32,
bank_slot_index: u32,
) -> Option<&MacroParam> {
self.macro_param_banks
.get(bank_index as usize)?
.find_macro_param_at(bank_slot_index)
}
/// Returns bank, slot index and param.
pub fn find_first_macro_param_with_fx_param_index(
&self,
index: u32,
) -> Option<(&MacroParamBank, u32, &MacroParam)> {
self.macro_param_banks.iter().find_map(|b| {
let (i, p) = b.find_first_param_with_fx_param_index(index)?;
Some((b, i, p))
})
}
pub fn macro_param_bank_count(&self) -> u32 {
self.macro_param_banks.len() as _
}
pub fn has_params(&self) -> bool {
!self.macro_param_banks.is_empty()
}
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct PersistentDatabaseId(String);
impl PersistentDatabaseId {
pub fn random() -> Self {
Self(nanoid::nanoid!())
}
pub const fn new(raw_id: String) -> Self {
PersistentDatabaseId(raw_id)
}
pub fn get(&self) -> &str {
&self.0
}
}
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct PersistentInnerPresetId(String);
impl PersistentInnerPresetId {
pub fn new(raw_id: String) -> Self {
Self(raw_id)
}
pub fn get(&self) -> &str {
&self.0
}
}
/// A preset ID that survives restarts and rescans.
///
/// It's more expensive to clone, hash etc. than [`PresetId`]. That's why we should only use it
/// for purposes where persistence matters, e.g. when saving favorites, currently selected preset
/// or for associating preview files.
///
/// The schema is `<DATABASE_ID>|<INNER_PRESET_ID>`. The pipe character `|` can also be used within
/// the inner preset ID, so in order to extract the database ID, it's important to split at the
/// first pipe character and ignore the other ones. In general, it should be avoided to use
/// the pipe character in the database ID or inner preset ID, but it's still important to escape it.
///
/// # Examples
///
/// - `defaults|vst2|1967946098`
/// - `track-templates|Synths/Lead.RTrackTemplate`
/// - `fx-chains|Synths/Sun.RfxChain`
/// - `fx-presets|vst3-Surge XT.ini|My Preset`
/// - `komplete|77c5507f5d0b421ea93eeb4cee4b6f99`
/// - `n98h1f9unp92|maojiao/2023-02-03-ben/2023-02-03-ben.RPP|0FF9F738-7CF6-8A49-9AEA-A9AF26DF9C46`
#[derive(Clone, Eq, PartialEq, Debug)]
pub struct PersistentPresetId {
db_id: PersistentDatabaseId,
inner_preset_id: PersistentInnerPresetId,
}
impl PersistentPresetId {
pub fn new(db_id: PersistentDatabaseId, inner_preset_id: PersistentInnerPresetId) -> Self {
Self {
db_id,
inner_preset_id,
}
}
}
impl Display for PersistentPresetId {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
// Database IDs ideally shouldn't contain pipe characters, but if they do, we escaped them.
let escaped_db_id = PipeEscaped(self.db_id.get());
write!(f, "{escaped_db_id}|{}", self.inner_preset_id.get())
}
}
impl FromStr for PersistentPresetId {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (escaped_db_id, inner_preset_id) = s
.split_once(unescaped_pipe_pattern())
.ok_or("no | separator found in persistent preset ID")?;
// Unescaped pipe character (in the unlikely case that there was any)
let db_id = unescape_pipes(escaped_db_id);
let id = Self {
db_id: PersistentDatabaseId(db_id),
inner_preset_id: PersistentInnerPresetId(inner_preset_id.to_string()),
};
Ok(id)
}
}
pub fn unescaped_pipe_pattern() -> impl FnMut(char) -> bool {
unescaped_char_pattern('|')
}
/// A Rust string matching pattern that matches the given character, but only if it's not preceded
/// by a backslash.
fn unescaped_char_pattern(needle: char) -> impl FnMut(char) -> bool {
let mut prev_char = None;
move |c: char| {
let matches = if c == needle {
prev_char != Some('\\')
} else {
false
};
prev_char = Some(c);
matches
}
}
/// Converts "hello\|fellow" to "hello|fellow"
pub fn unescape_pipes(escaped: &str) -> String {
escaped.replace(r"\|", "|")
}
pub struct PipeEscaped<'a>(pub &'a str);
impl Display for PipeEscaped<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
for c in self.0.chars() {
if c == '|' {
f.write_str(r"\|")?;
} else {
f.write_char(c)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::{PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId};
#[test]
fn format_persistent_preset_id() {
let id = PersistentPresetId::new(
PersistentDatabaseId::new("test|hello".into()),
PersistentInnerPresetId::new("vst2|124135".into()),
);
assert_eq!(id.to_string(), r#"test\|hello|vst2|124135"#);
}
#[test]
fn parse_persistent_preset_id() {
let expression = r#"test\|hello|vst2|124135"#;
let parsed_id: PersistentPresetId = expression.parse().unwrap();
let expected_id = PersistentPresetId::new(
PersistentDatabaseId::new("test|hello".into()),
PersistentInnerPresetId::new("vst2|124135".into()),
);
assert_eq!(parsed_id, expected_id);
}
}
@@ -0,0 +1,65 @@
use reaper_high::Reaper;
use reaper_medium::{
AccelMsgKind, AcceleratorBehavior, AcceleratorKeyCode, AcceleratorPosition, RegistrationHandle,
TranslateAccel, TranslateAccelArgs, TranslateAccelResult,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
pub struct EscapeCatcher {
accel_handle: RegistrationHandle<EscapeCatcherAccel>,
escape_was_pressed: Arc<AtomicBool>,
}
impl TranslateAccel for EscapeCatcherAccel {
fn call(&mut self, args: TranslateAccelArgs) -> TranslateAccelResult {
let is_escape = args.msg.behavior().contains(AcceleratorBehavior::VirtKey)
&& args.msg.message() == AccelMsgKind::KeyDown
&& args.msg.key() == AcceleratorKeyCode::new(0x1b);
if !is_escape {
return TranslateAccelResult::NotOurWindow;
}
self.escape_was_pressed.store(true, Ordering::Relaxed);
TranslateAccelResult::Eat
}
}
struct EscapeCatcherAccel {
escape_was_pressed: Arc<AtomicBool>,
}
impl Default for EscapeCatcher {
fn default() -> Self {
Self::new()
}
}
impl EscapeCatcher {
pub fn new() -> Self {
let escape_was_pressed = Arc::new(AtomicBool::new(false));
let accel = EscapeCatcherAccel {
escape_was_pressed: escape_was_pressed.clone(),
};
let accel_handle = Reaper::get()
.medium_session()
.plugin_register_add_accelerator_register(Box::new(accel), AcceleratorPosition::Front)
.expect("couldn't register escape accelerator");
Self {
accel_handle,
escape_was_pressed,
}
}
pub fn escape_was_pressed(&self) -> bool {
self.escape_was_pressed.load(Ordering::Relaxed)
}
}
impl Drop for EscapeCatcher {
fn drop(&mut self) {
Reaper::get()
.medium_session()
.plugin_register_remove_accelerator(self.accel_handle)
.expect("escape accelerator was not registered");
}
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
use base::default_util::{deserialize_null_default, is_default};
#[derive(Clone, Eq, PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct PersistentNksFilterSettings {
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub bank: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub sub_bank: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub category: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub sub_category: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_null_default",
skip_serializing_if = "is_default"
)]
pub mode: Option<String>,
}
+241
View File
@@ -0,0 +1,241 @@
use base::LimitedAsciiString;
use std::fmt;
use std::fmt::{Display, Formatter};
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub enum PluginId {
Vst2 { vst_magic_number: i32 },
Vst3 { vst_uid: [u32; 4] },
Clap { clap_id: LimitedAsciiString<100> },
Js { js_id: LimitedAsciiString<100> },
}
impl PluginId {
pub fn vst2(vst_magic_number: i32) -> Self {
Self::Vst2 { vst_magic_number }
}
pub fn vst3(vst_uid: [u32; 4]) -> Self {
Self::Vst3 { vst_uid }
}
pub fn clap(id_expression: &str) -> Result<Self, &'static str> {
let id = Self::Clap {
clap_id: LimitedAsciiString::try_from_str(id_expression)?,
};
Ok(id)
}
pub fn js(id_expression: &str) -> Result<Self, &'static str> {
// It's better to normalize to lowercase. When loading the FX via TrackFX_AddByName,
// REAPER is case-insensitive anyway and we should be as well when doing comparisons.
let lowercase_id_expression = id_expression.to_lowercase();
let id = Self::Js {
js_id: LimitedAsciiString::try_from_str(&lowercase_id_expression)?,
};
Ok(id)
}
pub fn parse_from_rxml_line(line: &str) -> Result<PluginId, &'static str> {
let line = line.trim();
let mut tokens = splitty::split_unquoted_whitespace(line).unwrap_quotes(true);
let tag_opener = tokens.next().ok_or("missing FX tag opener")?;
match tag_opener {
"<VST" => {
// Examples:
// - <VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> ""
// - <VST "VST3i: Pianoteq 8 (Modartt) (1->5ch)" "Pianoteq 8.vst3" 0 "" 1031062328{565354507438717069616E6F74657120} ""
// Skip plug-in name, file, zero, custom name
for _ in 0..4 {
tokens.next();
}
// Process ID expression
let id_expression = tokens.next().ok_or("missing VST ID expression")?;
if let Some((_, remainder)) = id_expression.split_once('{') {
// VST3
let vst3_uid_string = remainder.strip_suffix('}').unwrap_or(remainder);
let uid = parse_vst3_uid(vst3_uid_string)?;
Ok(Self::vst3(uid))
} else if let Some((magic_number_string, _)) = id_expression.split_once('<') {
// VST2
let magic_number = parse_vst2_magic_number(magic_number_string)?;
Ok(Self::vst2(magic_number))
} else {
Err("couldn't process VST ID expression")
}
}
"<CLAP" => {
// Example: <CLAP "CLAPi: Surge XT (Surge Synth Team)" org.surge-synth-team.surge-xt Surgi
// Skip plug-in name
tokens.next();
// Process ID expression
let id_expression = tokens.next().ok_or("missing CLAP ID expression")?;
Self::clap(id_expression)
}
"<JS" => {
// Example: <JS analysis/hund ""
let id_expression = tokens.next().ok_or("missing JS ID expression")?;
Self::js(id_expression)
}
_ => Err("unknown FX tag opener"),
}
}
pub fn kind(&self) -> PluginKind {
match self {
PluginId::Vst2 { .. } => PluginKind::Vst2,
PluginId::Vst3 { .. } => PluginKind::Vst3,
PluginId::Clap { .. } => PluginKind::Clap,
PluginId::Js { .. } => PluginKind::Js,
}
}
pub fn content_formatted_for_reaper(&self) -> String {
PluginIdContentInReaperFormat(self).to_string()
}
}
/// Example: `1967946098` for a VST2 plug-in ID.
pub struct PluginIdContentInReaperFormat<'a>(pub &'a PluginId);
impl Display for PluginIdContentInReaperFormat<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match self.0 {
PluginId::Clap { clap_id } => clap_id.fmt(f),
PluginId::Js { js_id } => js_id.fmt(f),
PluginId::Vst2 { vst_magic_number } => vst_magic_number.fmt(f),
PluginId::Vst3 { vst_uid } => {
// D39D5B69 D6AF42FA 12345678 534D4433
write!(
f,
"{:X}{:X}{:X}{:X}",
vst_uid[0], vst_uid[1], vst_uid[2], vst_uid[3],
)
}
}
}
}
/// Example: `vst|1967946098` for a VST2 plug-in ID.
pub struct PluginIdInPipeFormat<'a>(pub &'a PluginId);
impl Display for PluginIdInPipeFormat<'_> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let kind = self.0.kind();
let kind = kind.as_ref();
let content = PluginIdContentInReaperFormat(self.0);
write!(f, "{kind}|{content}")
}
}
/// When adding a new variant, the serialization should correspond to the string which is used
/// as prefix for the ini file names in "REAPER_RESOURCE_PATH/presets".
#[derive(Copy, Clone, Eq, PartialEq, Debug, strum::AsRefStr, strum::EnumString)]
pub enum PluginKind {
#[strum(serialize = "vst")]
Vst2,
#[strum(serialize = "vst3")]
Vst3,
#[strum(serialize = "clap")]
Clap,
#[strum(serialize = "js")]
Js,
}
impl PluginKind {
pub fn name(&self) -> &'static str {
match self {
Self::Vst2 => "VST",
Self::Vst3 => "VST3",
Self::Clap => "CLAP",
Self::Js => "JS",
}
}
/// Need to put some random string in front of "<" due to bug in REAPER < 6.69,
/// otherwise loading by VST2 magic number doesn't work.
pub fn reaper_add_by_name_prefix_fix(&self) -> &'static str {
match self {
Self::Vst2 | Self::Vst3 => "i7zh34z",
Self::Clap | Self::Js => "",
}
}
pub fn formatted_for_reaper(&self) -> &'static str {
match self {
Self::Vst2 => "<",
Self::Vst3 => "{",
Self::Clap | Self::Js => "",
}
}
}
/// "1397572658" => 1397572658
pub fn parse_vst2_magic_number(expression: &str) -> Result<i32, &'static str> {
expression
.parse()
.map_err(|_| "couldn't parse VST2 magic number")
}
/// "565354507438717069616E6F74657120" => [0x56535450, 0x74387170, 0x69616E6F, 0x74657120]
pub fn parse_vst3_uid(expression: &str) -> Result<[u32; 4], &'static str> {
fn parse_component(text: &str, i: usize) -> Result<u32, &'static str> {
let from = i * 8;
let until = from + 8;
let parsed = u32::from_str_radix(&text[from..until], 16)
.map_err(|_| "couldn't parse VST3 uid component")?;
Ok(parsed)
}
let uid = [
parse_component(expression, 0)?,
parse_component(expression, 1)?,
parse_component(expression, 2)?,
parse_component(expression, 3)?,
];
Ok(uid)
}
#[cfg(test)]
mod tests {
use crate::PluginId;
#[test]
pub fn vst2() {
assert_eq!(
PluginId::parse_from_rxml_line(
r#"<VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> """#
),
Ok(PluginId::vst2(1397572658))
);
}
#[test]
pub fn vst3() {
assert_eq!(
PluginId::parse_from_rxml_line(
r#"<VST "VST3i: Pianoteq 8 (Modartt) (1->5ch)" "Pianoteq 8.vst3" 0 "" 1031062328{565354507438717069616E6F74657120} """#
),
Ok(PluginId::vst3([
0x56535450, 0x74387170, 0x69616E6F, 0x74657120
]))
);
}
#[test]
pub fn clap() {
assert_eq!(
PluginId::parse_from_rxml_line(
r#"<CLAP "CLAPi: Surge XT (Surge Synth Team)" org.surge-synth-team.surge-xt Surgi"#
),
Ok(PluginId::clap("org.surge-synth-team.surge-xt").unwrap())
);
}
#[test]
pub fn js() {
assert_eq!(
PluginId::parse_from_rxml_line(r#"<JS Analysis/hund """#),
Ok(PluginId::js("analysis/hund").unwrap())
);
}
}
+720
View File
@@ -0,0 +1,720 @@
use crate::{parse_vst2_magic_number, parse_vst3_uid, PluginId, ProductId};
use base::file_util;
use base::hash_util::NonCryptoHashMap;
use camino::Utf8Path;
use ini::Ini;
use regex::Match;
use std::fmt;
use std::fmt::{Display, Formatter};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use walkdir::WalkDir;
#[derive(Clone, Debug, Default)]
pub struct PluginDatabase {
plugins: NonCryptoHashMap<PluginId, Plugin>,
products: Vec<Product>,
detected_legacy_vst3_scan: bool,
}
/// Responsible for grouping similar plug-ins into products.
#[derive(Default)]
struct ProductAccumulator {
products: Vec<Product>,
}
impl ProductAccumulator {
/// Adds a product with the given characteristics.
pub fn add_other_product(&mut self, name: String, kind: Option<ProductKind>) -> ProductId {
let product = Product { name, kind };
self.products.push(product);
ProductId(self.products.len() as u32 - 1)
}
/// Tries to find an already added product matching the product name within the given plug-in
/// expression (and product kind) or creates a new product.
pub fn get_or_add_plugin_product(
&mut self,
name_expression: &str,
kind: Option<ProductKind>,
) -> ProductId {
// Extract product name
let name = if let Some(name) = ProductName::parse(name_expression) {
normalize_product_main_name(name.main)
} else {
name_expression
};
// Find existing product
let existing_product = self
.products
.iter()
.enumerate()
.find(|(_, product)| product.name == name && product.kind == kind);
// Return existing or create new product
let i = match existing_product {
None => {
let new_product = Product {
name: name.to_string(),
kind,
};
self.products.push(new_product);
self.products.len() - 1
}
Some((i, _)) => i,
};
ProductId(i as u32)
}
pub fn into_products(self) -> Vec<Product> {
self.products
}
}
impl PluginDatabase {
pub fn crawl(reaper_resource_dir: &Utf8Path) -> Self {
let mut product_accumulator = ProductAccumulator::default();
let mut detected_legacy_vst3_scan = false;
let shared_library_plugins = crawl_shared_library_plugins(
&mut product_accumulator,
reaper_resource_dir,
&mut detected_legacy_vst3_scan,
);
let js_root_dir = reaper_resource_dir.join("Effects");
let js_plugins = crawl_js_plugins(&mut product_accumulator, &js_root_dir);
let plugin_map = shared_library_plugins
.into_iter()
.chain(js_plugins)
.map(|p| (p.common.core.id, p))
.collect();
Self {
plugins: plugin_map,
products: product_accumulator.into_products(),
detected_legacy_vst3_scan,
}
}
pub fn detected_legacy_vst3_scan(&self) -> bool {
self.detected_legacy_vst3_scan
}
pub fn plugins(&self) -> impl Iterator<Item = &Plugin> {
self.plugins.values()
}
pub fn products(&self) -> impl Iterator<Item = (ProductId, &Product)> {
self.products
.iter()
.enumerate()
.map(|(i, p)| (ProductId(i as _), p))
}
pub fn find_plugin_by_id(&self, plugin_id: &PluginId) -> Option<&Plugin> {
self.plugins.get(plugin_id)
}
pub fn find_product_by_id(&self, product_id: &ProductId) -> Option<&Product> {
self.products.get(product_id.0 as usize)
}
pub fn detect_plugin_from_rxml_line(&self, line: &str) -> Option<&Plugin> {
let is_fx_line = ["<VST ", "<CLAP ", "<JS "]
.into_iter()
.any(|suffix| line.starts_with(suffix));
if !is_fx_line {
return None;
}
let plugin_id = PluginId::parse_from_rxml_line(line).ok()?;
self.find_plugin_by_id(&plugin_id)
}
}
/// A product - an abstraction over related plug-ins.
///
/// Example: The product "Zebra2 (u-he)". This ignores architecture and plug-in framework.
/// So this product stands for all of: "Zebra VSTi", "Zebra VST3i", "Zebra CLAPi",
/// "Zebra VST (x86_64)", and so on.
#[derive(Clone, Debug)]
pub struct Product {
pub name: String,
pub kind: Option<ProductKind>,
}
#[derive(Clone, Debug)]
pub struct Plugin {
/// Contains data relevant for all kinds of plug-ins.
pub common: PluginCommon,
/// Contains data specific to certain kinds of plug-ins.
pub kind: SuperPluginKind,
}
#[derive(Clone, Debug)]
pub struct PluginCommon {
/// Full name of the plug-in without kind (for display purposes mainly).
///
/// E.g. "Zebra2 (u-he)"
pub name: String,
pub core: PluginCore,
}
#[derive(Copy, Clone, Debug)]
pub struct PluginCore {
/// Uniquely identifies the plug-in in a rather cheap way (copyable).
pub id: PluginId,
/// Whether we have an effect or an instrument or unknown.
pub product_kind: Option<ProductKind>,
/// What product this plug-in belongs to.
pub product_id: ProductId,
}
impl Display for PluginCommon {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
f.write_str(self.core.id.kind().name())?;
if let Some(ProductKind::Instrument) = self.core.product_kind {
f.write_str("i")?;
}
write!(f, ": {}", &self.name)?;
Ok(())
}
}
#[derive(Clone, Debug)]
pub enum SuperPluginKind {
Vst(VstPlugin),
Clap(ClapPlugin),
Js(JsPlugin),
}
#[derive(Clone, Debug)]
pub struct ClapPlugin {
/// Real file name, no characters replaced.
pub file_name: String,
/// According to Justin, this is a Win32 FILETIME timestamp (last write time and creation time,
/// concatenated).
pub filetime: String,
pub id: String,
}
#[derive(Clone, Debug)]
pub struct JsPlugin {
/// Relative path from JS root dir.
///
/// This is a runtime path and it's not normalized to lower-case! So it shouldn't be used
/// as an ID.
pub path: String,
}
#[derive(Clone, Debug)]
pub struct VstPlugin {
/// Safe means: Each space and special character is replaced with an underscore.
pub safe_file_name: String,
/// A value which identifies the actual plug-in within a shell file.
///
/// Some files are just shells. That means, they contain multiple actual plug-ins.
/// If this plug-in is part of a shell file, this value will be set. In practice, it's
/// equal to the magic number / uid_hash.
pub shell_qualifier: Option<String>,
pub checksum: String,
pub kind: VstPluginKind,
}
#[derive(Clone, Debug)]
pub enum VstPluginKind {
Vst2 { magic_number: String },
Vst3 { uid_hash: String, uid: String },
}
impl VstPluginKind {
pub fn plugin_id(&self) -> Result<PluginId, &'static str> {
let id = match self {
VstPluginKind::Vst2 { magic_number } => PluginId::Vst2 {
vst_magic_number: parse_vst2_magic_number(magic_number)?,
},
VstPluginKind::Vst3 { uid, .. } => PluginId::Vst3 {
vst_uid: parse_vst3_uid(uid)?,
},
};
Ok(id)
}
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, derive_more::Display)]
pub enum ProductKind {
Effect,
Instrument,
Loop,
OneShot,
}
impl ProductKind {
pub fn komplete_id(&self) -> &'static u32 {
const INSTRUMENT: u32 = 1;
const EFFECT: u32 = 2;
const ONE_SHOT: u32 = 4;
const LOOP: u32 = 8;
use ProductKind as K;
match self {
K::Effect => &EFFECT,
K::Instrument => &INSTRUMENT,
K::Loop => &LOOP,
K::OneShot => &ONE_SHOT,
}
}
}
fn crawl_js_plugins(
product_accumulator: &mut ProductAccumulator,
js_root_dir: &Utf8Path,
) -> Vec<Plugin> {
WalkDir::new(js_root_dir)
.follow_links(true)
.into_iter()
.filter_entry(|e| !file_util::is_hidden(e.file_name()))
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_file() {
return None;
}
let relative_path = entry.path().strip_prefix(js_root_dir).ok()?;
let relative_path = relative_path.to_str()?;
let product_kind = Some(ProductKind::Effect);
let js_desc = read_js_desc_from_file(entry.path())?;
let plugin = Plugin {
common: PluginCommon {
name: js_desc.clone(),
core: PluginCore {
id: PluginId::js(relative_path).ok()?,
product_kind,
product_id: product_accumulator.add_other_product(js_desc, product_kind),
},
},
kind: SuperPluginKind::Js(JsPlugin {
path: relative_path.to_string(),
}),
};
Some(plugin)
})
.collect()
}
fn crawl_shared_library_plugins(
product_accumulator: &mut ProductAccumulator,
reaper_resource_dir: &Utf8Path,
detected_legacy_vst3_scan: &mut bool,
) -> Vec<Plugin> {
WalkDir::new(reaper_resource_dir)
.max_depth(1)
.follow_links(false)
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_file() {
return None;
}
let file_name = entry.file_name().to_str()?;
enum PlugType {
Vst,
Clap,
}
let plug_type = match file_name {
VST_CACHE_FILE => PlugType::Vst,
CLAP_CACHE_FILE => PlugType::Clap,
_ => return None,
};
let ini = Ini::load_from_file(entry.path()).ok()?;
let plugins = match plug_type {
PlugType::Vst => crawl_vst_plugins_in_ini_file(
product_accumulator,
ini,
detected_legacy_vst3_scan,
),
PlugType::Clap => crawl_clap_plugins_in_ini_file(product_accumulator, ini),
};
Some(plugins)
})
.flatten()
.collect()
}
fn crawl_clap_plugins_in_ini_file(
product_accumulator: &mut ProductAccumulator,
ini: Ini,
) -> Vec<Plugin> {
ini.iter()
.filter_map(|(section, props)| {
let file_name = section?;
let checksum = props.get("_")?;
let plugins: Vec<_> = props
.iter()
.filter_map(|(key, value)| {
if key == "_" {
return None;
}
let (product_kind_id, plugin_name) = value.split_once('|')?;
let product_kind = match product_kind_id {
"0" => Some(ProductKind::Effect),
"1" => Some(ProductKind::Instrument),
_ => None,
};
let plugin = Plugin {
common: PluginCommon {
core: PluginCore {
id: PluginId::clap(key).ok()?,
product_kind,
product_id: product_accumulator
.get_or_add_plugin_product(plugin_name, product_kind),
},
name: plugin_name.to_string(),
},
kind: SuperPluginKind::Clap(ClapPlugin {
file_name: file_name.to_string(),
filetime: checksum.to_string(),
id: key.to_string(),
}),
};
Some(plugin)
})
.collect();
Some(plugins)
})
.flatten()
.collect()
}
fn crawl_vst_plugins_in_ini_file(
product_accumulator: &mut ProductAccumulator,
ini: Ini,
detected_legacy_vst3_scan: &mut bool,
) -> Vec<Plugin> {
let Some(section) = ini.section(Some("vstcache")) else {
return vec![];
};
section
.iter()
.filter_map(|(key, value)| {
let (safe_file_name, shell_qualifier) = match key.split_once('<') {
Some((first, second)) => (first.to_string(), Some(second.to_string())),
None => (key.to_string(), None),
};
let mut value_iter = value.splitn(3, ',');
let checksum = value_iter.next()?.to_string();
let plugin_id_expression = value_iter.next()?;
if plugin_id_expression == "0" {
// Must be a plug-in shell, not an actual plug-in.
return None;
}
let plugin_name_kind_expression = value_iter.next()?;
let vst_kind = match plugin_id_expression.split_once('{') {
None => {
if safe_file_name.ends_with(".vst3") {
// We skip VST3 plug-ins that have been scanned with old versions of
// REAPER (and therefore missing a UID) in order to avoid creating wrong
// persistent plug-in IDs).
*detected_legacy_vst3_scan = true;
return None;
}
VstPluginKind::Vst2 {
magic_number: plugin_id_expression.to_string(),
}
}
Some((left, right)) => VstPluginKind::Vst3 {
uid_hash: left.to_string(),
uid: right.to_string(),
},
};
let (name, product_kind) = match plugin_name_kind_expression.split_once("!!!") {
None => (
plugin_name_kind_expression.to_string(),
Some(ProductKind::Effect),
),
Some((left, right)) => {
let kind = match right {
"VSTi" => Some(ProductKind::Instrument),
_ => None,
};
(left.to_string(), kind)
}
};
let plugin = Plugin {
common: PluginCommon {
core: PluginCore {
id: vst_kind.plugin_id().ok()?,
product_id: product_accumulator
.get_or_add_plugin_product(name.as_str(), product_kind),
product_kind,
},
name,
},
kind: SuperPluginKind::Vst(VstPlugin {
safe_file_name,
shell_qualifier,
checksum,
kind: vst_kind,
}),
};
Some(plugin)
})
.collect()
}
#[derive(Eq, PartialEq, Debug)]
struct ProductName<'a> {
main: &'a str,
arch: Option<&'a str>,
company: &'a str,
channels: Option<&'a str>,
}
impl<'a> ProductName<'a> {
pub fn parse(name_expression: &'a str) -> Option<Self> {
let four_part_regex = base::regex!(r"(.*) \((.*)\) \((.*)\) \((.*)\)");
let three_part_regex = base::regex!(r"(.*) \((.*)\) \((.*)\)");
let two_part_regex = base::regex!(r"(.*) \((.*)\)");
if let Some(captures) = four_part_regex.captures(name_expression) {
return Some(ProductName {
main: s(captures.get(1)),
arch: Some(s(captures.get(2))),
company: s(captures.get(3)),
channels: Some(s(captures.get(4))),
});
}
if let Some(captures) = three_part_regex.captures(name_expression) {
let name = if &captures[2] == "x86_64" {
ProductName {
main: s(captures.get(1)),
arch: Some(s(captures.get(2))),
company: s(captures.get(3)),
channels: None,
}
} else {
ProductName {
main: s(captures.get(1)),
arch: None,
company: s(captures.get(2)),
channels: Some(s(captures.get(3))),
}
};
return Some(name);
}
if let Some(captures) = two_part_regex.captures(name_expression) {
return Some(ProductName {
main: s(captures.get(1)),
arch: None,
company: s(captures.get(2)),
channels: None,
});
}
None
}
}
fn s(m: Option<Match>) -> &str {
m.unwrap().as_str()
}
fn read_js_desc_from_file(path: &Path) -> Option<String> {
let file = File::open(path).ok()?;
let mut buffer = String::new();
let mut reader = BufReader::new(&file);
while let Ok(count) = reader.read_line(&mut buffer) {
if count == 0 {
// EOF
break;
}
let line = buffer.trim();
if let Some((left, right)) = line.split_once(':') {
if left == "desc" {
return Some(right.trim().to_string());
}
}
buffer.clear();
}
None
}
/// Further normalizes the already extracted main name so it conforms to the way "Komplete"
/// displays the products.
///
/// # Maybe crop version number
///
/// Example: So we have successfully extracted "Kontakt 5" as main name from an expression
/// "VSTi: Kontakt 5 (x86_64) (Native Instruments GmbH) (64 out). So far a very neutral extraction.
/// But Komplete's product filter section doesn't distinguish between multiple versions of Kontakt.
/// So we want to normalize this to just "Kontakt".
///
/// There are other NKS-ready products such as Pianoteq that distinguish between version numbers,
/// so as a general rule we distinguish between version numbers and just add a few exceptions here.
fn normalize_product_main_name(main_name: &str) -> &str {
maybe_crop_version_number(main_name)
}
fn maybe_crop_version_number(main_name: &str) -> &str {
const PRODUCT_NAMES_WITHOUT_VERSION_NUMBER: &[&str] =
&["Kontakt", "Absynth", "Guitar Rig", "Reaktor", "Battery"];
let Some((left, right)) = main_name.rsplit_once(' ') else {
// No right part
return main_name;
};
if PRODUCT_NAMES_WITHOUT_VERSION_NUMBER.contains(&left)
&& right.chars().all(|c| c.is_ascii_digit())
{
// Conforms to pattern, e.g. "Kontakt 7"
left
} else {
// Doesn't conform to pattern
main_name
}
}
const VST_CACHE_FILE: &str = {
#[cfg(target_os = "windows")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-vstplugins64.ini"
}
#[cfg(target_arch = "x86")]
{
"reaper-vstplugins.ini"
}
#[cfg(target_arch = "arm64ec")]
{
"reaper-vstplugins64arwmin.ini"
}
}
#[cfg(target_os = "linux")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-vstplugins64.ini"
}
#[cfg(target_arch = "x86")]
{
"reaper-vstplugins.ini"
}
#[cfg(target_arch = "aarch64")]
{
"reaper-vstplugins_arm64.ini"
}
#[cfg(target_arch = "arm")]
{
"reaper-vstplugins.ini"
}
}
#[cfg(target_os = "macos")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-vstplugins64.ini"
}
#[cfg(target_arch = "aarch64")]
{
"reaper-vstplugins_arm64.ini"
}
}
};
const CLAP_CACHE_FILE: &str = {
#[cfg(target_os = "windows")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-clap-win64"
}
#[cfg(target_arch = "x86")]
{
"reaper-clap-win32"
}
#[cfg(target_arch = "arm64ec")]
{
"reaper-clap-winarm64"
}
}
#[cfg(target_os = "linux")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-clap-linux-x86_64"
}
#[cfg(target_arch = "x86")]
{
"reaper-clap-linux-i386"
}
#[cfg(target_arch = "aarch64")]
{
"reaper-clap-linux-aarch64"
}
#[cfg(target_arch = "arm")]
{
"reaper-clap-linux-arm"
}
}
#[cfg(target_os = "macos")]
{
#[cfg(target_arch = "x86_64")]
{
"reaper-clap-macos-x86_64"
}
#[cfg(target_arch = "aarch64")]
{
"reaper-clap-macos-aarch64.ini"
}
}
};
#[cfg(test)]
mod tests {
use crate::plugins::ProductName;
#[test]
pub fn product_name_parsing_2() {
assert_eq!(
ProductName::parse("TDR Nova (Tokyo Dawn Labs)"),
Some(ProductName {
main: "TDR Nova",
arch: None,
company: "Tokyo Dawn Labs",
channels: None,
})
);
}
#[test]
pub fn product_name_parsing_3_arch() {
assert_eq!(
ProductName::parse("VC 76 (x86_64) (Native Instruments GmbH)"),
Some(ProductName {
main: "VC 76",
arch: Some("x86_64"),
company: "Native Instruments GmbH",
channels: None,
})
);
}
#[test]
pub fn product_name_parsing_3_ch() {
assert_eq!(
ProductName::parse("Surge XT (Surge Synth Team) (2->6ch)"),
Some(ProductName {
main: "Surge XT",
arch: None,
company: "Surge Synth Team",
channels: Some("2->6ch"),
})
);
}
#[test]
pub fn product_name_parsing_4() {
assert_eq!(
ProductName::parse("Sitala (x86_64) (Decomposer) (32 out)"),
Some(ProductName {
main: "Sitala",
arch: Some("x86_64"),
company: "Decomposer",
channels: Some("32 out"),
})
);
}
}
@@ -0,0 +1,554 @@
use crate::provider_database::{
Database, DatabaseId, InnerFilterItem, ProviderContext, SortablePresetId,
FIL_HAS_PREVIEW_FALSE, FIL_HAS_PREVIEW_TRUE, FIL_IS_AVAILABLE_FALSE, FIL_IS_AVAILABLE_TRUE,
FIL_IS_FAVORITE_FALSE, FIL_IS_FAVORITE_TRUE, FIL_IS_SUPPORTED_FALSE, FIL_IS_SUPPORTED_TRUE,
FIL_IS_USER_PRESET_FALSE, FIL_IS_USER_PRESET_TRUE, FIL_PRODUCT_KIND_EFFECT,
FIL_PRODUCT_KIND_INSTRUMENT, FIL_PRODUCT_KIND_LOOP, FIL_PRODUCT_KIND_ONE_SHOT,
};
use crate::providers::directory::{DirectoryDatabase, DirectoryDbConfig};
use crate::providers::komplete::KompleteDatabase;
use crate::{
preview_exists, BuildInput, Fil, FilterItem, FilterItemCollections, FilterItemId, Filters,
InnerBuildInput, PersistentDatabaseId, PersistentPresetId, PluginId, PotFavorites, PotPreset,
PresetId, PresetWithId, Stats,
};
use base::{blocking_read_lock, blocking_write_lock};
use crate::plugins::PluginDatabase;
use crate::providers::defaults::DefaultsDatabase;
use crate::providers::ini::IniDatabase;
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use reaper_high::Reaper;
use std::collections::{BTreeMap, HashSet};
use std::error::Error;
use std::fmt::Debug;
use std::ops::Deref;
use base::hash_util::NonCryptoIndexSet;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{RwLock, RwLockReadGuard};
use std::time::{Duration, Instant};
pub fn pot_db() -> &'static PotDatabase {
use once_cell::sync::Lazy;
static POT_DB: Lazy<PotDatabase> = Lazy::new(PotDatabase::open);
&POT_DB
}
type BoxedDatabase = Box<dyn Database + Send + Sync>;
type DatabaseOpeningResult = Result<BoxedDatabase, PotDatabaseError>;
// The pot database is thread-safe! We achieve this by using internal read-write locks. Making it
// thread-safe greatly simplifies usage of PotDatabase from a consumer perspective, because we can
// easily obtain a static reference to it and let the pot database internals decide how to deal with
// concurrency.
//
// But not just that, we can also improve performance. If the pot database wouldn't be thread-safe,
// we would have to expose it wrapped by a mutex or read-write lock - which means less fine-granular
// locking. Either the whole thing is locked or not at all.
//
// By having the RwLocks around the provider databases and not around the database collection, we
// can have "more" concurrent access. E.g. find_preset_by_id, a function which can be called very
// often by the GUI, only read-locks one particular database, not all. So if another database
// is currently written to, it doesn't matter. It's just more flexible. Also, in future we might
// want to use some fork-join approach to refresh/search concurrently multiple databases.
// This would require having a RwLock around the database itself
// because we would need to pass the database reference to the async code with an Arc, but an Arc
// alone doesn't allow mutation of its contents. That's true even if the async database access would
// be read-only. The synchronous refresh would still need mutable access but we wouldn't be able to
// get one directly within an Arc.
pub struct PotDatabase {
plugin_db: RwLock<PluginDatabase>,
databases: RwLock<Databases>,
revision: AtomicU8,
detected_legacy_vst3_scan: AtomicBool,
}
type Databases = BTreeMap<DatabaseId, RwLock<BoxedDatabase>>;
#[derive(Clone, Debug, derive_more::Display)]
pub struct PotDatabaseError(String);
impl Error for PotDatabaseError {}
fn box_db_result<D: Database + Send + Sync + 'static>(
opening_result: Result<D, Box<dyn Error>>,
) -> DatabaseOpeningResult {
let db = opening_result.map_err(|e| PotDatabaseError(e.to_string()))?;
Ok(Box::new(db))
}
impl PotDatabase {
pub fn open() -> Self {
let resource_path = Reaper::get().resource_path();
let komplete_db = KompleteDatabase::open();
let rfx_chain_db = {
let config = DirectoryDbConfig {
persistent_id: PersistentDatabaseId::new("fx-chains".to_string()),
root_dir: resource_path.join("FXChains"),
valid_extensions: &["RfxChain"],
name: "FX chains",
description: "All the RfxChain files in your FXChains directory",
};
DirectoryDatabase::open(config)
};
let track_template_db = {
let config = DirectoryDbConfig {
persistent_id: PersistentDatabaseId::new("track-templates".to_string()),
root_dir: resource_path.join("TrackTemplates"),
valid_extensions: &["RTrackTemplate"],
name: "Track templates",
description: "All the RTrackTemplate files in your TrackTemplates directory.\n\
Doesn't load the complete track, only its FX chain!",
};
DirectoryDatabase::open(config)
};
let ini_db = IniDatabase::open(
PersistentDatabaseId::new("fx-presets".to_string()),
resource_path.join("presets"),
);
let defaults_db = DefaultsDatabase::open();
let databases = [
box_db_result(komplete_db),
box_db_result(rfx_chain_db),
box_db_result(track_template_db),
box_db_result(ini_db),
box_db_result(Ok(defaults_db)),
];
let databases = databases
.into_iter()
.flatten()
.enumerate()
.map(|(i, db)| (DatabaseId(i as _), RwLock::new(db)))
.collect();
Self {
plugin_db: Default::default(),
databases: RwLock::new(databases),
revision: Default::default(),
detected_legacy_vst3_scan: Default::default(),
}
}
/// Returns a number that will be increased with each database refresh.
pub fn revision(&self) -> u8 {
self.revision.load(Ordering::Relaxed)
}
pub fn refresh(&self) {
// Build provider context
let resource_path = Reaper::get().resource_path();
// Crawl plug-ins
let plugin_db = PluginDatabase::crawl(&resource_path);
// In order to be able to query the legacy-vst3-scan result without having to lock the
// plug-in DB (which could lead to unresponsive UI), we save it as atomic bool right here.
self.detected_legacy_vst3_scan
.store(plugin_db.detected_legacy_vst3_scan(), Ordering::Relaxed);
let provider_context = ProviderContext::new(&plugin_db);
// Refresh databases
for db in self.read_lock_databases().values() {
let mut db = blocking_write_lock(db, "pot db refresh provider db");
let _ = db.refresh(&provider_context);
}
// Memorize plug-ins
*blocking_write_lock(&self.plugin_db, "pot db refresh plugin db") = plugin_db;
// Increment revision
self.revision.fetch_add(1, Ordering::Relaxed);
}
pub fn detected_legacy_vst3_scan(&self) -> bool {
self.detected_legacy_vst3_scan.load(Ordering::Relaxed)
}
fn read_lock_databases(&self) -> RwLockReadGuard<Databases> {
blocking_read_lock(&self.databases, "read-lock pot-db databases")
}
fn read_lock_plugin_db(&self) -> RwLockReadGuard<PluginDatabase> {
blocking_read_lock(&self.plugin_db, "read-lock plug-in database")
}
pub fn add_database(&self, db: impl Database + Send + Sync + 'static) -> DatabaseId {
let mut databases = blocking_write_lock(&self.databases, "add_database");
let new_db_id = DatabaseId(databases.len() as u32);
databases.insert(new_db_id, RwLock::new(Box::new(db)));
new_db_id
}
pub fn build_collections(
&self,
mut input: BuildInput,
affected_kinds: EnumSet<PotFilterKind>,
) -> BuildOutput {
// Preparation
// TODO-high-pot Implement correctly as soon as favorites writable
let favorites = PotFavorites::default();
let plugin_db = self.read_lock_plugin_db();
let provider_context = ProviderContext::new(&plugin_db);
// Build constant filter collections
let mut total_output = BuildOutput {
supported_filter_kinds: enum_set!(
PotFilterKind::Database
| PotFilterKind::IsUser
| PotFilterKind::IsFavorite
| PotFilterKind::ProductKind
),
..Default::default()
};
measure_duration(&mut total_output.stats.filter_query_duration, || {
add_constant_filter_items(affected_kinds, &mut total_output.filter_item_collections);
// Let all databases build filter collections and accumulate them
let mut database_filter_items = Vec::new();
let mut used_product_ids = HashSet::new();
for (db_id, db) in self.read_lock_databases().deref() {
// If the database is on the exclude list, we don't even want it to appear in the
// database list.
if input.filter_excludes.contains_database(*db_id) {
continue;
}
// Acquire database access
let db = blocking_read_lock(db, "pot db build_collections 1");
// Create database filter item
let filter_item = FilterItem {
persistent_id: "".to_string(),
id: FilterItemId(Some(Fil::Database(*db_id))),
parent_name: None,
name: Some(db.name().to_string()),
icon: None,
more_info: Some(db.description().to_string()),
};
database_filter_items.push(filter_item);
// Don't continue if database doesn't match filter
// (but it should appear on the list)
if !input.filters.database_matches(*db_id) {
continue;
}
// Add supported filter kinds
total_output.supported_filter_kinds |= db.supported_advanced_filter_kinds();
// Build and accumulate filters collections
let inner_input = InnerBuildInput::new(&input, &favorites, *db_id);
let Ok(filter_collections) =
db.query_filter_collections(&provider_context, inner_input, affected_kinds)
else {
continue;
};
// Add unique filter items directly to the list of filters. Gather shared filter
// items so we can deduplicate them later.
for (kind, items) in filter_collections.into_iter() {
let final_filter_items = items.into_iter().filter_map(|i| match i {
InnerFilterItem::Unique(i) => Some(i),
InnerFilterItem::Product(pid) => {
used_product_ids.insert(pid);
None
}
});
total_output
.filter_item_collections
.extend(kind, final_filter_items);
}
}
// Process shared filter items
let product_filter_items = used_product_ids.into_iter().filter_map(|pid| {
let product = plugin_db.find_product_by_id(&pid)?;
let filter_item = FilterItem {
persistent_id: "".to_string(),
id: FilterItemId(Some(Fil::Product(pid))),
parent_name: None,
name: Some(product.name.clone()),
icon: None,
more_info: product.kind.map(|k| k.to_string()),
};
Some(filter_item)
});
total_output
.filter_item_collections
.extend(PotFilterKind::Bank, product_filter_items);
// Add database filter items
if affected_kinds.contains(PotFilterKind::Database) {
total_output
.filter_item_collections
.set(PotFilterKind::Database, database_filter_items);
}
// Important: At this point, some previously selected filters might not exist anymore.
// So we should reset them and not let them influence the preset query anymore!
input.filters.clear_if_not_available_anymore(
affected_kinds,
&total_output.filter_item_collections,
);
});
// Finally build
let mut sortable_preset_ids: Vec<_> =
measure_duration(&mut total_output.stats.preset_query_duration, || {
self.gather_preset_ids_internal(&input, &provider_context, &favorites)
});
// Apply "has preview" filter if necessary (expensive!)
measure_duration(&mut total_output.stats.preview_filter_duration, || {
self.apply_has_preview_filter(&input.filters, &mut sortable_preset_ids);
});
// Sort filter items and presets
measure_duration(&mut total_output.stats.sort_duration, || {
for (kind, collection) in total_output.filter_item_collections.iter_mut() {
if kind.wants_sorting() {
collection.sort_by(|i1, i2| {
lexical_sort::lexical_cmp(i1.sort_name(), i2.sort_name())
});
}
}
sortable_preset_ids.sort_by(|(_, p1), (_, p2)| {
lexical_sort::lexical_cmp(&p1.preset_name, &p2.preset_name)
});
});
// Index presets. Because later, we look up the preset index by the preset ID and vice versa
// and we want that to happen without complexity O(n)! There can be tons of presets!
measure_duration(&mut total_output.stats.index_duration, || {
total_output.preset_collection = sortable_preset_ids
.into_iter()
.map(|(db_id, p)| PresetId::new(db_id, p.inner_preset_id))
.collect();
});
total_output
}
fn apply_has_preview_filter(
&self,
filters: &Filters,
sortable_preset_ids: &mut Vec<(DatabaseId, SortablePresetId)>,
) {
if let Some(wants_preview) = filters.wants_preview() {
let reaper_resource_dir = Reaper::get().resource_path();
sortable_preset_ids.retain(|(db_id, sortable_preset_id)| {
let preset_id = PresetId::new(*db_id, sortable_preset_id.inner_preset_id);
if let Some(preset) = self.find_preset_by_id(preset_id) {
preview_exists(&preset, &reaper_resource_dir) == wants_preview
} else {
// Preset doesn't exist? Shouldn't happen, but treat it like a missing preview.
!wants_preview
}
});
}
}
/// Gathers an unsorted list of preset respecting all pre-filters.
pub fn gather_presets(&self, input: BuildInput) -> Vec<PresetWithId> {
// TODO-high-pot Implement correctly as soon as favorites writable
let favorites = PotFavorites::default();
let plugin_db = self.read_lock_plugin_db();
let provider_context = ProviderContext::new(&plugin_db);
self.gather_preset_ids_internal(&input, &provider_context, &favorites)
.into_iter()
.filter_map(|(db_id, sortable_preset_id)| {
let preset_id = PresetId::new(db_id, sortable_preset_id.inner_preset_id);
let preset = self.find_preset_by_id(preset_id)?;
Some(PresetWithId::new(preset_id, preset))
})
.collect()
}
fn gather_preset_ids_internal(
&self,
input: &BuildInput,
provider_context: &ProviderContext,
favorites: &PotFavorites,
) -> Vec<(DatabaseId, SortablePresetId)> {
self.read_lock_databases()
.deref()
.iter()
.filter(|(db_id, _)| {
input.filters.database_matches(**db_id)
&& !input.filter_excludes.contains_database(**db_id)
})
.filter_map(|(db_id, db)| {
// Acquire database access
let db = blocking_read_lock(db, "pot db build_collections 2");
// Don't even try to get presets if one filter is set which is not
// supported by database.
if input
.filters
.any_unsupported_filter_is_set_to_concrete_value(
db.supported_advanced_filter_kinds(),
)
{
return None;
}
// Let database build presets
let inner_input = InnerBuildInput::new(input, favorites, *db_id);
let preset_ids = db.query_presets(provider_context, inner_input).ok()?;
Some((*db_id, preset_ids))
})
.flat_map(|(db_id, preset_ids)| preset_ids.into_iter().map(move |p| (db_id, p)))
.collect()
}
pub fn find_preset_by_id(&self, preset_id: PresetId) -> Option<PotPreset> {
let plugin_db = self.read_lock_plugin_db();
let provider_context = ProviderContext::new(&plugin_db);
let databases = self.read_lock_databases();
let db = databases.get(&preset_id.database_id)?;
let db = blocking_read_lock(db, "pot db find_preset_by_id 1");
db.find_preset_by_id(&provider_context, preset_id.preset_id)
}
pub fn with_plugin_db<R>(&self, f: impl FnOnce(&PluginDatabase) -> R) -> R {
f(&self.read_lock_plugin_db())
}
pub fn try_with_db<R>(
&self,
db_id: DatabaseId,
f: impl FnOnce(&dyn Database) -> R,
) -> Result<R, &'static str> {
let databases = self.read_lock_databases();
let db = databases.get(&db_id).ok_or("database not found")?;
let db = db
.try_read()
.map_err(|_| "couldn't acquire provider db lock")?;
let r = f(&**db);
Ok(r)
}
pub fn try_find_preset_by_id(
&self,
preset_id: PresetId,
) -> Result<Option<PotPreset>, &'static str> {
let plugin_db = self
.plugin_db
.try_read()
.map_err(|_| "couldn't acquire plugin db lock")?;
let provider_context = ProviderContext::new(&plugin_db);
let databases = self.read_lock_databases();
let db = databases
.get(&preset_id.database_id)
.ok_or("database not found")?;
let db = db
.try_read()
.map_err(|_| "couldn't acquire provider db lock")?;
Ok(db.find_preset_by_id(&provider_context, preset_id.preset_id))
}
/// Ignores exclude lists.
pub fn find_unsupported_preset_matching(
&self,
plugin_id: &PluginId,
preset_name: &str,
) -> Option<PersistentPresetId> {
let product_id = {
let plugin_db = self.read_lock_plugin_db();
let plugin = plugin_db.find_plugin_by_id(plugin_id)?;
plugin.common.core.product_id
};
self.read_lock_databases().values().find_map(|db| {
// Acquire database access
let db = blocking_read_lock(db, "pot db find_unsupported_preset_matching");
// Find preset
let preset = db.find_unsupported_preset_matching(product_id, preset_name)?;
Some(preset.common.persistent_id)
})
}
}
#[derive(Default)]
pub struct BuildOutput {
pub supported_filter_kinds: EnumSet<PotFilterKind>,
pub filter_item_collections: FilterItemCollections,
pub preset_collection: NonCryptoIndexSet<PresetId>,
pub stats: Stats,
}
fn measure_duration<R>(duration: &mut Duration, f: impl FnOnce() -> R) -> R {
let start = Instant::now();
let r = f();
*duration = start.elapsed();
r
}
fn add_constant_filter_items(
affected_kinds: EnumSet<PotFilterKind>,
filter_item_collections: &mut FilterItemCollections,
) {
if affected_kinds.contains(PotFilterKind::IsAvailable) {
filter_item_collections.set(
PotFilterKind::IsAvailable,
create_filter_items_is_available(),
);
}
if affected_kinds.contains(PotFilterKind::IsSupported) {
filter_item_collections.set(
PotFilterKind::IsSupported,
create_filter_items_is_supported(),
);
}
if affected_kinds.contains(PotFilterKind::IsFavorite) {
filter_item_collections.set(PotFilterKind::IsFavorite, create_filter_items_is_favorite());
}
if affected_kinds.contains(PotFilterKind::IsUser) {
filter_item_collections.set(PotFilterKind::IsUser, create_filter_items_is_user());
}
if affected_kinds.contains(PotFilterKind::HasPreview) {
filter_item_collections.set(PotFilterKind::HasPreview, create_filter_items_has_preview());
}
if affected_kinds.contains(PotFilterKind::ProductKind) {
filter_item_collections.set(
PotFilterKind::ProductKind,
create_filter_items_product_kind(),
);
}
}
fn create_filter_items_is_available() -> Vec<FilterItem> {
vec![
FilterItem::simple(FIL_IS_AVAILABLE_FALSE, "Not available", '❌', ""),
FilterItem::simple(
FIL_IS_AVAILABLE_TRUE,
"Available",
'✔',
"Usually means that the \
corresponding plug-in has been scanned before by REAPER.\n\
For Komplete, it means that the preset file itself is available.",
),
]
}
fn create_filter_items_is_supported() -> Vec<FilterItem> {
vec![
FilterItem::simple(FIL_IS_SUPPORTED_FALSE, "Not supported", '☹', ""),
FilterItem::simple(
FIL_IS_SUPPORTED_TRUE,
"Supported",
'☺',
"Means that Pot Browser \
can automatically load the preset into the corresponding plug-in.",
),
]
}
fn create_filter_items_is_favorite() -> Vec<FilterItem> {
vec![
FilterItem::simple(FIL_IS_FAVORITE_FALSE, "Not favorite", '☆', ""),
FilterItem::simple(FIL_IS_FAVORITE_TRUE, "Favorite", '★', ""),
]
}
fn create_filter_items_product_kind() -> Vec<FilterItem> {
vec![
FilterItem::none(),
FilterItem::simple(FIL_PRODUCT_KIND_INSTRUMENT, "Instrument", '🎹', ""),
FilterItem::simple(FIL_PRODUCT_KIND_EFFECT, "Effect", '✨', ""),
FilterItem::simple(FIL_PRODUCT_KIND_LOOP, "Loop", '➿', ""),
FilterItem::simple(FIL_PRODUCT_KIND_ONE_SHOT, "One shot", '💥', ""),
]
}
fn create_filter_items_is_user() -> Vec<FilterItem> {
vec![
FilterItem::simple(FIL_IS_USER_PRESET_FALSE, "Factory preset", '🏭', ""),
FilterItem::simple(FIL_IS_USER_PRESET_TRUE, "User preset", '🕵', ""),
]
}
fn create_filter_items_has_preview() -> Vec<FilterItem> {
vec![
FilterItem::simple(FIL_HAS_PREVIEW_FALSE, "No preview", '🔇', "Display only presets that have no preview. This filter can take very long when operating on a large preset list because it checks whether the preview files actually exist!"),
FilterItem::simple(FIL_HAS_PREVIEW_TRUE, "Has preview", '🔊', "Display only presets that have a preview. This filter can take very long when operating on a large preset list because it checks whether the preview files actually exist!"),
]
}
@@ -0,0 +1,393 @@
use crate::{
parse_vst2_magic_number, parse_vst3_uid, pot_db, EscapeCatcher, PersistentPresetId, PluginId,
};
use base::enigo::EnigoMouse;
use base::future_util::millis;
use base::hash_util::NonCryptoIndexMap;
use base::{blocking_lock_arc, file_util, hash_util};
use base::{Mouse, MouseCursorPosition};
use camino::{Utf8Path, Utf8PathBuf};
use helgobox_api::persistence::MouseButton;
use reaper_high::{Fx, FxInfo, Reaper};
use std::error::Error;
use std::fs;
use std::fs::File;
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::{Arc, Mutex};
pub type SharedPresetCrawlingState = Arc<Mutex<PresetCrawlingState>>;
#[derive(Debug)]
pub struct PresetCrawlingState {
crawled_presets: NonCryptoIndexMap<String, CrawledPreset>,
duplicate_preset_names: Vec<String>,
same_preset_name_in_a_row: Option<String>,
same_preset_name_in_a_row_attempts: u32,
same_preset_names_like_beginning: Vec<String>,
same_preset_name_like_beginning_attempts: u32,
total_bytes_crawled: usize,
}
#[derive(Debug)]
pub struct PresetCrawlingOutcome {
/// One temporary file that holds the chunks of all FXs when crawling finished.
/// Will be copied to separate destination files at a later stage.
/// If not set when stopped, this means at first it's a failure. Later we take the
/// file out of here for processing, in that case it's also `None`.
pub chunks_file: File,
pub reason: PresetCrawlerStopReason,
}
impl PresetCrawlingOutcome {
pub fn new(chunks_file: File, reason: PresetCrawlerStopReason) -> Self {
Self {
chunks_file,
reason,
}
}
}
impl PresetCrawlingState {
pub fn new() -> SharedPresetCrawlingState {
let state = Self {
crawled_presets: Default::default(),
duplicate_preset_names: Default::default(),
same_preset_name_in_a_row: None,
same_preset_name_in_a_row_attempts: 0,
same_preset_names_like_beginning: Default::default(),
same_preset_name_like_beginning_attempts: 0,
total_bytes_crawled: 0,
};
Arc::new(Mutex::new(state))
}
pub fn last_crawled_preset(&self) -> Option<&CrawledPreset> {
let last = self.crawled_presets.last()?;
Some(last.1)
}
pub fn pop_crawled_preset(&mut self) -> Option<CrawledPreset> {
let last = self.crawled_presets.pop()?;
Some(last.1)
}
pub fn bytes_crawled(&self) -> usize {
self.total_bytes_crawled
}
pub fn crawled_presets(&self) -> &NonCryptoIndexMap<String, CrawledPreset> {
&self.crawled_presets
}
pub fn preset_count(&self) -> u32 {
self.crawled_presets.len() as _
}
pub fn duplicate_preset_name_count(&self) -> u32 {
self.duplicate_preset_names.len() as _
}
pub fn duplicate_preset_names(&self) -> &[String] {
&self.duplicate_preset_names
}
fn add_preset(&mut self, preset: CrawledPreset, never_stop_crawling: bool) -> NextCrawlStep {
// Give stop signal if we reached the end of the list or are at its beginning again.
if !never_stop_crawling {
if let Some(step) = self.make_stop_check(&preset) {
return step;
}
}
// Reset "same preset name attempts" logic
self.same_preset_name_in_a_row_attempts = 0;
if let Some(last_same_preset_name) = self.same_preset_name_in_a_row.take() {
// Turns out that the last discovered same preset name was actually not the end
// of the preset list but just an intermediate duplicate. Treat it as such!
self.duplicate_preset_names.push(last_same_preset_name);
}
// Reset "same preset name like beginning" logic
self.same_preset_name_like_beginning_attempts = 0;
self.duplicate_preset_names
.append(&mut self.same_preset_names_like_beginning);
// Add or skip
if self.crawled_presets.contains_key(&preset.name) {
// Duplicate name. Skip preset!
self.duplicate_preset_names.push(preset.name);
} else {
// Add preset
self.total_bytes_crawled += preset.size_in_bytes;
self.crawled_presets.insert(preset.name.clone(), preset);
}
NextCrawlStep::Continue
}
/// This executes a heuristic to check whether the end of the preset list has been reached and
/// crawling should therefore stop.
///
/// It looks at the preset names only. I also tried to take the chunk into account but it's not
/// deterministic. Getting the chunk for one preset multiple times can yield different results!
fn make_stop_check(&mut self, preset: &CrawledPreset) -> Option<NextCrawlStep> {
// If we haven't crawled anything yet, there's nothing to check.
let (_, last_preset) = self.crawled_presets.last()?;
// Check if we get multiple equally named presets in a row.
if preset.name == last_preset.name {
// Same name like last crawled preset
if self.same_preset_name_in_a_row_attempts <= MAX_SAME_PRESET_NAME_IN_A_ROW_ATTEMPTS {
// Let's tolerate that right now and still continue crawling.
// It's possible that the plug-in crops the preset name and therefore
// presets that seemingly have the same name, in fact have different ones
// but have the same prefix. This happened with Zebra2 VSTi, for example.
self.same_preset_name_in_a_row_attempts += 1;
// Don't add it to the list of duplicates right away because it *might* really
// turn out to be the end of the preset list! If it turns out it isn't, we still add
// it to the list of duplicates later.
self.same_preset_name_in_a_row = Some(preset.name.clone());
return Some(NextCrawlStep::Continue);
} else {
// More than max same preset names in a row! That either means the
// "Next preset" button doesn't work at all or we have reached the end of the
// preset list.
return Some(NextCrawlStep::Stop(
PresetCrawlerStopReason::PresetNameNotChangingAnymore,
));
}
}
// Now check if the presets that we crawl are the same ones that we crawled in the beginning.
if let Some((_, reference_preset)) = self
.crawled_presets
.get_index(self.same_preset_name_like_beginning_attempts as usize)
{
if preset.name == reference_preset.name {
// This preset has the same name as the reference preset, which is one of the
// presets crawled right at the beginning.
if self.same_preset_name_like_beginning_attempts
<= MAX_SAME_PRESET_NAME_LIKE_BEGINNING_ATTEMPTS
{
// Let's tolerate that right now and still continue crawling.
// It's possible that the plug-in doesn't navigate through the preset list in
// a linear way.
self.same_preset_name_like_beginning_attempts += 1;
// Don't add it to the list of duplicates right away because it *might* really
// turn out to be the beginning of the preset list! If it turns out it isn't,
// we still add it to the list of duplicates later.
self.same_preset_names_like_beginning
.push(preset.name.clone());
return Some(NextCrawlStep::Continue);
} else {
// More than max matches with the beginning! That either means the plug-in
// navigates in a *very* non-linear fashion through the preset list or we have
// reached the end of the preset list and restarted at its beginning.
return Some(NextCrawlStep::Stop(
PresetCrawlerStopReason::PresetNameLikeBeginning,
));
}
}
}
None
}
}
enum NextCrawlStep {
Continue,
Stop(PresetCrawlerStopReason),
}
#[derive(Debug)]
pub struct CrawledPreset {
name: String,
offset: u64,
size_in_bytes: usize,
destination: Utf8PathBuf,
}
impl CrawledPreset {
pub fn name(&self) -> &str {
&self.name
}
pub fn destination(&self) -> &Utf8Path {
&self.destination
}
}
pub struct CrawlPresetArgs<F> {
pub fx: Fx,
pub next_preset_cursor_pos: MouseCursorPosition,
pub state: SharedPresetCrawlingState,
pub stop_if_destination_exists: bool,
pub never_stop_crawling: bool,
pub bring_focus_back_to_crawler: F,
}
pub async fn crawl_presets<F>(
args: CrawlPresetArgs<F>,
) -> Result<PresetCrawlingOutcome, Box<dyn Error + Send + Sync>>
where
F: Fn() + 'static,
{
let reaper_resource_dir = Reaper::get().resource_path();
// No need to fall back to chunk-based FX info because Pot is experimental, and we can assume it's used
// with recent REAPER versions.
let fx_info = args.fx.info()?;
let plugin_id = get_plugin_id_from_fx_info(&fx_info);
let mut mouse = EnigoMouse::new();
let escape_catcher = EscapeCatcher::new();
let mut chunks_file = tempfile::tempfile()?;
let mut current_file_offset = 0u64;
loop {
// Check if escape has been pressed
if escape_catcher.escape_was_pressed() {
return Ok(PresetCrawlingOutcome::new(
chunks_file,
PresetCrawlerStopReason::Interrupted,
));
}
// Get preset name
let name = args
.fx
.preset_name()
.ok_or("couldn't get preset name")?
.into_string();
{
// Query chunk and save it in temporary file
let fx_chunk = args.fx.chunk()?;
let fx_chunk_content = fx_chunk.content();
let fx_chunk_bytes = fx_chunk_content.as_bytes();
chunks_file.write_all(fx_chunk_bytes)?;
chunks_file.flush()?;
// Determine where on the disk the RfxChain file should end up
let destination = determine_preset_file_destination(
&fx_info,
&reaper_resource_dir,
&name,
plugin_id.as_ref(),
);
if args.stop_if_destination_exists && destination.exists() {
return Ok(PresetCrawlingOutcome::new(
chunks_file,
PresetCrawlerStopReason::DestinationFileExists,
));
}
// Build crawled preset
let crawled_preset = CrawledPreset {
destination,
name,
offset: current_file_offset,
size_in_bytes: fx_chunk_bytes.len(),
};
current_file_offset += fx_chunk_bytes.len() as u64;
let next_step = blocking_lock_arc(&args.state, "crawl_presets 3")
.add_preset(crawled_preset, args.never_stop_crawling);
match next_step {
NextCrawlStep::Stop(reason) => {
return Ok(PresetCrawlingOutcome::new(chunks_file, reason));
}
NextCrawlStep::Continue => {}
}
}
// Click "Next preset" button
args.fx.show_in_floating_window()?;
mouse.set_cursor_position(args.next_preset_cursor_pos)?;
moment().await;
mouse.press(MouseButton::Left)?;
moment().await;
mouse.release(MouseButton::Left)?;
a_bit_longer().await;
}
}
fn determine_preset_file_destination(
fx_info: &FxInfo,
reaper_resource_dir: &Utf8Path,
preset_name: &str,
plugin_id: Option<&PluginId>,
) -> Utf8PathBuf {
if let Some(persistent_preset_id) = find_shimmable_preset(plugin_id, preset_name) {
// Matched with existing unsupported preset. Create RfxChain file, a so called shim file,
// but not in the FX chain directory because we don't want it to show up in the FX chain
// database. Instead, we want the original preset (probably in the Komplete database)
// to become loadable. There's logic in our preset loading mechanism that looks for
// a shim file if it realizes that the preset can't be loaded. A kind of fallback!
get_shim_file_path(reaper_resource_dir, &persistent_preset_id)
} else {
// No match with existing unsupported preset
let sanitized_effect_name = sanitize_filename::sanitize(&fx_info.effect_name);
let file_name = format!("{}.RfxChain", &preset_name);
let sanitized_file_name = sanitize_filename::sanitize(file_name);
reaper_resource_dir
.join("FXChains/Pot")
.join(sanitized_effect_name)
.join(sanitized_file_name)
}
}
/// Returns the file name of the original preset.
fn find_shimmable_preset(
plugin_id: Option<&PluginId>,
preset_name: &str,
) -> Option<PersistentPresetId> {
let plugin_id = plugin_id?;
pot_db().find_unsupported_preset_matching(plugin_id, preset_name)
}
fn get_plugin_id_from_fx_info(fx_info: &FxInfo) -> Option<PluginId> {
let plugin_id = match fx_info.sub_type_expression.as_str() {
"VST" | "VSTi" => PluginId::vst2(parse_vst2_magic_number(&fx_info.id).ok()?),
"VST3" | "VST3i" => PluginId::vst3(parse_vst3_uid(&fx_info.id).ok()?),
// Komplete doesn't support CLAP or JS anyway, so not important right now.
_ => return None,
};
Some(plugin_id)
}
pub async fn import_crawled_presets(
state: SharedPresetCrawlingState,
mut chunks_file: File,
) -> Result<(), Box<dyn Error + Send + Sync>> {
loop {
let p = blocking_lock_arc(&state, "import_crawled_presets").pop_crawled_preset();
let Some(p) = p else {
break;
};
let dest_file_path = &p.destination;
let dest_dir_path = p.destination.parent().ok_or("destination without parent")?;
fs::create_dir_all(dest_dir_path)?;
chunks_file.seek(SeekFrom::Start(p.offset))?;
let mut buf = vec![0; p.size_in_bytes];
chunks_file.read_exact(&mut buf)?;
fs::write(dest_file_path, buf)?;
}
Ok(())
}
async fn a_bit_longer() {
millis(100).await;
}
async fn moment() {
millis(50).await;
}
const MAX_SAME_PRESET_NAME_IN_A_ROW_ATTEMPTS: u32 = 10;
const MAX_SAME_PRESET_NAME_LIKE_BEGINNING_ATTEMPTS: u32 = 10;
pub fn get_shim_file_path(
reaper_resource_dir: &Utf8Path,
preset_id: &PersistentPresetId,
) -> Utf8PathBuf {
// We don't need to
let hash =
hash_util::calculate_persistent_non_crypto_hash_one_shot(preset_id.to_string().as_bytes());
let file_name = file_util::convert_hash_to_dir_structure(hash, ".RfxChain");
reaper_resource_dir
.join("Helgoboss/Pot/shims")
.join(file_name)
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum PresetCrawlerStopReason {
Interrupted,
DestinationFileExists,
PresetNameNotChangingAnymore,
PresetNameLikeBeginning,
}
@@ -0,0 +1,254 @@
use crate::provider_database::{
FIL_IS_AVAILABLE_TRUE, FIL_IS_SUPPORTED_TRUE, FIL_PRODUCT_KIND_INSTRUMENT,
};
use crate::{
pot_db, preview_exists, BuildInput, Destination, EscapeCatcher, FilterItemId,
LoadPresetOptions, LoadPresetWindowBehavior, PluginId, PotPreset, PotPresetKind, PresetWithId,
ProductId, SharedRuntimePotUnit,
};
use base::future_util::millis;
use base::hash_util::PersistentHash;
use base::{blocking_lock_arc, blocking_write_lock, file_util};
use camino::{Utf8Path, Utf8PathBuf};
use helgobox_api::persistence::PotFilterKind;
use reaper_high::{Project, Reaper};
use reaper_medium::{CommandId, OpenProjectBehavior, ProjectContext, ProjectInfoAttributeKey};
use std::error::Error;
use std::sync::{Arc, RwLock};
pub type SharedPreviewRecorderState = Arc<RwLock<PreviewRecorderState>>;
#[derive(Debug)]
pub struct PreviewRecorderState {
pub todos: Vec<PresetWithId>,
pub failures: Vec<PreviewRecorderFailure>,
}
impl PreviewRecorderState {
pub fn new(todos: Vec<PresetWithId>) -> Self {
Self {
todos,
failures: vec![],
}
}
}
#[derive(Debug)]
pub struct PreviewRecorderFailure {
pub preset: PresetWithId,
pub reason: String,
}
impl AsRef<PotPreset> for PreviewRecorderFailure {
fn as_ref(&self) -> &PotPreset {
&self.preset.preset
}
}
pub struct RecordPreviewsArgs<'a> {
pub shared_pot_unit: SharedRuntimePotUnit,
pub state: SharedPreviewRecorderState,
/// RPP file that is used to render the previews
pub preview_rpp: &'a Utf8Path,
pub config: PreviewOutputConfig,
}
#[derive(Clone, Debug)]
pub enum PreviewOutputConfig {
/// For playback within Pot Browser.
ForPotBrowserPlayback,
/// For export to custom location.
Export(ExportPreviewOutputConfig),
}
#[derive(Clone, Debug)]
pub struct ExportPreviewOutputConfig {
/// Where to put the preview files.
pub base_dir: Utf8PathBuf,
}
pub async fn record_previews(args: RecordPreviewsArgs<'_>) -> Result<(), Box<dyn Error>> {
let reaper = Reaper::get();
let reaper_resource_dir = reaper.resource_path();
// Open preview project template in new tab
let project = open_preview_project_in_new_tab(args.preview_rpp);
moment().await;
// Prepare destination (first track, first FX)
let first_track = project
.first_track()
.ok_or("preview must have at least one track")?;
let destination = Destination {
chain: first_track.normal_fx_chain(),
fx_index: 0,
};
let cloned_state = args.state.clone();
let report_failure = move |preset: PresetWithId, reason: String| {
let mut state = blocking_write_lock(&cloned_state, "record_previews state 2");
let failure = PreviewRecorderFailure { preset, reason };
state.failures.push(failure);
};
let escape_catcher = EscapeCatcher::new();
// Loop over the preset list
loop {
// Check if escape has been pressed
if escape_catcher.escape_was_pressed() {
break;
}
// Take new preset to be recorded
moment().await;
let Some(preset_with_id) = blocking_write_lock(&args.state, "record_previews state")
.todos
.pop()
else {
// Done!
break;
};
// Determine destination file
let preset = &preset_with_id.preset;
let preview_file_path = match &args.config {
PreviewOutputConfig::ForPotBrowserPlayback => {
// Prefer creating preview file name based on preset content. That means whenever the
// content changes, we get a different preview file name. That is cool.
let hash = preset.common.content_or_id_hash();
get_preview_file_path_from_hash(&reaper_resource_dir, hash)
}
PreviewOutputConfig::Export(c) => {
let product_dir_name = preset
.common
.product_name
.as_deref()
.unwrap_or("Unknown product");
c.base_dir.join(product_dir_name).join(&preset.common.name)
}
};
// Load preset
let options = LoadPresetOptions {
window_behavior_override: Some(LoadPresetWindowBehavior::AlwaysShow),
audio_sample_behavior: Default::default(),
};
{
let load_result = blocking_lock_arc(&args.shared_pot_unit, "record_previews pot unit")
.load_preset_at(preset, options, &|_| Ok(destination.clone()));
if let Err(e) = load_result {
report_failure(preset_with_id, e.to_string());
continue;
}
}
moment().await;
// Record preview
if let Err(e) = render_to_file(project, &preview_file_path) {
report_failure(preset_with_id, e.to_string());
}
}
Ok(())
}
fn render_to_file(project: Project, full_path: &Utf8Path) -> Result<(), Box<dyn Error>> {
let reaper = Reaper::get();
let medium_reaper = reaper.medium_reaper();
let dir = full_path.parent().ok_or("render path has not parent")?;
let file_name = full_path
.file_name()
.ok_or("render path has no file name")?;
medium_reaper.get_set_project_info_string_set(
ProjectContext::Proj(project.raw()),
ProjectInfoAttributeKey::RenderFile,
dir.as_str(),
)?;
medium_reaper.get_set_project_info_string_set(
ProjectContext::Proj(project.raw()),
ProjectInfoAttributeKey::RenderPattern,
file_name,
)?;
// "File: Render project, using the most recent render settings, auto-close render dialog"
reaper
.main_section()
.action_by_command_id(CommandId::new(42230))
.invoke_as_trigger(Some(project), None)?;
Ok(())
}
fn open_preview_project_in_new_tab(preview_rpp: &Utf8Path) -> Project {
let reaper = Reaper::get();
let project = reaper.create_empty_project_in_new_tab();
let mut behavior = OpenProjectBehavior::default();
behavior.prompt = false;
behavior.open_as_template = true;
reaper
.medium_reaper()
.main_open_project(preview_rpp, behavior);
project
}
async fn moment() {
millis(200).await;
}
pub fn get_preview_file_path_from_hash(
reaper_resource_dir: &Utf8Path,
hash: PersistentHash,
) -> Utf8PathBuf {
let file_name = file_util::convert_hash_to_dir_structure(hash, ".ogg");
reaper_resource_dir
.join("Helgoboss/Pot/previews")
.join(file_name)
}
/// Can take long.
pub fn prepare_preview_recording(
mut build_input: BuildInput,
output_config: &PreviewOutputConfig,
) -> Vec<PresetWithId> {
// We want only available and supported instruments
build_input.filters.set(
PotFilterKind::ProductKind,
Some(FilterItemId(Some(FIL_PRODUCT_KIND_INSTRUMENT))),
);
build_input.filters.set(
PotFilterKind::IsAvailable,
Some(FilterItemId(Some(FIL_IS_AVAILABLE_TRUE))),
);
build_input.filters.set(
PotFilterKind::IsSupported,
Some(FilterItemId(Some(FIL_IS_SUPPORTED_TRUE))),
);
// Gather
let mut presets = pot_db().gather_presets(build_input);
if matches!(output_config, PreviewOutputConfig::ForPotBrowserPlayback) {
// Take only those that don't have a preview within Pot Browser yet
let reaper_resource_dir = Reaper::get().resource_path();
presets.retain(|p| !preview_exists(&p.preset, &reaper_resource_dir));
}
// Reverse, so we can efficiently pop from the front later on
presets.reverse();
// Sort by plug-in
presets.sort_by(|left, right| bucket(left).cmp(&bucket(right)));
presets
}
fn bucket(preset_with_id: &PresetWithId) -> BucketId {
let preset = &preset_with_id.preset;
if !preset.common.plugin_ids.is_empty() {
return BucketId::Plugin(&preset.common.plugin_ids);
}
if !preset.common.product_ids.is_empty() {
return BucketId::ProductId(&preset.common.product_ids);
}
if let PotPresetKind::FileBased(kind) = &preset.kind {
return BucketId::FileExtension(&kind.file_ext);
}
BucketId::Remaining
}
#[derive(Eq, PartialEq, Ord, PartialOrd)]
enum BucketId<'a> {
/// The plug-in ID is certainly the best criteria here! We have that for all databases except
/// Komplete. For FX chains and track templates, we might have multiple plug-ins. The order of
/// these plug-ins is important because different orders will reload the plug-ins.
Plugin(&'a [PluginId]),
/// The next best bet is
ProductId(&'a [ProductId]),
/// And finally: File extension.
FileExtension(&'a str),
Remaining,
}
@@ -0,0 +1,132 @@
use crate::plugins::{PluginDatabase, ProductKind};
use crate::{
Fil, FilterItem, FilterItemId, GenericFilterItemCollections, HasFilterItemId, InnerBuildInput,
InnerPresetId, PersistentDatabaseId, PotPreset, ProductId,
};
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use std::borrow::Cow;
use std::error::Error;
/// A database ID that's only stable during the runtime of ReaLearn.
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub struct DatabaseId(pub u32);
pub trait Database {
fn persistent_id(&self) -> &PersistentDatabaseId;
// TODO-medium-performace Maybe we should require this to be a reference.
fn name(&self) -> Cow<str>;
// TODO-medium-performace Maybe we should require this to be a reference.
fn description(&self) -> Cow<str>;
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
enum_set!()
}
fn refresh(&mut self, context: &ProviderContext) -> Result<(), Box<dyn Error>>;
fn query_filter_collections(
&self,
context: &ProviderContext,
input: InnerBuildInput,
affected_kinds: EnumSet<PotFilterKind>,
) -> Result<InnerFilterItemCollections, Box<dyn Error>>;
fn query_presets(
&self,
context: &ProviderContext,
input: InnerBuildInput,
) -> Result<Vec<SortablePresetId>, Box<dyn Error>>;
fn find_preset_by_id(
&self,
context: &ProviderContext,
preset_id: InnerPresetId,
) -> Option<PotPreset>;
/// Tries to find a preset that belongs to the given product and has the given name *and*
/// most importantly a preset file format that can't be loaded by Pot Browser.
///
/// This is used by the preset crawler to identify whether a crawled preset can be used to
/// make a preset with an unsupported format actually loadable. Only makes sense for Komplete
/// at the moment because this is the only database which exposes unsupported presets.
fn find_unsupported_preset_matching(
&self,
product_id: ProductId,
preset_name: &str,
) -> Option<PotPreset> {
let _ = (product_id, preset_name);
None
}
}
pub type InnerFilterItemCollections = GenericFilterItemCollections<InnerFilterItem>;
pub enum InnerFilterItem {
/// A unique final filter item. Only makes sense within a specific database and within the
/// context of a specific pot filter item kind. Not deduplicated.
Unique(FilterItem),
/// A filter item representing a particular product (product for which the preset is made).
///
/// Will be deduplicated by the pot database!
Product(ProductId),
}
impl HasFilterItemId for InnerFilterItem {
fn id(&self) -> FilterItemId {
match self {
InnerFilterItem::Unique(i) => i.id,
InnerFilterItem::Product(i) => FilterItemId(Some(Fil::Product(*i))),
}
}
}
pub struct SortablePresetId {
pub inner_preset_id: InnerPresetId,
pub preset_name: String,
}
impl SortablePresetId {
pub fn new(i: u32, preset_name: String) -> Self {
Self {
inner_preset_id: InnerPresetId(i),
preset_name,
}
}
}
#[derive(Copy, Clone)]
pub struct ProviderContext<'a> {
pub plugin_db: &'a PluginDatabase,
}
impl<'a> ProviderContext<'a> {
pub fn new(plugin_db: &'a PluginDatabase) -> Self {
Self { plugin_db }
}
}
/// Komplete content path state ID = 1
pub const FIL_IS_AVAILABLE_TRUE: Fil = Fil::Boolean(true);
/// Komplete content path state ID = 4
pub const FIL_IS_AVAILABLE_FALSE: Fil = Fil::Boolean(false);
pub const FIL_IS_SUPPORTED_TRUE: Fil = Fil::Boolean(true);
pub const FIL_IS_SUPPORTED_FALSE: Fil = Fil::Boolean(false);
pub const FIL_IS_FAVORITE_TRUE: Fil = Fil::Boolean(true);
pub const FIL_IS_FAVORITE_FALSE: Fil = Fil::Boolean(false);
/// Komplete content type ID = 1
pub const FIL_IS_USER_PRESET_TRUE: Fil = Fil::Boolean(true);
/// Komplete content type ID = 2
pub const FIL_IS_USER_PRESET_FALSE: Fil = Fil::Boolean(false);
/// Komplete product type ID = 1
pub const FIL_PRODUCT_KIND_INSTRUMENT: Fil = Fil::ProductKind(ProductKind::Instrument);
/// Komplete product type ID = 2
pub const FIL_PRODUCT_KIND_EFFECT: Fil = Fil::ProductKind(ProductKind::Effect);
/// Komplete product type ID = 4
pub const FIL_PRODUCT_KIND_LOOP: Fil = Fil::ProductKind(ProductKind::Loop);
/// Komplete product type ID = 8
pub const FIL_PRODUCT_KIND_ONE_SHOT: Fil = Fil::ProductKind(ProductKind::OneShot);
pub const FIL_HAS_PREVIEW_TRUE: Fil = Fil::Boolean(true);
pub const FIL_HAS_PREVIEW_FALSE: Fil = Fil::Boolean(false);
@@ -0,0 +1,151 @@
use crate::provider_database::{
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
};
use crate::{
create_plugin_factory_preset, FilterInput, InnerBuildInput, InnerPresetId,
PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId, PluginId,
PluginIdInPipeFormat, PotPreset, SearchInput,
};
use std::borrow::Cow;
use crate::plugins::PluginCommon;
use either::Either;
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use itertools::Itertools;
use std::error::Error;
use std::iter;
pub struct DefaultsDatabase {
persistent_id: PersistentDatabaseId,
plugins: Vec<PluginCommon>,
}
impl Default for DefaultsDatabase {
fn default() -> Self {
Self {
persistent_id: PersistentDatabaseId::new("fx-defaults".to_string()),
plugins: vec![],
}
}
}
impl DefaultsDatabase {
pub fn open() -> Self {
Default::default()
}
fn query_presets_internal<'a>(
&'a self,
filter_input: &'a FilterInput,
) -> impl Iterator<Item = (usize, &'a PluginCommon)> + 'a {
let matches = !filter_input.filters.wants_user_presets_only();
if !matches {
return Either::Left(iter::empty());
}
let iter = self.plugins.iter().enumerate().filter(|(i, p)| {
let id = InnerPresetId(*i as _);
filter_input.everything_matches(Some(&p.core), id)
});
Either::Right(iter)
}
}
impl Database for DefaultsDatabase {
fn persistent_id(&self) -> &PersistentDatabaseId {
&self.persistent_id
}
fn name(&self) -> Cow<str> {
"FX defaults".into()
}
fn description(&self) -> Cow<str> {
"Default factory presets for all of your plug-ins".into()
}
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
enum_set!(PotFilterKind::Bank)
}
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
// We clone the plug-in list so we can create own own order and maintain stable IDs.
self.plugins = ctx.plugin_db.plugins().map(|p| p.common.clone()).collect();
Ok(())
}
fn query_filter_collections(
&self,
_: &ProviderContext,
input: InnerBuildInput,
_: EnumSet<PotFilterKind>,
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
let mut new_filters = *input.filter_input.filters;
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
let product_items = self
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
.map(|(_, plugin)| plugin.core.product_id)
.unique()
.map(InnerFilterItem::Product)
.collect();
let mut collections = InnerFilterItemCollections::empty();
collections.set(PotFilterKind::Bank, product_items);
Ok(collections)
}
fn query_presets(
&self,
_: &ProviderContext,
input: InnerBuildInput,
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
let preset_ids = self
.query_presets_internal(&input.filter_input)
.filter(|(_, entry)| {
let search_input = DefaultSearchInput { entry };
input.search_evaluator.matches(search_input)
})
.map(|(i, _)| SortablePresetId::new(i as _, PRESET_NAME.to_string()))
.collect();
Ok(preset_ids)
}
fn find_preset_by_id(
&self,
_: &ProviderContext,
preset_id: InnerPresetId,
) -> Option<PotPreset> {
let plugin = self.plugins.get(preset_id.0 as usize)?;
let persistent_id = PersistentPresetId::new(
self.persistent_id.clone(),
create_persistent_inner_id(&plugin.core.id),
);
let preset = create_plugin_factory_preset(plugin, persistent_id, PRESET_NAME.to_string());
Some(preset)
}
}
const PRESET_NAME: &str = "<Default>";
/// Example: `vst2|1967946098`
fn create_persistent_inner_id(plugin_id: &PluginId) -> PersistentInnerPresetId {
let id = PluginIdInPipeFormat(plugin_id).to_string();
PersistentInnerPresetId::new(id)
}
struct DefaultSearchInput<'a> {
entry: &'a PluginCommon,
}
impl SearchInput for DefaultSearchInput<'_> {
fn preset_name(&self) -> &str {
PRESET_NAME
}
fn product_name(&self) -> Option<Cow<str>> {
Some(self.entry.to_string().into())
}
fn file_extension(&self) -> Option<&str> {
None
}
}
@@ -0,0 +1,293 @@
use crate::provider_database::{
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
};
use crate::{
FiledBasedPotPresetKind, FilterInput, InnerBuildInput, InnerPresetId, PersistentDatabaseId,
PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginId, PotPreset, PotPresetCommon,
PotPresetKind, SearchInput,
};
use std::borrow::Cow;
use crate::plugins::{PluginCore, PluginDatabase};
use base::hash_util::{NonCryptoHashSet, NonCryptoIndexMap, PersistentHash, PersistentHasher};
use camino::Utf8PathBuf;
use either::Either;
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use itertools::Itertools;
use std::error::Error;
use std::ffi::OsStr;
use std::fs::File;
use std::hash::Hasher;
use std::io::{BufRead, BufReader};
use std::iter;
use std::path::Path;
use walkdir::WalkDir;
pub struct DirectoryDatabase {
persistent_id: PersistentDatabaseId,
root_dir: Utf8PathBuf,
valid_extensions: NonCryptoHashSet<&'static OsStr>,
name: &'static str,
description: &'static str,
entries: Vec<PresetEntry>,
}
pub struct DirectoryDbConfig {
pub persistent_id: PersistentDatabaseId,
pub root_dir: Utf8PathBuf,
pub valid_extensions: &'static [&'static str],
pub name: &'static str,
pub description: &'static str,
}
impl DirectoryDatabase {
pub fn open(config: DirectoryDbConfig) -> Result<Self, Box<dyn Error>> {
if !config.root_dir.try_exists()? {
return Err("path to root directory doesn't exist".into());
}
let db = Self {
persistent_id: config.persistent_id,
name: config.name,
entries: Default::default(),
root_dir: config.root_dir,
valid_extensions: config.valid_extensions.iter().map(OsStr::new).collect(),
description: config.description,
};
Ok(db)
}
fn query_presets_internal<'a>(
&'a self,
filter_input: &'a FilterInput,
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
let matches = !filter_input.filters.wants_factory_presets_only();
if !matches {
return Either::Left(iter::empty());
}
let iter = self.entries.iter().enumerate().filter(|(id, e)| {
let id = InnerPresetId(*id as _);
e.plugin_cores
.values()
.any(|core| filter_input.everything_matches(Some(core), id))
});
Either::Right(iter)
}
}
struct PresetEntry {
preset_name: String,
relative_path: String,
plugin_cores: NonCryptoIndexMap<PluginId, PluginCore>,
content_hash: PersistentHash,
}
impl Database for DirectoryDatabase {
fn persistent_id(&self) -> &PersistentDatabaseId {
&self.persistent_id
}
fn name(&self) -> Cow<str> {
self.name.into()
}
fn description(&self) -> Cow<str> {
self.description.into()
}
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
enum_set!(PotFilterKind::Bank)
}
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
self.entries = WalkDir::new(&self.root_dir)
.follow_links(true)
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_file() {
return None;
}
let extension = entry.path().extension()?;
if !self.valid_extensions.contains(extension) {
return None;
}
let relative_path = entry.path().strip_prefix(&self.root_dir).ok()?;
// Immediately exclude relative paths that can't be represented as valid UTF-8.
// Otherwise we will potentially open a can of worms (regarding persistence etc.).
let processing_output = process_file(entry.path(), ctx.plugin_db).ok()?;
let preset_entry = PresetEntry {
preset_name: entry.path().file_stem()?.to_str()?.to_string(),
relative_path: relative_path.to_str()?.to_string(),
plugin_cores: processing_output.used_plugins,
content_hash: processing_output.content_hash,
};
Some(preset_entry)
})
.collect();
Ok(())
}
fn query_filter_collections(
&self,
_: &ProviderContext,
input: InnerBuildInput,
_: EnumSet<PotFilterKind>,
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
let mut new_filters = *input.filter_input.filters;
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
let product_items = self
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
.flat_map(|(_, entry)| entry.plugin_cores.values().map(|core| core.product_id))
.unique()
.map(InnerFilterItem::Product)
.collect();
let mut collections = InnerFilterItemCollections::empty();
collections.set(PotFilterKind::Bank, product_items);
Ok(collections)
}
fn query_presets(
&self,
ctx: &ProviderContext,
input: InnerBuildInput,
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
let preset_ids = self
.query_presets_internal(&input.filter_input)
.filter(|(_, entry)| {
let search_input = DirectorySearchInput {
ctx,
preset_entry: entry,
};
input.search_evaluator.matches(search_input)
})
.map(|(i, entry)| SortablePresetId::new(i as _, entry.preset_name.clone()))
.collect();
Ok(preset_ids)
}
fn find_preset_by_id(
&self,
ctx: &ProviderContext,
preset_id: InnerPresetId,
) -> Option<PotPreset> {
let preset_entry = self.entries.get(preset_id.0 as usize)?;
let preset = PotPreset {
common: PotPresetCommon {
persistent_id: PersistentPresetId::new(
self.persistent_id().clone(),
create_persistent_inner_id(preset_entry),
),
name: preset_entry.preset_name.clone(),
context_name: Path::new(&preset_entry.relative_path)
.parent()
.and_then(|p| Some(p.to_str()?.to_string())),
plugin_ids: preset_entry.plugin_cores.values().map(|c| c.id).collect(),
product_ids: preset_entry
.plugin_cores
.values()
.map(|c| c.product_id)
.collect(),
product_name: build_product_name(ctx, preset_entry),
content_hash: Some(preset_entry.content_hash),
db_specific_preview_file: None,
is_supported: true,
is_available: !preset_entry.plugin_cores.is_empty(),
metadata: Default::default(),
},
kind: PotPresetKind::FileBased(FiledBasedPotPresetKind {
file_ext: get_file_extension(&preset_entry.relative_path).to_string(),
path: self.root_dir.join(&preset_entry.relative_path),
}),
};
Some(preset)
}
}
struct FileProcessingOutput {
content_hash: PersistentHash,
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
}
/// Finds used plug-ins in a REAPER-XML-like text file (e.g. RPP, RfxChain, RTrackTemplate).
///
/// Examples entries:
///
/// ```text
/// <VST "VSTi: Zebra2 (u-he)" Zebra2.vst 0 Schmackes 1397572658<565354534D44327A6562726132000000> ""
/// <VST "VSTi: ReaSamplOmatic5000 (Cockos)"
/// <CLAP "CLAPi: Surge XT (Surge Synth Team)"
/// ```
fn process_file(
path: &Path,
plugin_db: &PluginDatabase,
) -> Result<FileProcessingOutput, Box<dyn Error>> {
let file = File::open(path)?;
let mut used_plugins = NonCryptoIndexMap::default();
let mut buffer = String::new();
let mut reader = BufReader::new(&file);
let mut hasher = PersistentHasher::new();
while let Ok(count) = reader.read_line(&mut buffer) {
if count == 0 {
// EOF
break;
}
hasher.write(buffer.as_bytes());
let line = buffer.trim();
if let Some(plugin) = plugin_db.detect_plugin_from_rxml_line(line) {
used_plugins.insert(plugin.common.core.id, plugin.common.core);
}
buffer.clear();
}
let output = FileProcessingOutput {
content_hash: hasher.digest_128(),
used_plugins,
};
Ok(output)
}
/// Example: `Synths/Lead.RTrackTemplate`
fn create_persistent_inner_id(preset_entry: &PresetEntry) -> PersistentInnerPresetId {
let escaped_path = PipeEscaped(preset_entry.relative_path.as_str());
PersistentInnerPresetId::new(escaped_path.to_string())
}
struct DirectorySearchInput<'a> {
ctx: &'a ProviderContext<'a>,
preset_entry: &'a PresetEntry,
}
impl SearchInput for DirectorySearchInput<'_> {
fn preset_name(&self) -> &str {
&self.preset_entry.preset_name
}
fn product_name(&self) -> Option<Cow<str>> {
let product_name = build_product_name(self.ctx, self.preset_entry)?;
Some(product_name.into())
}
fn file_extension(&self) -> Option<&str> {
Some(get_file_extension(&self.preset_entry.relative_path))
}
}
fn build_product_name(ctx: &ProviderContext, preset_entry: &PresetEntry) -> Option<String> {
if preset_entry.plugin_cores.len() > 1 {
Some("<Multiple>".to_string())
} else if let Some(first) = preset_entry.plugin_cores.values().next() {
ctx.plugin_db
.find_plugin_by_id(&first.id)
.map(|p| p.common.to_string())
} else {
None
}
}
fn get_file_extension(relative_path: &str) -> &str {
Path::new(relative_path)
.extension()
.unwrap()
.to_str()
.unwrap()
}
@@ -0,0 +1,331 @@
use crate::provider_database::{
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
};
use crate::{
FilterInput, InnerBuildInput, InnerPresetId, InternalPotPresetKind, PersistentDatabaseId,
PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginKind, PotPreset,
PotPresetCommon, PotPresetKind, SearchInput,
};
use std::borrow::Cow;
use crate::plugins::{Plugin, PluginCore, SuperPluginKind};
use base::hash_util::{PersistentHash, PersistentHasher};
use camino::Utf8PathBuf;
use either::Either;
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use ini::Ini;
use itertools::Itertools;
use std::error::Error;
use std::hash::Hasher;
use std::iter;
use std::str::FromStr;
use walkdir::WalkDir;
pub struct IniDatabase {
persistent_id: PersistentDatabaseId,
root_dir: Utf8PathBuf,
entries: Vec<PresetEntry>,
}
impl IniDatabase {
pub fn open(
persistent_id: PersistentDatabaseId,
root_dir: Utf8PathBuf,
) -> Result<Self, Box<dyn Error>> {
if !root_dir.try_exists()? {
return Err("path to presets root directory doesn't exist".into());
}
let db = Self {
persistent_id,
entries: Default::default(),
root_dir,
};
Ok(db)
}
fn query_presets_internal<'a>(
&'a self,
filter_input: &'a FilterInput,
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
let matches = !filter_input.filters.wants_factory_presets_only();
if !matches {
return Either::Left(iter::empty());
}
let iter = self.entries.iter().enumerate().filter(|(i, e)| {
let id = InnerPresetId(*i as _);
filter_input.everything_matches(e.plugin.as_ref(), id)
});
Either::Right(iter)
}
}
struct PresetEntry {
preset_name: String,
plugin_kind: PluginKind,
/// Example: "Massive"
plugin_identifier: String,
/// If `None`, it means the corresponding plug-in is not installed/scanned.
plugin: Option<PluginCore>,
content_hash: Option<PersistentHash>,
}
impl Database for IniDatabase {
fn persistent_id(&self) -> &PersistentDatabaseId {
&self.persistent_id
}
fn name(&self) -> Cow<str> {
"FX presets".into()
}
fn description(&self) -> Cow<str> {
"All FX presets that you saved via \"Save preset...\" in REAPER's FX window.\n\".vstpreset\"-style presets are not yet supported!"
.into()
}
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
enum_set!(PotFilterKind::Bank)
}
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
let file_name_regex = base::regex!(r#"(?i)(.*?)-(.*).ini"#);
self.entries = WalkDir::new(&self.root_dir)
.max_depth(1)
.follow_links(true)
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_file() {
return None;
}
let file_name = entry.file_name().to_str()?;
// Example file names:
// - vst-Zebra2.ini
// - vst3-FM8-1168312232-builtin.ini
// - vst-TDR Nova-builtin.ini
// - vst-reacomp.ini
// - vst3-Massive.ini
// - clap-org_surge-synth-team_surge-xt.ini
// - js-analysis_hund.ini
let captures = file_name_regex.captures(file_name)?;
let plugin_kind_str = captures.get(1)?.as_str();
let plugin_kind = PluginKind::from_str(plugin_kind_str).ok()?;
let plugin_identifier = captures.get(2)?.as_str();
if plugin_identifier.ends_with("-builtin") {
return None;
}
let (main_plugin_identifier, shell_qualifier) =
match plugin_identifier.rsplit_once('-') {
// Example: vst3-Zebra2-959560201.ini
// (interpret the number behind the dash as shell qualifier)
Some((left, right))
if right.len() >= 5 && right.chars().all(|ch| ch.is_ascii_digit()) =>
{
(left, Some(right))
}
// Examples: "vst-Tritik-Irid.ini", "vst-Zebra2.ini"
_ => (plugin_identifier, None),
};
let plugin = ctx.plugin_db.plugins().find(|p| {
if p.common.core.id.kind() != plugin_kind {
return false;
}
match &p.kind {
SuperPluginKind::Vst(k) => {
let unsafe_char_regex = base::regex!(r#"[^a-zA-Z0-9_]"#);
let safe_main_plugin_identifier =
unsafe_char_regex.replace_all(main_plugin_identifier, "_");
let file_name_prefix = format!("{safe_main_plugin_identifier}.");
tracing::trace!(
"Test VST '{}' should start with INI plug-in file name prefix '{file_name_prefix}'",
k.safe_file_name
);
if !k.safe_file_name.starts_with(&file_name_prefix) {
return false;
}
let plugin_shell_qualifier = k.shell_qualifier.as_deref();
if shell_qualifier != plugin_shell_qualifier {
return false;
}
true
}
SuperPluginKind::Clap(k) => {
let safe_plugin_id = k.id.replace('.', "_");
if plugin_identifier != safe_plugin_id {
return false;
}
true
}
SuperPluginKind::Js(k) => {
let lowercase_safe_path = k.path.replace(['/', '\\', '.'], "_").to_lowercase();
let lowercase_plugin_identifier = plugin_identifier.to_lowercase();
tracing::trace!(
"Test JS '{lowercase_safe_path}' vs. INI plug-in identifier '{lowercase_plugin_identifier}'"
);
if lowercase_plugin_identifier != lowercase_safe_path {
return false;
}
true
}
}
});
let ini_file = Ini::load_from_file(entry.path()).ok()?;
let general_section = ini_file.section(Some("General"))?;
let nb_presets = general_section.get("NbPresets")?;
let preset_count: u32 = nb_presets.parse().ok()?;
let plugin_identifier = plugin_identifier.to_string();
let iter = (0..preset_count).filter_map(move |i| {
let section_name = format!("Preset{i}");
let section = ini_file.section(Some(section_name))?;
let name = section.get("Name")?;
// Calculate hash. At first add info about the plug-in. Without that info,
// the content could be ambiguous.
let mut hasher = PersistentHasher::new();
let plugin_kind_str = plugin_kind.as_ref();
let plugin_info = format!("{plugin_kind_str}-{plugin_identifier}");
hasher.write(plugin_info.as_bytes());
// Calculate hash out of data properties ("Data", "Data_1", "Data_2", ...)
let data = section.get("Data")?;
hasher.write(data.as_bytes());
let mut i = 1;
while let Some(more_data) = section.get(format!("Data{i}")) {
hasher.write(more_data.as_bytes());
i += 1;
}
// Build entry
let preset_entry = PresetEntry {
preset_name: name.to_string(),
plugin_kind,
plugin_identifier: plugin_identifier.clone(),
plugin: plugin.map(|p| p.common.core),
content_hash: Some(hasher.digest_128()),
};
Some(preset_entry)
});
Some(iter)
})
.flatten()
.collect();
Ok(())
}
fn query_filter_collections(
&self,
_: &ProviderContext,
input: InnerBuildInput,
_: EnumSet<PotFilterKind>,
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
let mut new_filters = *input.filter_input.filters;
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
let product_items = self
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
.filter_map(|(_, entry)| Some(entry.plugin.as_ref()?.product_id))
.unique()
.map(InnerFilterItem::Product)
.collect();
let mut collections = InnerFilterItemCollections::empty();
collections.set(PotFilterKind::Bank, product_items);
Ok(collections)
}
fn query_presets(
&self,
ctx: &ProviderContext,
input: InnerBuildInput,
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
let preset_ids = self
.query_presets_internal(&input.filter_input)
.filter(|(_, preset_entry)| {
let search_input = IniSearchInput { ctx, preset_entry };
input.search_evaluator.matches(search_input)
})
.map(|(i, entry)| SortablePresetId::new(i as _, entry.preset_name.clone()))
.collect();
Ok(preset_ids)
}
fn find_preset_by_id(
&self,
ctx: &ProviderContext,
preset_id: InnerPresetId,
) -> Option<PotPreset> {
let preset_entry = self.entries.get(preset_id.0 as usize)?;
let plugin = preset_entry
.plugin
.as_ref()
.and_then(|entry| ctx.plugin_db.find_plugin_by_id(&entry.id));
let preset = PotPreset {
common: PotPresetCommon {
persistent_id: PersistentPresetId::new(
self.persistent_id().clone(),
create_persistent_inner_id(preset_entry),
),
name: preset_entry.preset_name.clone(),
context_name: None,
plugin_ids: preset_entry
.plugin
.as_ref()
.map(|p| p.id)
.into_iter()
.collect(),
product_ids: preset_entry
.plugin
.as_ref()
.map(|p| p.product_id)
.into_iter()
.collect(),
product_name: Some(build_product_name(preset_entry, plugin).to_string()),
content_hash: preset_entry.content_hash,
db_specific_preview_file: None,
is_supported: true,
is_available: preset_entry.plugin.is_some(),
metadata: Default::default(),
},
kind: PotPresetKind::Internal(InternalPotPresetKind {
plugin_id: preset_entry.plugin.as_ref().map(|p| p.id),
}),
};
Some(preset)
}
}
/// Example: `vst3-Surge XT.ini|My Preset`
fn create_persistent_inner_id(preset_entry: &PresetEntry) -> PersistentInnerPresetId {
let plugin_kind = preset_entry.plugin_kind.as_ref();
let plugin_identifier = &preset_entry.plugin_identifier;
let escaped_preset_name = PipeEscaped(&preset_entry.preset_name);
let id = format!("{plugin_kind}-{plugin_identifier}.ini|{escaped_preset_name}");
PersistentInnerPresetId::new(id)
}
struct IniSearchInput<'a> {
ctx: &'a ProviderContext<'a>,
preset_entry: &'a PresetEntry,
}
impl SearchInput for IniSearchInput<'_> {
fn preset_name(&self) -> &str {
&self.preset_entry.preset_name
}
fn product_name(&self) -> Option<Cow<str>> {
let plugin = self
.preset_entry
.plugin
.as_ref()
.and_then(|entry| self.ctx.plugin_db.find_plugin_by_id(&entry.id));
Some(build_product_name(self.preset_entry, plugin))
}
fn file_extension(&self) -> Option<&str> {
None
}
}
fn build_product_name<'a>(preset_entry: &'a PresetEntry, plugin: Option<&Plugin>) -> Cow<'a, str> {
match plugin {
None => preset_entry.plugin_identifier.as_str().into(),
Some(p) => p.common.to_string().into(),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
pub mod defaults;
pub mod directory;
pub mod ini;
pub mod komplete;
pub mod projects;
@@ -0,0 +1,430 @@
use crate::provider_database::{
Database, InnerFilterItem, InnerFilterItemCollections, ProviderContext, SortablePresetId,
};
use crate::{
Fil, FilterInput, FilterItem, FilterItemId, InnerBuildInput, InnerPresetId,
PersistentDatabaseId, PersistentInnerPresetId, PersistentPresetId, PipeEscaped, PluginId,
PotPreset, PotPresetCommon, PotPresetKind, ProjectBasedPotPresetKind, ProjectId, SearchInput,
};
use std::borrow::Cow;
use crate::plugins::{PluginCore, PluginDatabase};
use base::hash_util::{
calculate_persistent_non_crypto_hash_one_shot, NonCryptoIndexMap, PersistentHash,
};
use either::Either;
use enumset::{enum_set, EnumSet};
use helgobox_api::persistence::PotFilterKind;
use itertools::Itertools;
use std::error::Error;
use std::ffi::OsStr;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::{fs, iter};
use walkdir::WalkDir;
pub struct ProjectDatabase {
persistent_id: PersistentDatabaseId,
root_dir: PathBuf,
name: String,
description: String,
projects: Vec<Proj>,
preset_entries: Vec<PresetEntry>,
}
pub struct ProjectDbConfig {
pub persistent_id: PersistentDatabaseId,
pub root_dir: PathBuf,
pub name: String,
}
impl ProjectDatabase {
pub fn open(config: ProjectDbConfig) -> Result<Self, Box<dyn Error>> {
if !config.root_dir.try_exists()? {
return Err("path to projects root directory doesn't exist".into());
}
let db = Self {
persistent_id: config.persistent_id,
name: config.name,
preset_entries: Default::default(),
description: format!("Projects in {}", config.root_dir.to_string_lossy()),
root_dir: config.root_dir,
projects: vec![],
};
Ok(db)
}
fn query_presets_internal<'a>(
&'a self,
filter_input: &'a FilterInput,
) -> impl Iterator<Item = (usize, &'a PresetEntry)> + 'a {
let matches = !filter_input.filters.wants_factory_presets_only();
if !matches {
return Either::Left(iter::empty());
}
let iter = self.preset_entries.iter().enumerate().filter(|(id, e)| {
if let Some(FilterItemId(Some(Fil::Project(id)))) =
filter_input.filters.get(PotFilterKind::Project)
{
if e.project_id != id {
return false;
}
}
let id = InnerPresetId(*id as _);
e.track_preset
.used_plugins
.values()
.any(|core| filter_input.everything_matches(Some(core), id))
});
Either::Right(iter)
}
}
struct PresetEntry {
project_id: ProjectId,
track_preset: TrackPreset,
}
struct Proj {
name: String,
relative_path_to_rpp: String,
}
impl Database for ProjectDatabase {
fn persistent_id(&self) -> &PersistentDatabaseId {
&self.persistent_id
}
fn name(&self) -> Cow<str> {
self.name.as_str().into()
}
fn description(&self) -> Cow<str> {
self.description.as_str().into()
}
fn supported_advanced_filter_kinds(&self) -> EnumSet<PotFilterKind> {
enum_set!(PotFilterKind::Bank | PotFilterKind::Project)
}
fn refresh(&mut self, ctx: &ProviderContext) -> Result<(), Box<dyn Error>> {
self.preset_entries = WalkDir::new(&self.root_dir)
.follow_links(true)
.into_iter()
.filter_map(|entry| {
let entry = entry.ok()?;
if !entry.file_type().is_file() {
return None;
}
let extension = entry.path().extension()?;
if extension != OsStr::new("RPP") {
return None;
}
let relative_path = entry.path().strip_prefix(&self.root_dir).ok()?;
let stem = entry.path().file_stem()?;
// Immediately exclude relative paths that can't be represented as valid UTF-8.
// Otherwise we will potentially open a can of worms (regarding persistence etc.).
let project = Proj {
name: stem.to_str()?.to_string(),
relative_path_to_rpp: relative_path.to_str()?.to_string(),
};
self.projects.push(project);
let project_id = ProjectId(self.projects.len() as u32 - 1);
process_file(entry.path(), ctx.plugin_db, project_id).ok()
})
.flatten()
.collect();
Ok(())
}
fn query_filter_collections(
&self,
_: &ProviderContext,
input: InnerBuildInput,
affected_kinds: EnumSet<PotFilterKind>,
) -> Result<InnerFilterItemCollections, Box<dyn Error>> {
let mut collections = InnerFilterItemCollections::empty();
if affected_kinds.contains(PotFilterKind::Project) {
let mut new_filters = *input.filter_input.filters;
new_filters.clear_this_and_dependent_filters(PotFilterKind::Project);
let project_items = self
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
.map(|(_, entry)| entry.project_id)
.unique()
.filter_map(|project_id| {
let project = self.projects.get(project_id.0 as usize)?;
let item = FilterItem {
persistent_id: "".to_string(),
id: FilterItemId(Some(Fil::Project(project_id))),
parent_name: None,
name: Some(project.name.clone()),
icon: None,
more_info: Some(project.relative_path_to_rpp.to_string()),
};
Some(InnerFilterItem::Unique(item))
})
.collect();
collections.set(PotFilterKind::Project, project_items);
}
if affected_kinds.contains(PotFilterKind::Bank) {
let mut new_filters = *input.filter_input.filters;
new_filters.clear_this_and_dependent_filters(PotFilterKind::Bank);
let product_items = self
.query_presets_internal(&input.filter_input.with_filters(&new_filters))
.flat_map(|(_, entry)| {
entry
.track_preset
.used_plugins
.values()
.map(|core| core.product_id)
})
.unique()
.map(InnerFilterItem::Product)
.collect();
collections.set(PotFilterKind::Bank, product_items);
}
Ok(collections)
}
fn query_presets(
&self,
ctx: &ProviderContext,
input: InnerBuildInput,
) -> Result<Vec<SortablePresetId>, Box<dyn Error>> {
let preset_ids = self
.query_presets_internal(&input.filter_input)
.filter(|(_, preset_entry)| {
let search_input = ProjectSearchInput { ctx, preset_entry };
input.search_evaluator.matches(search_input)
})
.map(|(i, entry)| SortablePresetId::new(i as _, entry.track_preset.preset_name.clone()))
.collect();
Ok(preset_ids)
}
fn find_preset_by_id(
&self,
ctx: &ProviderContext,
preset_id: InnerPresetId,
) -> Option<PotPreset> {
let preset_entry = self.preset_entries.get(preset_id.0 as usize)?;
let project = self.projects.get(preset_entry.project_id.0 as usize)?;
let relative_path = PathBuf::from(&project.relative_path_to_rpp);
let preset = PotPreset {
common: PotPresetCommon {
persistent_id: PersistentPresetId::new(
self.persistent_id().clone(),
create_persistent_inner_id(project, preset_entry),
),
name: preset_entry.track_preset.preset_name.clone(),
context_name: Some(project.name.clone()),
plugin_ids: preset_entry
.track_preset
.used_plugins
.values()
.map(|c| c.id)
.collect(),
product_ids: preset_entry
.track_preset
.used_plugins
.values()
.map(|c| c.product_id)
.collect(),
product_name: build_product_name(ctx, preset_entry).map(|n| n.to_string()),
content_hash: Some(preset_entry.track_preset.content_hash),
db_specific_preview_file: None,
is_supported: true,
is_available: !preset_entry.track_preset.used_plugins.is_empty(),
metadata: Default::default(),
},
kind: PotPresetKind::ProjectBased(ProjectBasedPotPresetKind {
path_to_rpp: self.root_dir.join(relative_path),
fx_chain_range: preset_entry.track_preset.fx_chain_range.clone(),
}),
};
Some(preset)
}
}
struct TrackPreset {
preset_name: String,
track_id: String,
fx_chain_range: Range<usize>,
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
content_hash: PersistentHash,
}
fn process_file(
path: &Path,
plugin_db: &PluginDatabase,
project_id: ProjectId,
) -> Result<Vec<PresetEntry>, Box<dyn Error>> {
let rppxml = fs::read_to_string(path)?;
Ok(extract_presets(&rppxml, plugin_db, project_id))
}
/// Example: `maojiao/2023-02-03-ben/2023-02-03-ben.RPP|0FF9F738-7CF6-8A49-9AEA-A9AF26DF9C46`
fn create_persistent_inner_id(
project: &Proj,
preset_entry: &PresetEntry,
) -> PersistentInnerPresetId {
let escaped_path = PipeEscaped(project.relative_path_to_rpp.as_str());
let id = format!("{escaped_path}|{}", preset_entry.track_preset.track_id);
PersistentInnerPresetId::new(id)
}
fn extract_presets(
rppxml: &str,
plugin_db: &PluginDatabase,
project_id: ProjectId,
) -> Vec<PresetEntry> {
use rppxml_parser::*;
let parser = OneShotParser::new(rppxml);
#[derive(Debug, Default)]
struct P<'a> {
track_id: &'a str,
name: Option<&'a str>,
rfx_chain_start: Option<usize>,
rfx_chain_end: Option<usize>,
used_plugins: NonCryptoIndexMap<PluginId, PluginCore>,
}
impl<'a> P<'a> {
pub fn new(track_id: &'a str) -> Self {
Self {
track_id,
name: None,
rfx_chain_start: None,
rfx_chain_end: None,
used_plugins: Default::default(),
}
}
}
let mut stack: Vec<&str> = Vec::with_capacity(10);
let mut preset: Option<P> = None;
let mut presets: Vec<P> = vec![];
for e in parser.events() {
let line = e.line();
match e.item {
Item::StartTag(el) => {
stack.push(el.name());
match *stack.as_slice() {
["REAPER_PROJECT", "TRACK"] => {
let track_id = el.into_values().next().unwrap_or_default();
preset = Some(P::new(track_id));
}
["REAPER_PROJECT", "TRACK", "FXCHAIN", _] => {
if let Some(p) = &mut preset {
if let Some(plugin) =
plugin_db.detect_plugin_from_rxml_line(line.trim())
{
p.used_plugins
.insert(plugin.common.core.id, plugin.common.core);
}
}
}
_ => {}
}
}
Item::EndTag => {
match *stack.as_slice() {
["REAPER_PROJECT", "TRACK"] => {
presets.extend(preset.take());
}
["REAPER_PROJECT", "TRACK", "FXCHAIN"] => {
if let Some(p) = &mut preset {
p.rfx_chain_end = Some(e.start);
}
}
_ => {}
}
stack.pop();
}
Item::Attribute(el) => match *stack.as_slice() {
["REAPER_PROJECT", "TRACK"] => match el.name() {
"NAME" => {
if let Some(p) = &mut preset {
let name = el.into_values().next().unwrap_or_default();
p.name = Some(name)
}
}
_ => {}
},
["REAPER_PROJECT", "TRACK", "FXCHAIN"] => match el.name() {
"BYPASS" => {
if let Some(p) = &mut preset {
if p.rfx_chain_start.is_none() {
p.rfx_chain_start = Some(e.start);
}
}
}
_ => {}
},
_ => {}
},
Item::Content(_) => {}
Item::Empty => {}
}
}
presets
.into_iter()
.filter_map(|p| {
if p.used_plugins.is_empty() {
return None;
}
let fx_chain_range = p.rfx_chain_start?..p.rfx_chain_end?;
let track_id = p.track_id.strip_prefix('{')?.strip_suffix('}')?.to_string();
let preset_name = p.name?.to_string();
let content_hash = calculate_persistent_non_crypto_hash_one_shot(
rppxml[fx_chain_range.clone()].as_bytes(),
);
let track_preset = TrackPreset {
preset_name,
track_id,
fx_chain_range,
used_plugins: p.used_plugins,
content_hash,
};
let preset_entry = PresetEntry {
project_id,
track_preset,
};
Some(preset_entry)
})
.collect()
}
struct ProjectSearchInput<'a> {
ctx: &'a ProviderContext<'a>,
preset_entry: &'a PresetEntry,
}
impl SearchInput for ProjectSearchInput<'_> {
fn preset_name(&self) -> &str {
&self.preset_entry.track_preset.preset_name
}
fn product_name(&self) -> Option<Cow<str>> {
build_product_name(self.ctx, self.preset_entry)
}
fn file_extension(&self) -> Option<&str> {
None
}
}
fn build_product_name(
ctx: &ProviderContext,
preset_entry: &PresetEntry,
) -> Option<Cow<'static, str>> {
if preset_entry.track_preset.used_plugins.len() > 1 {
Some("<Multiple>".into())
} else if let Some(first) = preset_entry.track_preset.used_plugins.values().next() {
ctx.plugin_db
.find_plugin_by_id(&first.id)
.map(|p| p.common.to_string().into())
} else {
None
}
}
+136
View File
@@ -0,0 +1,136 @@
use base::Global;
use futures::channel::oneshot;
use once_cell::sync::Lazy;
use std::any::Any;
use std::collections::VecDeque;
use std::error::Error;
use std::future::Future;
use tokio::runtime::Runtime;
pub type PotWorkerDispatcher<C> = WorkerDispatcher<C, PotWorkerSpawner>;
pub type MainThreadDispatcher<C> = WorkerDispatcher<C, MainThreadSpawner>;
type PotWorkerResult<R> = Result<R, Box<dyn Error>>;
/// Helper for easily dispatching background work from a non-asynchronous context and executing
/// code as soon as the result of the background work is available.
#[derive(Debug)]
pub struct WorkerDispatcher<C, S> {
tasks: VecDeque<Task<C, Box<dyn Any + Send + 'static>>>,
spawner: S,
}
impl<C, S> WorkerDispatcher<C, S>
where
S: Spawner,
{
pub fn new(spawner: S) -> Self {
Self {
tasks: Default::default(),
spawner,
}
}
/// Checks for each background task if a result is available and if yes, executes the
/// corresponding result handler.
///
/// Must be called repeatedly from the non-asynchronous code.
pub fn poll(&mut self, state: &mut C) {
// Take next task from queue. Do nothing if no task enqueued.
let Some(mut task) = self.tasks.pop_front() else {
return;
};
// Check if task has already produced some result.
match task.result_receiver.try_recv() {
// Result available. Handle it, done.
Ok(Some(result)) => {
(task.result_handler)(state, result);
}
// Result not yet available. Push task back on queue.
Ok(None) => {
self.tasks.push_back(task);
}
// Sender dropped. Discard task.
Err(_) => {}
}
}
/// Schedules the given work for execution by the Pot worker and registers a handler that
/// will be executed as soon as the work has produced a result.
pub fn do_in_background_and_then<R>(
&mut self,
work: impl Future<Output = R> + Send + 'static,
result_handler: impl FnOnce(&mut C, R) + Send + 'static,
) where
R: Send + 'static,
{
// Create one-shot channel for transferring task result from background thread to
// polling thread.
let (sender, receiver) = oneshot::channel::<Box<dyn Any + Send + 'static>>();
// Combine this and the corresponding handler into a so-called task.
let task = Task {
result_receiver: receiver,
result_handler: Box::new(|context, result| {
if let Ok(result) = result.downcast::<R>() {
result_handler(context, *result);
}
}),
};
// Enqueue that task for repeated polling.
self.tasks.push_back(task);
// Schedule work to be done in background.
self.spawner.spawn(async move {
// Start executing work
let result = work.await;
// Work done. Send result.
let _ = sender.send(Box::new(result));
Ok(())
});
}
}
pub fn spawn_in_pot_worker(f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
POT_WORKER_RUNTIME.as_ref().unwrap().spawn(async {
f.await.unwrap();
});
}
pub trait Spawner {
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static);
}
#[derive(Debug)]
pub struct PotWorkerSpawner;
impl Spawner for PotWorkerSpawner {
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
spawn_in_pot_worker(f);
}
}
#[derive(Debug)]
pub struct MainThreadSpawner;
impl Spawner for MainThreadSpawner {
fn spawn(&self, f: impl Future<Output = PotWorkerResult<()>> + Send + 'static) {
Global::future_support().spawn_in_main_thread_from_main_thread(f);
}
}
#[derive(derivative::Derivative)]
#[derivative(Debug)]
struct Task<C, R> {
result_receiver: oneshot::Receiver<R>,
#[derivative(Debug = "ignore")]
result_handler: ResultHandler<C, R>,
}
type ResultHandler<C, R> = Box<dyn FnOnce(&mut C, R) + Send>;
static POT_WORKER_RUNTIME: Lazy<std::io::Result<Runtime>> = Lazy::new(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_time()
.thread_name("Helgobox Pot Worker")
.worker_threads(1)
.build()
});