From d67529be1b6a326692c2750348a2aebdf5173757 Mon Sep 17 00:00:00 2001 From: Weird Constructor Date: Tue, 19 Jul 2022 05:54:26 +0200 Subject: [PATCH] added documentation for writing nodes for HexoDSP --- src/dsp/helpers.rs | 10 -- src/dsp/mod.rs | 325 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 324 insertions(+), 11 deletions(-) diff --git a/src/dsp/helpers.rs b/src/dsp/helpers.rs index 5020fc7..3fa7c27 100644 --- a/src/dsp/helpers.rs +++ b/src/dsp/helpers.rs @@ -2181,16 +2181,6 @@ impl TriSawLFO { self.phase * self.fall_r - self.fall_r }; - if s.abs() > f(1.0) { - println!( - "RECALC TRISAW: rev={}, rise={}, fall={}, phase={}", - self.rev.to_f64().unwrap_or(0.0), - self.rise_r.to_f64().unwrap_or(0.0) as f32, - self.fall_r.to_f64().unwrap_or(0.0) as f32, - self.phase.to_f64().unwrap_or(0.0) as f32 - ); - } - self.phase = self.phase + self.freq * self.israte; s diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index e726073..c5b33c6 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -1,7 +1,330 @@ -// Copyright (c) 2021 Weird Constructor +// Copyright (c) 2021-2022 Weird Constructor // This file is a part of HexoDSP. Released under GPL-3.0-or-later. // See README.md and COPYING for details. +/*! + +# HexoDSP DSP nodes and DSP code + +## How to Add a New DSP Node + +When adding a new node to HexoDSP, I recommend working through the following checklist: + +- [ ] Implement boilerplate +- [ ] Document boilerplate +- [ ] DSP implementation +- [ ] Parameter fine tuning +- [ ] DSP tests for all params +- [ ] Ensure Documentation is properly formatted for the GUI +- [ ] Add CHANGELOG.md entry in HexoSynth +- [ ] Add CHANGELOG.md entry in HexoDSP +- [ ] Add table entry in README.md in HexoSynth +- [ ] Add table entry in README.md in HexoDSP + +Here are some hints to get you started: + +### Boilerplate + +- I recommend copying an existing node code, like `node_ad.rs` for instance. +- In this file `mod.rs` copy it's entry in the `node_list` macro definition. +- Copy the `tests/node_ad.rs` file to have a starting point for the automated testing. +Also keep in mind looking in other tests, about how they test things. Commonly used +macros are found in the ´tests/common/´ module. + +A `node_list` macro entry looks like this: + +```ignore + // node_id => node_label UIType UICategory + // | | / | + // / /------/ / | + // / | / | + xxx => Xxx UIType::Generic UICategory::Signal + // node_param_idx + // name denorm round format steps norm norm denorm + // norm_fun fun fun fun def min max default + (0 inp n_id d_id r_id f_def stp_d -1.0, 1.0, 0.0) + (1 gain n_gain d_gain r_id f_def stp_d 0.0, 1.0, 1.0) + (2 att n_att d_att r_id f_def stp_d 0.0, 1.0, 1.0) + // node_param_idx UI widget type (mode, knob, sample) + // | atom_idx | format fun + // | | name constructor| | min max + // | | | | def|ult_v|lue | / + // | | | | | | | | | + {3 0 mono setting(0) mode fa_out_mono 0 1}, + [0 sig], +``` + +The first entries, encapsulated in ´( )´ are the input ports or also called input parameters. +Input parameters can be connected to outputs of other DSP nodes. In contrast to the ´{ }´ encapsulated +so called _Atom parameters_. The data type for these is the [SAtom] datatype. And these parameters +can not be automated. + +### Node Documentation + +Every DSP node must come with online documentation. The online documentation is what makes the +node usable in the first place. It's the only hint for the user to learn how to use this node. +Keep in mind that the user is not an engineer, but usually a musician. They want to make music +and know how to use a parameter. + +Every input parameter and atom parameter requires you to define a documenation entry in the +corresponding ´node_*.rs´ module/file. And also a _DESC_ and _HELP_ entry. + +Here an example from ´node_ad.rs´: + +```ignore + pub const inp: &'static str = + "Ad inp\nSignal input. If you don't connect this, and set this to 1.0 \ + this will act as envelope signal generator. But you can also just \ + route a signal directly through this of course.\nRange: (-1..1)\n"; +``` + +The general format of the parameter documentation should be: + +```ignore + " \n + A short description what this paramter means/does and relates to others.\n + Range: \n" +``` + +Keep the description of the paramter short and concise. Look at the space available +in HexoSynth where this is displayed. If you want to write more elaborate documentation +for a paramter, write it in the `HELP` entry. + +The _range_ relates to the DSP signal range this paramter is supposed to receive. +This should either be the unipolar range (0..1) or the bipolar range (-1..1). Other +ranges should be avoided, because everything in HexoDSP is supposed to be fine with +receiving values in those ranges. + +Next you need to document the node itself, how it works what it does and so on... +For this there are two entries: + +```ignore + pub const DESC: &'static str = r#"Attack-Decay Envelope + + This is a simple envelope offering an attack time and decay time with a shape parameter. + You can use it as envelope generator to modulate other inputs or process a signal with it directly. + "#; + + pub const HELP: &'static str = r#"Ad - Attack-Decay Envelope + + This simple two stage envelope with attack and decay offers shape parameters + ... + "#; +``` + +_DESC_ should only contain a short description of the node. It's space is as limited as the +space for the parameter description. It will be autowrapped. + +_HELP_ can be a multiple pages long detailed description of the node. Keep the +width of the lines below a certain limit (below 80 usually). Or else it will be +truncated in the help text window in HexoSynth. As inspiration what should be in +the help documentation: + +- What the node does (even if it repeats mostly what _DESC_ already says) +- How the input paramters relate to each other. +- What the different atom settings (if any) mean. +- Which other DSP nodes this node is commonly combined with. +- Interesting or even uncommon uses of this DSP node. +- Try to inspire the user to experiment. + +### Node Code Structure + +For non trivial DSP nodes, the DSP code itself should be separate from it's `dsp/node_*.rs` +file. That file should only care about interfacing the DSP code with HexoDSP, but not implement +all the complicated DSP code. It's good practice to factor out the DSP code into +a separate module or file. + +Look at `node_tslfo.rs` for instance. It wires up the `TriSawLFO` from `dsp/helpers.rs` +to the HexoDSP node interface. + +```ignore + // node_tslfo.rs + use super::helpers::{TriSawLFO, Trigger}; + + #[derive(Debug, Clone)] + pub struct TsLFO { + lfo: Box>, + trig: Trigger, + } + + // ... + impl DspNode for TsLFO { + // ... + #[inline] + fn process(&mut self, /* ... */) { + // ... + let lfo = &mut *self.lfo; + + for frame in 0..ctx.nframes() { + // ... + out.write(frame, lfo.next_unipolar() as f32); + } + + // ... + } + } +``` + +The code for `TriSawLFO` in `dsp/helpers.rs` is then independent and reusable else where. + +### Node Beautification + +To make nodes responsive in HexoSynth the `DspNode::process` function receives the [LedPhaseVals]. +These should be written after the inner loop that calculates the samples. The first context value +is the _LED value_, it should be in the range between -1 and 1. The most easy way to set it is +by using the last written sample from your loop: + +```ignore + ctx_vals[0].set(out.read(ctx.nframes() - 1)); +``` + +But consider giving it a more meaningful value if possible. The `node_ad.rs` sets the LED value +to the internal phase value of the envelope instead of it's output. + +The second value in [LedPhaseVals] is the _Phase value_. It usually has special meaning for the +node specific visualizations. Such as TSeq emits the position of the playhead for instance. +The CQnt quantizer emits the most recently activated key to the GUI using the Phase value. + +Consider also providing a visualization graph if possible. You can look eg at `node_ad.rs` +or `node_tslfo.rs` or many others how to provide a visualization graph function: + +```ignore + impl DspNode for TsLFO { + fn graph_fun() -> Option { + Some(Box::new(|gd: &dyn GraphAtomData, _init: bool, x: f32, xn: f32| -> f32 { + // ... + })) + } + } +``` + +Let me explain the callback function parameters quickly: + +- `gd: &dyn GraphAtomData` this trait object gives you access to the input paramters of +this node. And also the LED and Phase values. +- `init: bool` allows you to detect when the first sample of the graph is being drawn/requested. +You can use this to reset any state that is carried with the callback. +- `x: f32` is the current X position of the graph. Use this to derive the Y value which +must be returned from the callback. +- `xn: f32` is the next value for X. This is useful for determining if your function might +reaches a min or max between _x_ and _xn_, so you could for instance return the min or max +value now instead of the next sample. + +### Automated Testing of Your Node + +The start of your `tests/node_*.rs` file usually should look like this: + +```ignore + mod common; + use common::*; + + #[test] + fn check_node_ad_1() { + let (node_conf, mut node_exec) = new_node_engine(); + let mut matrix = Matrix::new(node_conf, 3, 3); + + let ad = NodeId::Ad(0); + let out = NodeId::Out(0); + matrix.place(0, 0, Cell::empty(ad).out(None, None, ad.out("sig"))); + matrix.place(0, 1, Cell::empty(out).input(out.inp("ch1"), None, None)); + matrix.sync().unwrap(); + + // ... + } +``` + +Lets dissect this a bit. The beginning of each test case should setup an instance of the DSP engine +of HexoDSP using [crate::new_node_engine]. It returns a [crate::NodeConfigurator] and a [crate::NodeExecutor]. +The first is responsible for setting up the DSP graph and modifying it at runtime. +The latter ([crate::NodeExecutor]) is responsible for executing the DSP graph and generate output samples. + +```ignore + let (node_conf, mut node_exec) = new_node_engine(); +``` + +The [crate::Matrix] abstraction encapsulates the configurator and provides you an interface +to layout the nodes in a hexagonal grid. It is currently the easiest API for using HexoDSP. +The two parameters to _new_ are the width and height of the hex grid. + +```ignore + let mut matrix = Matrix::new(node_conf, 3, 3); +``` + +Next you usually want to define short variable names for the [NodeId] that refer to the DSP +node instances: + +```ignore + let ad = NodeId::Ad(0); + let out = NodeId::Out(0); +``` + +You can have multiple instances for a node. The number in the parenthesis are +the instance index of that node. + +Next you want to layout the nodes adjacent to each other on the hexagonal grid. +This is a bit more tricky than with a rectangular grid. + +```ignore + matrix.place(0, 0, Cell::empty(ad).out(None, None, ad.out("sig"))); + matrix.place(0, 1, Cell::empty(out).input(out.inp("ch1"), None, None)); + matrix.sync().unwrap(); +``` + +The `sync` is necessary to update the DSP graph. +When doing this, keep the following grid layout in mind: + +```text + _____ _____ + / \ / \ + / 0,0 \_____/ 2,0 \_____ + \ / \ / \ + \_____/ 1,0 \_____/ 3,0 \ + / \ / \ / + / 0,1 \_____/ 2,1 \_____/ + \ / \ / \ + \_____/ 1,1 \_____/ 3,1 \ + / \ / \ / + / 0,2 \_____/ 2,2 \_____/ + \ / \ / + \_____/ 1,2 \_____/ + \ / + \_____/ +``` + +After you have setup everything for the test, you usually want to modify a paramter +and look at the values the graph returns. + +```ignore + #[test] + fn check_node_ad_1() { + // matrix setup code above + + // Fetch parameter id: + let trig_p = ad.inp_param("trig").unwrap(); + + // Set parameter: + matrix.set_param(trig_p, SAtom::param(1.0)); + + /// Run the DSP graph for 25 milliseconds of audio. + let res = run_for_ms(&mut node_exec, 25.0); + + // `res` now contains two vectors. one for first channel "ch1" + // and one for the second channel "ch2". + assert_decimated_slope_feq!( + res.0, + // .... + ) + } +``` + +***Attention: This is important to keep in mind:*** After using `matrix.set_param(...)` to +set a paramter, keep in mind that the parameter values will be smoothed. That means it will +take a few milliseconds until `trig_p` reaches the 1.0. In case of the Ad node that means +the trigger threshold won't be triggered at the first sample, but a few milliseconds +later! + +*/ + #[allow(non_upper_case_globals)] mod node_ad; #[allow(non_upper_case_globals)]