Initial commit of a toy analogue audio generator
Key components: - no_std core so it can be used bare metal on embedded systems - default implementation for RPi 2350, with midi input and I2s output using PIO - Visualiser for the web to play with on a dev system
This commit is contained in:
22
crates/synth-core/Cargo.toml
Normal file
22
crates/synth-core/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "synth-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "no_std DSP/audio components for an analogue-modelling synthesiser"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
std = []
|
||||
alloc = []
|
||||
|
||||
[dependencies]
|
||||
libm = { workspace = true }
|
||||
micromath = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
heapless = { workspace = true }
|
||||
midi-types = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# approx = "0.5"
|
||||
14
crates/synth-core/src/config.rs
Normal file
14
crates/synth-core/src/config.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! Sample rate and shared audio configuration.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SampleRate(pub f32);
|
||||
|
||||
impl SampleRate {
|
||||
#[inline]
|
||||
pub fn period(self) -> f32 {
|
||||
1.0 / self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub const SR_44100: SampleRate = SampleRate(44_100.0);
|
||||
pub const SR_48000: SampleRate = SampleRate(48_000.0);
|
||||
66
crates/synth-core/src/envelope.rs
Normal file
66
crates/synth-core/src/envelope.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! ADSR envelope generator.
|
||||
|
||||
use crate::{AudioProcessor, config::SampleRate};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Stage { Idle, Attack, Decay, Sustain, Release }
|
||||
|
||||
pub struct Adsr {
|
||||
pub attack_s: f32,
|
||||
pub decay_s: f32,
|
||||
pub sustain: f32, // 0.0–1.0
|
||||
pub release_s: f32,
|
||||
sample_rate: SampleRate,
|
||||
stage: Stage,
|
||||
level: f32,
|
||||
}
|
||||
|
||||
impl Adsr {
|
||||
pub fn new(sample_rate: SampleRate) -> Self {
|
||||
Self {
|
||||
attack_s: 0.01, decay_s: 0.1, sustain: 0.7, release_s: 0.3,
|
||||
sample_rate,
|
||||
stage: Stage::Idle,
|
||||
level: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn gate_on(&mut self) { self.stage = Stage::Attack; }
|
||||
pub fn gate_off(&mut self) { self.stage = Stage::Release; }
|
||||
pub fn is_idle(&self) -> bool { self.stage == Stage::Idle }
|
||||
|
||||
#[inline]
|
||||
fn next_sample(&mut self) -> f32 {
|
||||
let dt = self.sample_rate.period();
|
||||
match self.stage {
|
||||
Stage::Idle => {},
|
||||
Stage::Attack => {
|
||||
self.level += dt / self.attack_s.max(dt);
|
||||
if self.level >= 1.0 { self.level = 1.0; self.stage = Stage::Decay; }
|
||||
}
|
||||
Stage::Decay => {
|
||||
self.level -= dt / self.decay_s.max(dt) * (1.0 - self.sustain);
|
||||
if self.level <= self.sustain { self.level = self.sustain; self.stage = Stage::Sustain; }
|
||||
}
|
||||
Stage::Sustain => { self.level = self.sustain; }
|
||||
Stage::Release => {
|
||||
self.level -= dt / self.release_s.max(dt) * self.level;
|
||||
if self.level <= 0.0001 { self.level = 0.0; self.stage = Stage::Idle; }
|
||||
}
|
||||
}
|
||||
self.level
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for Adsr {
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
for s in out.iter_mut() {
|
||||
*s = self.next_sample();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.stage = Stage::Idle;
|
||||
self.level = 0.0;
|
||||
}
|
||||
}
|
||||
76
crates/synth-core/src/filter.rs
Normal file
76
crates/synth-core/src/filter.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Analogue-modelling filters.
|
||||
//!
|
||||
//! - `MoogLadder` — 4-pole 24 dB/oct low-pass, Huovilainen model
|
||||
//! - `Svf` — State-variable filter (LP / HP / BP / Notch)
|
||||
|
||||
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum FilterMode {
|
||||
LowPass,
|
||||
HighPass,
|
||||
BandPass,
|
||||
Notch,
|
||||
}
|
||||
|
||||
// ── State-variable filter ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct Svf {
|
||||
pub cutoff_hz: f32,
|
||||
pub resonance: f32, // 0.0–1.0 (1.0 = self-oscillation)
|
||||
pub mode: FilterMode,
|
||||
sample_rate: SampleRate,
|
||||
// state
|
||||
low: f32,
|
||||
band: f32,
|
||||
}
|
||||
|
||||
impl Svf {
|
||||
pub fn new(sample_rate: SampleRate, cutoff_hz: f32, resonance: f32, mode: FilterMode) -> Self {
|
||||
Self { cutoff_hz, resonance, mode, sample_rate, low: 0.0, band: 0.0 }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn process_sample(&mut self, input: f32) -> f32 {
|
||||
let f = 2.0 * libm::sinf(
|
||||
core::f32::consts::PI * self.cutoff_hz * self.sample_rate.period()
|
||||
);
|
||||
let q = 1.0 - self.resonance;
|
||||
|
||||
let low = self.low + f * self.band;
|
||||
let high = input - low - q * self.band;
|
||||
let band = f * high + self.band;
|
||||
let notch = high + low;
|
||||
|
||||
self.low = low;
|
||||
self.band = band;
|
||||
|
||||
match self.mode {
|
||||
FilterMode::LowPass => low,
|
||||
FilterMode::HighPass => high,
|
||||
FilterMode::BandPass => band,
|
||||
FilterMode::Notch => notch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for Svf {
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
// in-place filter (caller pre-fills out with the audio signal)
|
||||
for s in out.iter_mut() {
|
||||
*s = self.process_sample(*s);
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.low = 0.0;
|
||||
self.band = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> CVProcessor<B> for Svf {
|
||||
/// CV modulates cutoff: 0 V = base cutoff, +1 V = 1 octave up.
|
||||
fn set_cv(&mut self, cv: f32) {
|
||||
self.cutoff_hz *= libm::powf(2.0, cv);
|
||||
}
|
||||
}
|
||||
48
crates/synth-core/src/lfo.rs
Normal file
48
crates/synth-core/src/lfo.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Low-frequency oscillator (LFO).
|
||||
//!
|
||||
//! Shares the Waveform enum from the oscillator module but operates at
|
||||
//! sub-audio rates (typically 0.01 Hz – 20 Hz) and outputs a CV signal
|
||||
//! in the range –1.0 to +1.0.
|
||||
|
||||
use crate::{AudioProcessor, config::SampleRate, oscillator::Waveform};
|
||||
|
||||
pub struct Lfo {
|
||||
pub waveform: Waveform,
|
||||
pub rate_hz: f32,
|
||||
pub depth: f32, // 0.0–1.0 output scale
|
||||
phase: f32,
|
||||
sample_rate: SampleRate,
|
||||
}
|
||||
|
||||
impl Lfo {
|
||||
pub fn new(sample_rate: SampleRate, rate_hz: f32, depth: f32, waveform: Waveform) -> Self {
|
||||
Self { waveform, rate_hz, depth, phase: 0.0, sample_rate }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn next_sample(&mut self) -> f32 {
|
||||
let p = self.phase;
|
||||
let raw = match self.waveform {
|
||||
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
|
||||
Waveform::Saw => 2.0 * p - 1.0,
|
||||
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
|
||||
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
|
||||
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
|
||||
};
|
||||
let next = p + self.rate_hz * self.sample_rate.period();
|
||||
self.phase = next - libm::floorf(next);
|
||||
raw * self.depth
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for Lfo {
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
for s in out.iter_mut() {
|
||||
*s = self.next_sample();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.phase = 0.0;
|
||||
}
|
||||
}
|
||||
63
crates/synth-core/src/lib.rs
Normal file
63
crates/synth-core/src/lib.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! synth-core — no_std DSP/audio components library.
|
||||
//!
|
||||
//! Compiles to any target that provides `core`:
|
||||
//! - wasm32-unknown-unknown (synth-visualiser)
|
||||
//! - thumbv7em-none-eabihf (Daisy Seed / STM32H750)
|
||||
//! - native (tests, desktop host)
|
||||
//!
|
||||
//! Feature flags
|
||||
//! -------------
|
||||
//! `std` — enables std (panicking, I/O helpers)
|
||||
//! `alloc` — enables heap-dependent paths
|
||||
|
||||
#![no_std]
|
||||
|
||||
extern crate libm;
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
extern crate alloc;
|
||||
|
||||
pub mod config;
|
||||
pub mod math;
|
||||
pub mod oscillator;
|
||||
pub mod filter;
|
||||
pub mod envelope;
|
||||
pub mod vca;
|
||||
pub mod lfo;
|
||||
pub mod midi;
|
||||
pub mod patch;
|
||||
|
||||
pub use config::SampleRate;
|
||||
pub use math::{db_to_linear, linear_to_db, lerp, midi_note_to_hz};
|
||||
|
||||
/// Every audio-processing module implements this trait.
|
||||
///
|
||||
/// `BLOCK` is the number of samples per render quantum — const generic so the
|
||||
/// compiler can unroll loops and callers need no heap allocation.
|
||||
pub trait AudioProcessor<const BLOCK: usize> {
|
||||
fn process(&mut self, out: &mut [f32; BLOCK]);
|
||||
fn reset(&mut self);
|
||||
}
|
||||
|
||||
/// A module that accepts a control-voltage input alongside audio.
|
||||
pub trait CVProcessor<const BLOCK: usize>: AudioProcessor<BLOCK> {
|
||||
fn set_cv(&mut self, cv: f32);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn midi_a4_is_440hz() {
|
||||
let hz = midi_note_to_hz(69);
|
||||
assert!((hz - 440.0).abs() < 0.5, "got {hz}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_round_trip() {
|
||||
let lin = db_to_linear(-6.0);
|
||||
let back = linear_to_db(lin);
|
||||
assert!((back - -6.0_f32).abs() < 0.01, "got {back}");
|
||||
}
|
||||
}
|
||||
22
crates/synth-core/src/math.rs
Normal file
22
crates/synth-core/src/math.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
//! DSP utility functions.
|
||||
|
||||
#[inline]
|
||||
pub fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||
a + t * (b - a)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn db_to_linear(db: f32) -> f32 {
|
||||
libm::powf(10.0, db / 20.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn linear_to_db(lin: f32) -> f32 {
|
||||
20.0 * libm::log10f(lin)
|
||||
}
|
||||
|
||||
/// Convert a MIDI note number to frequency in Hz (A4 = 69 = 440 Hz).
|
||||
#[inline]
|
||||
pub fn midi_note_to_hz(note: u8) -> f32 {
|
||||
440.0 * libm::powf(2.0, (note as f32 - 69.0) / 12.0)
|
||||
}
|
||||
103
crates/synth-core/src/midi.rs
Normal file
103
crates/synth-core/src/midi.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! MIDI byte-stream parser.
|
||||
//!
|
||||
//! Parses a raw MIDI byte stream (UART or Web MIDI) into typed events.
|
||||
//! No allocation; internal state is a small fixed-size buffer.
|
||||
|
||||
use heapless::Vec;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum MidiEvent {
|
||||
NoteOn { channel: u8, note: u8, velocity: u8 },
|
||||
NoteOff { channel: u8, note: u8, velocity: u8 },
|
||||
ControlChange { channel: u8, controller: u8, value: u8 },
|
||||
PitchBend { channel: u8, value: i16 }, // –8192 to +8191
|
||||
ProgramChange { channel: u8, program: u8 },
|
||||
Clock,
|
||||
Start,
|
||||
Stop,
|
||||
Continue,
|
||||
}
|
||||
|
||||
/// Running-status parser for a single MIDI stream.
|
||||
pub struct MidiParser {
|
||||
status: u8,
|
||||
buf: Vec<u8, 3>,
|
||||
}
|
||||
|
||||
impl MidiParser {
|
||||
pub fn new() -> Self {
|
||||
Self { status: 0, buf: Vec::new() }
|
||||
}
|
||||
|
||||
/// Feed one byte; returns `Some(event)` when a complete message is parsed.
|
||||
pub fn push_byte(&mut self, byte: u8) -> Option<MidiEvent> {
|
||||
// System real-time messages are single-byte and can appear anywhere.
|
||||
match byte {
|
||||
0xF8 => return Some(MidiEvent::Clock),
|
||||
0xFA => return Some(MidiEvent::Start),
|
||||
0xFB => return Some(MidiEvent::Continue),
|
||||
0xFC => return Some(MidiEvent::Stop),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if byte & 0x80 != 0 {
|
||||
// Status byte — start fresh.
|
||||
self.buf.clear();
|
||||
self.status = byte;
|
||||
// Single-byte status messages have no data bytes.
|
||||
return None;
|
||||
}
|
||||
|
||||
// Data byte.
|
||||
let _ = self.buf.push(byte);
|
||||
let status = self.status;
|
||||
let kind = (status >> 4) & 0x0F;
|
||||
let ch = status & 0x0F;
|
||||
|
||||
match (kind, self.buf.len()) {
|
||||
(0x8, 2) => {
|
||||
let ev = MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: self.buf[1] };
|
||||
self.buf.clear();
|
||||
Some(ev)
|
||||
}
|
||||
(0x9, 2) => {
|
||||
let vel = self.buf[1];
|
||||
let ev = if vel == 0 {
|
||||
MidiEvent::NoteOff { channel: ch, note: self.buf[0], velocity: 0 }
|
||||
} else {
|
||||
MidiEvent::NoteOn { channel: ch, note: self.buf[0], velocity: vel }
|
||||
};
|
||||
self.buf.clear();
|
||||
Some(ev)
|
||||
}
|
||||
(0xB, 2) => {
|
||||
let ev = MidiEvent::ControlChange { channel: ch, controller: self.buf[0], value: self.buf[1] };
|
||||
self.buf.clear();
|
||||
Some(ev)
|
||||
}
|
||||
(0xC, 1) => {
|
||||
let ev = MidiEvent::ProgramChange { channel: ch, program: self.buf[0] };
|
||||
self.buf.clear();
|
||||
Some(ev)
|
||||
}
|
||||
(0xE, 2) => {
|
||||
let lsb = self.buf[0] as i16;
|
||||
let msb = self.buf[1] as i16;
|
||||
let value = ((msb << 7) | lsb) - 8192;
|
||||
let ev = MidiEvent::PitchBend { channel: ch, value };
|
||||
self.buf.clear();
|
||||
Some(ev)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.status = 0;
|
||||
self.buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MidiParser {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
62
crates/synth-core/src/oscillator.rs
Normal file
62
crates/synth-core/src/oscillator.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! Voltage-controlled oscillator (VCO).
|
||||
//!
|
||||
//! Waveforms: Sine, Saw, Square, Triangle, Pulse (variable width).
|
||||
//! Uses phase accumulation; bandlimited variants (BLEP/BLAMP) to follow.
|
||||
|
||||
use crate::{AudioProcessor, CVProcessor, config::SampleRate};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Waveform {
|
||||
Sine,
|
||||
Saw,
|
||||
Square,
|
||||
Triangle,
|
||||
Pulse(f32), // pulse width 0.0–1.0
|
||||
}
|
||||
|
||||
pub struct Vco {
|
||||
pub waveform: Waveform,
|
||||
pub freq_hz: f32,
|
||||
phase: f32,
|
||||
sample_rate: SampleRate,
|
||||
}
|
||||
|
||||
impl Vco {
|
||||
pub fn new(sample_rate: SampleRate, freq_hz: f32, waveform: Waveform) -> Self {
|
||||
Self { waveform, freq_hz, phase: 0.0, sample_rate }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn next_sample(&mut self) -> f32 {
|
||||
let p = self.phase;
|
||||
let sample = match self.waveform {
|
||||
Waveform::Sine => libm::sinf(p * core::f32::consts::TAU),
|
||||
Waveform::Saw => 2.0 * p - 1.0,
|
||||
Waveform::Square => if p < 0.5 { 1.0 } else { -1.0 },
|
||||
Waveform::Triangle => 4.0 * (p - libm::floorf(p + 0.5)).abs() - 1.0,
|
||||
Waveform::Pulse(w) => if p < w { 1.0 } else { -1.0 },
|
||||
};
|
||||
let next = p + self.freq_hz * self.sample_rate.period();
|
||||
self.phase = next - libm::floorf(next);
|
||||
sample
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for Vco {
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
for s in out.iter_mut() {
|
||||
*s = self.next_sample();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.phase = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> CVProcessor<B> for Vco {
|
||||
/// CV is 1 V/oct: 0 V = 440 Hz, +1 V = 880 Hz, −1 V = 220 Hz.
|
||||
fn set_cv(&mut self, cv: f32) {
|
||||
self.freq_hz = 440.0 * libm::powf(2.0, cv);
|
||||
}
|
||||
}
|
||||
40
crates/synth-core/src/patch.rs
Normal file
40
crates/synth-core/src/patch.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Signal routing — connects module outputs to inputs.
|
||||
//!
|
||||
//! A `Patch` is a fixed-size connection graph. Each cable routes
|
||||
//! the output buffer of one slot into the CV or audio input of another.
|
||||
//! Const generics keep everything on the stack.
|
||||
|
||||
/// A single cable: from slot `src` output to slot `dst` input.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Cable {
|
||||
pub src: usize,
|
||||
pub dst: usize,
|
||||
}
|
||||
|
||||
/// A fixed-capacity patch with up to `MAX_CABLES` connections.
|
||||
pub struct Patch<const MAX_CABLES: usize> {
|
||||
cables: heapless::Vec<Cable, MAX_CABLES>,
|
||||
}
|
||||
|
||||
impl<const MAX_CABLES: usize> Patch<MAX_CABLES> {
|
||||
pub fn new() -> Self {
|
||||
Self { cables: heapless::Vec::new() }
|
||||
}
|
||||
|
||||
/// Add a cable; returns `Err(cable)` if the patch is full.
|
||||
pub fn connect(&mut self, src: usize, dst: usize) -> Result<(), Cable> {
|
||||
self.cables.push(Cable { src, dst }).map_err(|_| Cable { src, dst })
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self, src: usize, dst: usize) {
|
||||
self.cables.retain(|c| !(c.src == src && c.dst == dst));
|
||||
}
|
||||
|
||||
pub fn cables(&self) -> &[Cable] {
|
||||
&self.cables
|
||||
}
|
||||
}
|
||||
|
||||
impl<const MAX_CABLES: usize> Default for Patch<MAX_CABLES> {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
42
crates/synth-core/src/vca.rs
Normal file
42
crates/synth-core/src/vca.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! Voltage-controlled amplifier (VCA).
|
||||
|
||||
use crate::{AudioProcessor, CVProcessor};
|
||||
|
||||
pub struct Vca {
|
||||
pub gain: f32, // 0.0–1.0
|
||||
}
|
||||
|
||||
impl Vca {
|
||||
pub fn new(gain: f32) -> Self {
|
||||
Self { gain }
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> AudioProcessor<B> for Vca {
|
||||
/// Scales the signal already in `out` by the current gain.
|
||||
fn process(&mut self, out: &mut [f32; B]) {
|
||||
for s in out.iter_mut() {
|
||||
*s *= self.gain;
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.gain = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<const B: usize> CVProcessor<B> for Vca {
|
||||
fn set_cv(&mut self, cv: f32) {
|
||||
self.gain = cv.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow the ADSR output to drive the VCA directly.
|
||||
impl Vca {
|
||||
/// Apply a per-sample gain envelope to `audio`, returning the result in-place.
|
||||
pub fn apply_envelope<const B: usize>(&self, audio: &mut [f32; B], envelope: &[f32; B]) {
|
||||
for (s, &e) in audio.iter_mut().zip(envelope.iter()) {
|
||||
*s *= e * self.gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user