Hi! I've recently been working on a modular audio synthesizer in my spare time, and it has been truly incredible to work with rust's compiler! I've put all the source code on github: GitHub - smj-edison/synth: Yet another synth written in rust.
I've run into some limitations with my current design, however. From the get-go, I decided not to use the typical digital synthesizer design. That is, using a node network that has pointers all over the memory and is hard to follow. I instead opted to use a more top-down approach. I figured this would keep the RAM from being less fragmented (for caching), as well as allow me to use a lot more compile-time checks.
For example, instead of doing (node network approach):
let osc = OscillatorNode::new();
let gain = GainNode::new();
osc.connect(gain);
gain.connect(audio_out);
fn process_audio() -> f32 {
gain.receive()
}
I'm designing it more like (top-down approach):
let osc = OscillatorNode::new();
let gain = GainNode::new();
fn process_audio() -> f32 {
osc.process();
gain.receive(osc);
gain.get_out()
}
Another thing that I wanted to be able to do was to have common IO between nodes. This led to a design like this (from engine/src/node.rs):
pub trait AudioNode {
fn process(&mut self);
fn receive_audio(&mut self, input_type: InputType, input: f32) -> Result<(), SimpleError>;
fn get_output_audio(&self, output_type: OutputType) -> Result<f32, SimpleError>;
}
This design was working pretty well until I started setting up "pipelines," a function where sound is inputted, travels through a series of nodes, and spits out the processed audio. It ended up becoming somewhat unwieldy, looking like this (in src/pipeline/midi_oscillator.rs):
fn process(&mut self) {
// -- snip --
self.osc.process();
self.envelope.receive_audio(InputType::Gate, if self.notes_on > 0 {1.0} else {0.0}).unwrap();
self.envelope.process();
self.gain.receive_audio(InputType::In, self.osc.get_output_audio(OutputType::Out).unwrap()).unwrap();
self.gain.set_gain(self.envelope.get_output_audio(OutputType::Out).unwrap());
self.gain.process();
self.output_out = self.gain.get_output_audio(OutputType::Out).unwrap();
}
Another thing that is annoying is that I have three or four different node types that accept in audio from one channel (InputType::In
), but for each one I have to write
fn receive_audio(&mut self, input_type: InputType, input: f32) -> Result<(), SimpleError> {
match input_type {
InputType::In => self.input_in = input,
_ => bail!("Cannot receive {:?}", input_type),
}
Ok(())
}
I'm sure there's a lot of other places in my code where I'm not using the most ergonomic code, those were the biggest ones I could think of. It would be great if I could get advice on big structural things, as well as if I'm doing anything that could be done in a more "rusty" way.
I'm happy to do any refactors! I figure now's the best time before my codebase gets any bigger
Thank you in advance!