Initial release: 8-bit sound synthesizer with LLM/MCP integration

Soundgen is a Rust workspace for generating 8-bit/chiptune sound effects,
UI sounds, and ambient textures for game audio assets. It features JSON-first
sound specs, an MCP server for LLM tool-use, an egui GUI editor, and a
runtime library with NES-authentic DAC emulation.

Features:
- 6 voice types: pulse, triangle, noise, DPCM, wavetable, FM
- Effects: ADSR envelope, frequency sweep, biquad filter, vibrato
- 13 built-in presets (SFX/UI/ambient) as JSON data files
- MCP server: list_presets, generate_sfx, render_sound tools
- Pattern-based sequencer (JSON song format)
- egui GUI: virtual keyboard, preset browser, channel editor, undo/redo
- Runtime: NES nonlinear DAC + SoundBank for game embedding
- 90 tests, 0 warnings

Crates:
- soundgen-core: synthesis engine (no I/O)
- soundgen-fmt: SoundSpec JSON schema + PresetRegistry
- soundgen-io: WAV writer + audio playback
- soundgen-seq: sequencer (patterns, songs)
- soundgen-cli: gen/render/render-song/list-presets
- soundgen-mcp: MCP server for LLM integration
- soundgen-gui: egui editor
- soundgen-runtime: NES DAC + SoundBank
This commit is contained in:
Emil
2026-06-21 22:07:05 +03:00
commit c7d6c40683
74 changed files with 13345 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "soundgen-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
+91
View File
@@ -0,0 +1,91 @@
//! Frequency automation — pitch envelope / sweep configuration.
//!
//! Serializable config that drives a [`Sweep`] at render time.
use crate::effect::{Sweep, SweepCurve};
/// Describes how a channel's frequency changes over its duration.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct FrequencyAutomation {
pub start: f32,
pub end: f32,
#[serde(default = "default_curve")]
pub curve: SweepCurve,
}
fn default_curve() -> SweepCurve {
SweepCurve::Linear
}
impl FrequencyAutomation {
/// Build a [`Sweep`] for this automation over `duration` seconds.
pub fn to_sweep(&self, sample_rate: f32, duration: f32) -> Sweep {
Sweep::new(sample_rate, self.start, self.end, self.curve, duration)
}
/// Static frequency (no sweep).
pub fn fixed(freq: f32) -> Self {
Self {
start: freq,
end: freq,
curve: SweepCurve::Linear,
}
}
pub fn is_static(&self) -> bool {
(self.start - self.end).abs() < 0.01
}
}
impl Default for FrequencyAutomation {
fn default() -> Self {
Self::fixed(440.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_automation_is_static() {
let a = FrequencyAutomation::fixed(440.0);
assert!(a.is_static());
}
#[test]
fn test_sweep_automation_not_static() {
let a = FrequencyAutomation {
start: 200.0,
end: 800.0,
curve: SweepCurve::Exponential,
};
assert!(!a.is_static());
}
#[test]
fn test_to_sweep_produces_valid_sweep() {
let a = FrequencyAutomation {
start: 100.0,
end: 200.0,
curve: SweepCurve::Linear,
};
let mut s = a.to_sweep(100.0, 1.0);
s.trigger();
s.tick();
assert!((s.current() - 100.0).abs() < 2.0);
}
#[test]
fn test_serde_roundtrip() {
let a = FrequencyAutomation {
start: 100.0,
end: 400.0,
curve: SweepCurve::Exponential,
};
let json = serde_json::to_string(&a).unwrap();
let a2: FrequencyAutomation = serde_json::from_str(&json).unwrap();
assert!((a2.start - a.start).abs() < 0.01);
assert_eq!(a2.curve, a.curve);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Channel renderer — wraps a voice with envelope, frequency sweep, and filter.
//!
//! Produces stereo samples (left, right) with volume and pan applied.
use crate::effect::{Envelope, Filter, Sweep};
use crate::generator::{Generator, Voice};
use crate::voice::VoiceKind;
pub struct ChannelRenderer {
voice: VoiceKind,
envelope: Envelope,
freq_sweep: Option<Sweep>,
/// Frequency used when there's no sweep (static frequency).
initial_frequency: f32,
filter: Option<Filter>,
filter_sweep: Option<Sweep>,
volume: f32,
pan: f32,
}
impl ChannelRenderer {
pub fn new(voice: VoiceKind, sample_rate: f32) -> Self {
Self {
voice,
envelope: Envelope::new(sample_rate),
freq_sweep: None,
initial_frequency: 440.0,
filter: None,
filter_sweep: None,
volume: 1.0,
pan: 0.0,
}
}
pub fn with_envelope(mut self, envelope: Envelope) -> Self {
self.envelope = envelope;
self
}
pub fn with_freq_sweep(mut self, sweep: Sweep) -> Self {
self.freq_sweep = Some(sweep);
self
}
/// Set the static frequency (used when no sweep is present).
pub fn with_initial_frequency(mut self, freq: f32) -> Self {
self.initial_frequency = freq;
self
}
pub fn with_filter(mut self, filter: Filter) -> Self {
self.filter = Some(filter);
self
}
pub fn with_filter_sweep(mut self, sweep: Sweep) -> Self {
self.filter_sweep = Some(sweep);
self
}
pub fn with_volume(mut self, volume: f32) -> Self {
self.volume = volume.clamp(0.0, 1.0);
self
}
pub fn with_pan(mut self, pan: f32) -> Self {
self.pan = pan.clamp(-1.0, 1.0);
self
}
/// Trigger note on + envelope + sweeps.
pub fn trigger(&mut self) {
let freq = self
.freq_sweep
.as_ref()
.map(|s| s.current())
.unwrap_or(self.initial_frequency);
self.voice.note_on(freq, 1.0);
self.envelope.trigger();
if let Some(s) = &mut self.freq_sweep {
s.trigger();
}
if let Some(s) = &mut self.filter_sweep {
s.trigger();
}
}
/// Release the envelope (note off).
pub fn release(&mut self) {
self.envelope.release();
}
/// Set the voice frequency directly (overrides any sweep).
pub fn set_frequency(&mut self, freq: f32) {
if self.freq_sweep.is_none() {
self.voice.set_frequency(freq);
}
}
/// Produce one stereo sample (left, right).
#[inline]
pub fn tick(&mut self) -> (f32, f32) {
// Update frequency from sweep
if let Some(sweep) = &mut self.freq_sweep {
let freq = sweep.tick();
self.voice.set_frequency(freq);
}
// Tick voice
let mut sample = self.voice.tick();
// Update filter cutoff from sweep
if let (Some(filter), Some(fsweep)) = (&mut self.filter, &mut self.filter_sweep) {
let cutoff = fsweep.tick();
filter.set_cutoff(cutoff);
}
// Apply filter
if let Some(filter) = &mut self.filter {
sample = filter.process(sample);
}
// Apply envelope
let env = self.envelope.tick();
sample *= env * self.volume;
// Equal-power pan
let pan_norm = (self.pan + 1.0) * 0.5;
let left_gain = (1.0 - pan_norm).sqrt();
let right_gain = pan_norm.sqrt();
(sample * left_gain, sample * right_gain)
}
pub fn is_finished(&self) -> bool {
self.envelope.is_finished()
}
pub fn reset(&mut self) {
self.voice.reset();
self.envelope.reset();
if let Some(s) = &mut self.freq_sweep {
s.reset();
}
if let Some(s) = &mut self.filter_sweep {
s.reset();
}
if let Some(f) = &mut self.filter {
f.reset();
}
}
}
/// Render multiple channel renderers to interleaved stereo Vec (L, R, L, R, ...).
pub fn render_channels(channels: &mut [ChannelRenderer], n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples * 2);
for _ in 0..n_samples {
let mut left = 0.0f32;
let mut right = 0.0f32;
for ch in channels.iter_mut() {
let (l, r) = ch.tick();
left += l;
right += r;
}
// Soft clip
out.push(left.tanh());
out.push(right.tanh());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::voice::{DutyCycle, PulseChannel};
#[test]
fn test_channel_renderer_basic() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5);
ch.trigger();
// Envelope starts at 0 in attack phase; tick a few times to get non-zero
let mut found_signal = false;
for _ in 0..100 {
let (l, r) = ch.tick();
if l.abs() > 0.0 || r.abs() > 0.0 {
found_signal = true;
break;
}
}
assert!(found_signal);
}
#[test]
fn test_channel_renderer_envelope_decays() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0)
.with_envelope(Envelope::adsr(44100.0, 0.0, 0.0, 0.0, 0.01));
ch.trigger();
// With sustain=0 and 0 decay, envelope drops to 0 immediately
let (_l, _r) = ch.tick();
// After attack (0s) → decay (0s) → sustain (0), should be silent
let (l2, r2) = ch.tick();
assert!(l2.abs() < 0.01 && r2.abs() < 0.01);
}
#[test]
fn test_render_channels_length() {
let voice = VoiceKind::Pulse(PulseChannel::new(44100.0, DutyCycle::D50));
let mut ch = ChannelRenderer::new(voice, 44100.0).with_volume(0.5);
ch.trigger();
let out = render_channels(&mut [ch], 500);
assert_eq!(out.len(), 1000);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! ADSR envelope generator.
//!
//! Phases: Attack → Decay → Sustain → Release → Idle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnvelopePhase {
Idle,
Attack,
Decay,
Sustain,
Release,
}
#[derive(Clone, Debug)]
pub struct Envelope {
sample_rate: f32,
attack: f32,
decay: f32,
sustain: f32,
release: f32,
phase: EnvelopePhase,
level: f32,
release_start: f32,
sample_counter: f32,
}
impl Envelope {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
attack: 0.01,
decay: 0.1,
sustain: 0.7,
release: 0.1,
phase: EnvelopePhase::Idle,
level: 0.0,
release_start: 0.0,
sample_counter: 0.0,
}
}
pub fn adsr(sample_rate: f32, attack: f32, decay: f32, sustain: f32, release: f32) -> Self {
Self {
sample_rate,
attack,
decay,
sustain,
release,
phase: EnvelopePhase::Idle,
level: 0.0,
release_start: 0.0,
sample_counter: 0.0,
}
}
pub fn trigger(&mut self) {
self.phase = EnvelopePhase::Attack;
self.level = 0.0;
self.sample_counter = 0.0;
}
pub fn release(&mut self) {
if self.phase != EnvelopePhase::Idle {
self.phase = EnvelopePhase::Release;
self.release_start = self.level;
self.sample_counter = 0.0;
}
}
#[inline]
fn samples_for(&self, seconds: f32) -> f32 {
seconds * self.sample_rate
}
/// Produce next envelope amplitude [0, 1].
#[inline]
pub fn tick(&mut self) -> f32 {
match self.phase {
EnvelopePhase::Idle => {
self.level = 0.0;
}
EnvelopePhase::Attack => {
let attack_samples = self.samples_for(self.attack);
if attack_samples > 0.0 {
self.level = self.sample_counter / attack_samples;
} else {
self.level = 1.0;
}
self.sample_counter += 1.0;
if self.level >= 1.0 || self.sample_counter >= attack_samples {
self.level = 1.0;
self.phase = EnvelopePhase::Decay;
self.sample_counter = 0.0;
}
}
EnvelopePhase::Decay => {
let decay_samples = self.samples_for(self.decay);
if decay_samples > 0.0 {
self.level = 1.0 - (1.0 - self.sustain) * (self.sample_counter / decay_samples);
} else {
self.level = self.sustain;
}
self.sample_counter += 1.0;
if self.sample_counter >= decay_samples || self.level <= self.sustain {
self.level = self.sustain;
self.phase = EnvelopePhase::Sustain;
self.sample_counter = 0.0;
}
}
EnvelopePhase::Sustain => {
self.level = self.sustain;
}
EnvelopePhase::Release => {
let release_samples = self.samples_for(self.release);
if release_samples > 0.0 {
self.level = self.release_start * (1.0 - self.sample_counter / release_samples);
} else {
self.level = 0.0;
}
self.sample_counter += 1.0;
if self.level <= 0.0 || self.sample_counter >= release_samples {
self.level = 0.0;
self.phase = EnvelopePhase::Idle;
self.sample_counter = 0.0;
}
}
}
self.level.clamp(0.0, 1.0)
}
pub fn is_finished(&self) -> bool {
self.phase == EnvelopePhase::Idle
}
pub fn phase(&self) -> EnvelopePhase {
self.phase
}
pub fn reset(&mut self) {
self.phase = EnvelopePhase::Idle;
self.level = 0.0;
self.release_start = 0.0;
self.sample_counter = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_envelope_idle() {
let mut env = Envelope::new(44100.0);
assert_eq!(env.tick(), 0.0);
assert!(env.is_finished());
}
#[test]
fn test_envelope_attack() {
let mut env = Envelope::adsr(44100.0, 0.1, 0.1, 0.5, 0.1);
env.trigger();
assert_eq!(env.phase(), EnvelopePhase::Attack);
// First sample should be near 0
let s0 = env.tick();
assert!(s0 < 0.01);
// After ~half attack, should be ~0.5
for _ in 0..2205 {
env.tick();
}
assert!(env.level > 0.4 && env.level < 0.6);
// After full attack, should be in decay
for _ in 0..2205 {
env.tick();
}
assert_eq!(env.phase(), EnvelopePhase::Decay);
}
#[test]
fn test_envelope_sustain() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 0.7, 0.1);
env.trigger();
env.tick(); // attack (0 samples → immediate)
env.tick(); // decay (0 samples → immediate)
assert_eq!(env.phase(), EnvelopePhase::Sustain);
let s = env.tick();
assert!((s - 0.7).abs() < 0.01);
}
#[test]
fn test_envelope_release() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.0, 1.0, 0.1);
env.trigger();
env.tick();
env.tick();
assert!((env.level - 1.0).abs() < 0.01);
env.release();
let s0 = env.tick();
assert!(s0 <= 1.0);
// After full release, should be idle
for _ in 0..4500 {
env.tick();
}
assert!(env.is_finished());
}
#[test]
fn test_zero_attack_immediate() {
let mut env = Envelope::adsr(44100.0, 0.0, 0.1, 0.5, 0.1);
env.trigger();
env.tick();
// With 0 attack, should immediately be at 1.0 or in decay
assert!(env.level >= 0.99 || env.phase() == EnvelopePhase::Decay);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! Biquad filter — lowpass / highpass.
//!
//! Standard second-order IIR filter with coefficient calculation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilterType {
Lowpass,
Highpass,
}
/// Stateful biquad filter. Process one sample at a time.
pub struct Filter {
filter_type: FilterType,
sample_rate: f32,
cutoff: f32,
q: f32,
// Coefficients
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
// State (Direct Form I)
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl Filter {
pub fn new(sample_rate: f32, filter_type: FilterType, cutoff: f32, q: f32) -> Self {
let mut f = Self {
filter_type,
sample_rate,
cutoff,
q,
b0: 0.0,
b1: 0.0,
b2: 0.0,
a1: 0.0,
a2: 0.0,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
};
f.recalc();
f
}
pub fn set_cutoff(&mut self, cutoff: f32) {
self.cutoff = cutoff;
self.recalc();
}
pub fn set_q(&mut self, q: f32) {
self.q = q;
self.recalc();
}
fn recalc(&mut self) {
let w0 = 2.0 * std::f32::consts::PI * self.cutoff / self.sample_rate;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / (2.0 * self.q);
let (b0, b1, b2, a0, a1, a2);
match self.filter_type {
FilterType::Lowpass => {
b0 = (1.0 - cos_w0) / 2.0;
b1 = 1.0 - cos_w0;
b2 = (1.0 - cos_w0) / 2.0;
a0 = 1.0 + alpha;
a1 = -2.0 * cos_w0;
a2 = 1.0 - alpha;
}
FilterType::Highpass => {
b0 = (1.0 + cos_w0) / 2.0;
b1 = -(1.0 + cos_w0);
b2 = (1.0 + cos_w0) / 2.0;
a0 = 1.0 + alpha;
a1 = -2.0 * cos_w0;
a2 = 1.0 - alpha;
}
}
// Normalize by a0
self.b0 = b0 / a0;
self.b1 = b1 / a0;
self.b2 = b2 / a0;
self.a1 = a1 / a0;
self.a2 = a2 / a0;
}
/// Process one sample through the filter.
#[inline]
pub fn process(&mut self, input: f32) -> f32 {
let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = input;
self.y2 = self.y1;
self.y1 = output;
output
}
pub fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lowpass_attenuates_high_freq() {
let mut lp = Filter::new(44100.0, FilterType::Lowpass, 500.0, 0.707);
// Feed high-frequency signal (alternating +1/-1 per sample = Nyquist freq)
let mut amp_high = 0.0;
for i in 0..1000 {
let input = if i % 2 == 0 { 1.0 } else { -1.0 };
amp_high += lp.process(input).abs();
}
// Feed low-frequency signal (constant = DC, passes through lowpass)
let mut amp_low = 0.0;
for _ in 0..1000 {
amp_low += lp.process(1.0).abs();
}
// Lowpass should pass low freq better than high freq
assert!(
amp_low > amp_high,
"lowpass should attenuate high freq: low={} high={}",
amp_low,
amp_high
);
}
#[test]
fn test_highpass_attenuates_low_freq() {
let mut hp = Filter::new(44100.0, FilterType::Highpass, 2000.0, 0.707);
// DC (low freq) should be attenuated
let mut amp_dc = 0.0;
for _ in 0..1000 {
let s = hp.process(1.0);
amp_dc += s.abs();
}
// High freq (alternating) should pass
hp.reset();
let mut amp_hf = 0.0;
for _ in 0..500 {
let s = hp.process(1.0);
amp_hf += s.abs();
}
for _ in 0..500 {
let s = hp.process(-1.0);
amp_hf += s.abs();
}
assert!(amp_hf > amp_dc, "highpass should attenuate DC");
}
#[test]
fn test_filter_reset() {
let mut f = Filter::new(44100.0, FilterType::Lowpass, 1000.0, 0.707);
for _ in 0..100 {
f.process(0.5);
}
f.reset();
// After reset, state should be zero
assert_eq!(f.x1, 0.0);
assert_eq!(f.y1, 0.0);
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Audio effects: envelope, sweep, filter, vibrato.
pub mod envelope;
pub mod filter;
pub mod sweep;
pub mod vibrato;
pub use envelope::{Envelope, EnvelopePhase};
pub use filter::{Filter, FilterType};
pub use sweep::{Sweep, SweepCurve};
pub use vibrato::Vibrato;
+144
View File
@@ -0,0 +1,144 @@
//! Frequency sweep — pitch automation over time.
//!
//! Linear: freq = start + (end - start) * t
//! Exponential: freq = start * (end / start) ^ t
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SweepCurve {
Linear,
Exponential,
}
pub struct Sweep {
sample_rate: f32,
start: f32,
end: f32,
curve: SweepCurve,
duration: f32,
sample_counter: f32,
current: f32,
active: bool,
}
impl Sweep {
pub fn new(sample_rate: f32, start: f32, end: f32, curve: SweepCurve, duration: f32) -> Self {
Self {
sample_rate,
start,
end,
curve,
duration,
sample_counter: 0.0,
current: start,
active: false,
}
}
pub fn trigger(&mut self) {
self.sample_counter = 0.0;
self.current = self.start;
self.active = true;
}
pub fn stop(&mut self) {
self.active = false;
}
#[inline]
pub fn tick(&mut self) -> f32 {
if !self.active {
return self.current;
}
let total_samples = self.duration * self.sample_rate;
if total_samples <= 0.0 {
self.current = self.end;
self.active = false;
return self.current;
}
let t = self.sample_counter / total_samples;
if t >= 1.0 {
self.current = self.end;
self.active = false;
return self.current;
}
self.current = match self.curve {
SweepCurve::Linear => self.start + (self.end - self.start) * t,
SweepCurve::Exponential => {
if self.start <= 0.0 {
self.start + (self.end - self.start) * t
} else {
self.start * (self.end / self.start).powf(t)
}
}
};
self.sample_counter += 1.0;
self.current
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn current(&self) -> f32 {
self.current
}
pub fn reset(&mut self) {
self.sample_counter = 0.0;
self.current = self.start;
self.active = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_sweep() {
let mut sweep = Sweep::new(100.0, 100.0, 200.0, SweepCurve::Linear, 1.0);
sweep.trigger();
// t=0 → start
let f0 = sweep.tick();
assert!((f0 - 100.0).abs() < 2.0);
// t=0.5 → midpoint
for _ in 0..49 {
sweep.tick();
}
assert!((sweep.current() - 150.0).abs() < 3.0);
// t=1.0 → end (need 101 ticks: tick 101 hits t=1.0)
for _ in 0..51 {
sweep.tick();
}
assert!((sweep.current() - 200.0).abs() < 1.0);
assert!(!sweep.is_active());
}
#[test]
fn test_exponential_sweep() {
let mut sweep = Sweep::new(100.0, 100.0, 400.0, SweepCurve::Exponential, 1.0);
sweep.trigger();
// t=0 → start
sweep.tick();
assert!((sweep.current() - 100.0).abs() < 3.0);
// t=0.5 → 100 * 4^0.5 = 200
for _ in 0..49 {
sweep.tick();
}
assert!((sweep.current() - 200.0).abs() < 10.0);
// t=1.0 → 400
for _ in 0..51 {
sweep.tick();
}
assert!((sweep.current() - 400.0).abs() < 2.0);
}
#[test]
fn test_sweep_not_active_returns_current() {
let mut sweep = Sweep::new(100.0, 200.0, 300.0, SweepCurve::Linear, 1.0);
// Not triggered
let f = sweep.tick();
assert_eq!(f, 200.0); // current = start = 200
}
}
@@ -0,0 +1,89 @@
//! Vibrato — LFO for frequency modulation.
//!
//! Modulates the pitch by a sine wave at a given rate and depth.
pub struct Vibrato {
sample_rate: f32,
freq: f32,
depth: f32,
phase: f32,
active: bool,
}
impl Vibrato {
/// Create a vibrato with given LFO frequency (Hz) and depth (in cents).
pub fn new(sample_rate: f32, freq: f32, depth_cents: f32) -> Self {
Self {
sample_rate,
freq,
depth: depth_cents,
phase: 0.0,
active: false,
}
}
pub fn trigger(&mut self) {
self.phase = 0.0;
self.active = true;
}
pub fn stop(&mut self) {
self.active = false;
}
/// Returns the frequency multiplier for the current tick.
/// Multiply this by the base frequency to get the vibrato-modulated frequency.
#[inline]
pub fn tick(&mut self) -> f32 {
if !self.active {
return 1.0;
}
let lfo = (2.0 * std::f32::consts::PI * self.phase).sin();
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
// Convert cents to frequency ratio: 2^(cents/1200)
let cents = lfo * self.depth;
2.0f32.powf(cents / 1200.0)
}
pub fn reset(&mut self) {
self.phase = 0.0;
self.active = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vibrato_inactive_returns_unity() {
let mut v = Vibrato::new(44100.0, 5.0, 50.0);
assert!((v.tick() - 1.0).abs() < 0.001);
}
#[test]
fn test_vibrato_modulates() {
let mut v = Vibrato::new(100.0, 10.0, 100.0); // 10 Hz, 100 cents
v.trigger();
let mut ratios = Vec::new();
for _ in 0..100 {
ratios.push(v.tick());
}
// Should vary from unity
let min = ratios.iter().cloned().fold(1.0f32, f32::min);
let max = ratios.iter().cloned().fold(1.0f32, f32::max);
assert!(min < 0.99, "vibrato should go below unity: min={}", min);
assert!(max > 1.01, "vibrato should go above unity: max={}", max);
}
#[test]
fn test_vibrato_depth_zero_is_unity() {
let mut v = Vibrato::new(44100.0, 5.0, 0.0);
v.trigger();
for _ in 0..100 {
assert!((v.tick() - 1.0).abs() < 0.001);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Core traits for sound generation.
/// A sample-by-sample generator. Produces one mono sample per `tick()`.
///
/// Output range: -1.0 ..= 1.0.
/// Implementations must not allocate in `tick()`.
pub trait Generator {
fn tick(&mut self) -> f32;
fn reset(&mut self);
}
/// A playable voice with note on/off semantics.
pub trait Voice: Generator {
fn note_on(&mut self, freq: f32, velocity: f32);
fn note_off(&mut self);
fn set_frequency(&mut self, freq: f32);
fn is_active(&self) -> bool;
}
+20
View File
@@ -0,0 +1,20 @@
//! Core synthesis engine: generators, effects, mixer.
//!
//! No I/O. Renders to `Vec<f32>`.
pub mod automation;
pub mod channel;
pub mod effect;
pub mod generator;
pub mod mixer;
pub mod voice;
pub use automation::FrequencyAutomation;
pub use channel::{render_channels, ChannelRenderer};
pub use effect::{Envelope, EnvelopePhase, Filter, FilterType, Sweep, SweepCurve, Vibrato};
pub use generator::{Generator, Voice};
pub use mixer::Mixer;
pub use voice::{
DpcmChannel, DutyCycle, FmChannel, NoiseMode, PulseChannel, TriangleChannel, VoiceKind,
WavetableChannel,
};
+168
View File
@@ -0,0 +1,168 @@
//! Stereo mixer — sums multiple voices with per-channel volume and pan.
use crate::voice::VoiceKind;
use crate::Generator;
/// A single channel in the mixer: voice + amplitude + pan.
pub struct MixerChannel {
pub voice: VoiceKind,
pub volume: f32,
/// -1.0 = full left, 0.0 = center, 1.0 = full right.
pub pan: f32,
}
pub struct Mixer {
channels: Vec<MixerChannel>,
sample_rate: f32,
}
impl Mixer {
pub fn new(sample_rate: f32) -> Self {
Self {
channels: Vec::new(),
sample_rate,
}
}
pub fn push(&mut self, voice: VoiceKind, volume: f32, pan: f32) {
self.channels.push(MixerChannel {
voice,
volume: volume.clamp(0.0, 1.0),
pan: pan.clamp(-1.0, 1.0),
});
}
pub fn sample_rate(&self) -> f32 {
self.sample_rate
}
pub fn len(&self) -> usize {
self.channels.len()
}
pub fn is_empty(&self) -> bool {
self.channels.is_empty()
}
pub fn reset(&mut self) {
for ch in &mut self.channels {
ch.voice.reset();
}
}
/// Produce one stereo sample (left, right).
#[inline]
pub fn tick_stereo(&mut self) -> (f32, f32) {
let mut left = 0.0f32;
let mut right = 0.0f32;
for ch in &mut self.channels {
let sample = ch.voice.tick() * ch.volume;
// Equal-power panning
let pan_norm = (ch.pan + 1.0) * 0.5; // 0 = left, 1 = right
let left_gain = (1.0 - pan_norm).sqrt();
let right_gain = pan_norm.sqrt();
left += sample * left_gain;
right += sample * right_gain;
}
// Soft clip to prevent harsh clipping
let left = left.tanh();
let right = right.tanh();
(left, right)
}
/// Produce one mono sample (sum of all channels).
#[inline]
pub fn tick_mono(&mut self) -> f32 {
let (l, r) = self.tick_stereo();
(l + r) * 0.5
}
}
/// Render a mixer to a stereo Vec (interleaved L, R, L, R, ...).
pub fn render_stereo(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples * 2);
for _ in 0..n_samples {
let (l, r) = mixer.tick_stereo();
out.push(l);
out.push(r);
}
out
}
/// Render a mixer to a mono Vec.
pub fn render_mono(mixer: &mut Mixer, n_samples: usize) -> Vec<f32> {
let mut out = Vec::with_capacity(n_samples);
for _ in 0..n_samples {
out.push(mixer.tick_mono());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generator::Voice;
use crate::voice::{DutyCycle, PulseChannel};
#[test]
fn test_mixer_empty() {
let mut m = Mixer::new(44100.0);
let (l, r) = m.tick_stereo();
assert_eq!(l, 0.0);
assert_eq!(r, 0.0);
}
#[test]
fn test_mixer_single_channel() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let (l, r) = m.tick_stereo();
// Center pan, volume 0.5
assert!(l.abs() > 0.0 || r.abs() > 0.0);
}
#[test]
fn test_mixer_pan_left() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 1.0, -1.0); // full left
let (l, r) = m.tick_stereo();
assert!(l.abs() > 0.0);
// Right should be near zero (equal power pan at -1.0 → right_gain = 0)
assert!(r.abs() < 0.01);
}
#[test]
fn test_mixer_pan_right() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 1.0, 1.0); // full right
let (l, r) = m.tick_stereo();
assert!(r.abs() > 0.0);
assert!(l.abs() < 0.01);
}
#[test]
fn test_render_mono_length() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let out = render_mono(&mut m, 1000);
assert_eq!(out.len(), 1000);
}
#[test]
fn test_render_stereo_length() {
let mut m = Mixer::new(44100.0);
let mut pulse = PulseChannel::new(44100.0, DutyCycle::D50);
pulse.note_on(440.0, 1.0);
m.push(VoiceKind::Pulse(pulse), 0.5, 0.0);
let out = render_stereo(&mut m, 1000);
assert_eq!(out.len(), 2000); // interleaved
}
}
+191
View File
@@ -0,0 +1,191 @@
//! DPCM channel — 8-bit sample playback (NES DPCM-style).
//!
//! Plays back pre-decoded PCM samples at a variable clock rate.
//! Supports looping and pitch control via the clock frequency.
use crate::{Generator, Voice};
pub struct DpcmChannel {
sample_rate: f32,
freq: f32,
samples: Vec<f32>,
position: f32,
velocity: f32,
active: bool,
looping: bool,
}
impl DpcmChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 8000.0,
samples: vec![],
position: 0.0,
velocity: 0.0,
active: false,
looping: false,
}
}
/// Load 8-bit unsigned PCM samples (0-255).
pub fn load_pcm_u8(&mut self, data: &[u8]) {
self.samples = data.iter().map(|&b| (b as f32 / 128.0) - 1.0).collect();
}
/// Load float samples (-1..1).
pub fn load_samples(&mut self, data: &[f32]) {
self.samples = data.to_vec();
}
pub fn set_looping(&mut self, looping: bool) {
self.looping = looping;
}
/// Generate a simple kick drum sample.
pub fn kick() -> Vec<u8> {
let mut data = Vec::new();
let n = 2000;
for i in 0..n {
let t = i as f32 / n as f32;
let freq = 150.0 * (1.0 - t * 0.8);
let env = (1.0 - t).powi(2);
let s = (t * freq * 2.0 * std::f32::consts::PI).sin() * env;
data.push(((s * 0.8 + 1.0) * 128.0) as u8);
}
data
}
/// Generate a simple snare sample.
pub fn snare() -> Vec<u8> {
let mut data = Vec::new();
let n = 1500;
let mut lfsr: u16 = 0x1234;
for i in 0..n {
let t = i as f32 / n as f32;
let env = (1.0 - t).powi(1);
let bit0 = lfsr & 1;
let bit8 = (lfsr >> 8) & 1;
lfsr >>= 1;
lfsr |= (bit0 ^ bit8) << 14;
let noise = if (lfsr & 1) == 0 { 1.0 } else { -1.0 };
let tone = (t * 200.0 * 2.0 * std::f32::consts::PI).sin() * 0.3;
let s = (noise * 0.7 + tone) * env;
data.push(((s * 0.7 + 1.0) * 128.0) as u8);
}
data
}
}
impl Generator for DpcmChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active || self.samples.is_empty() {
return 0.0;
}
let idx = self.position as usize;
if idx >= self.samples.len() {
if self.looping {
self.position = 0.0;
} else {
self.active = false;
return 0.0;
}
}
let sample = self.samples[self.position as usize % self.samples.len()];
self.position += self.freq / self.sample_rate;
sample * self.velocity
}
fn reset(&mut self) {
self.position = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for DpcmChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.position = 0.0;
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dpcm_empty() {
let mut ch = DpcmChannel::new(44100.0);
ch.note_on(8000.0, 1.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_dpcm_playback() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5, 0.5, -0.5]);
ch.note_on(44100.0, 1.0); // 1 sample per tick
assert!((ch.tick() - 0.5).abs() < 0.01);
assert!((ch.tick() - (-0.5)).abs() < 0.01);
}
#[test]
fn test_dpcm_loop() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5]);
ch.set_looping(true);
ch.note_on(44100.0, 1.0); // 1 sample per tick
// tick 1: pos=0 → 0.5, pos→1
assert!((ch.tick() - 0.5).abs() < 0.01);
// tick 2: pos=1 → -0.5, pos→2
assert!((ch.tick() - (-0.5)).abs() < 0.01);
// tick 3: pos=2 ≥ len → wrap to 0 → 0.5, pos→1
assert!((ch.tick() - 0.5).abs() < 0.01);
assert!(ch.is_active());
// tick 4: pos=1 → -0.5
assert!((ch.tick() - (-0.5)).abs() < 0.01);
}
#[test]
fn test_dpcm_no_loop_ends() {
let mut ch = DpcmChannel::new(44100.0);
ch.load_samples(&[0.5, -0.5]);
ch.note_on(44100.0, 1.0);
ch.tick();
ch.tick();
ch.tick(); // past end
assert!(!ch.is_active());
}
#[test]
fn test_kick_sample() {
let kick = DpcmChannel::kick();
assert!(!kick.is_empty());
assert!(kick.len() > 500);
}
#[test]
fn test_snare_sample() {
let snare = DpcmChannel::snare();
assert!(!snare.is_empty());
assert!(snare.len() > 500);
}
}
+137
View File
@@ -0,0 +1,137 @@
//! FM channel — 2-operator FM synthesis (carrier + modulator).
//!
//! carrier_out = sin(car_phase + mod_index * sin(mod_phase))
//! mod_freq = car_freq * mod_ratio
use crate::{Generator, Voice};
pub struct FmChannel {
sample_rate: f32,
freq: f32,
car_phase: f32,
mod_phase: f32,
mod_ratio: f32,
mod_index: f32,
velocity: f32,
active: bool,
}
impl FmChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 440.0,
car_phase: 0.0,
mod_phase: 0.0,
mod_ratio: 2.0, // 2:1 ratio (classic FM)
mod_index: 1.0, // moderate modulation
velocity: 0.0,
active: false,
}
}
pub fn set_mod_ratio(&mut self, ratio: f32) {
self.mod_ratio = ratio;
}
pub fn set_mod_index(&mut self, index: f32) {
self.mod_index = index;
}
#[inline]
fn advance(&mut self) {
let car_inc = self.freq / self.sample_rate;
let mod_inc = self.freq * self.mod_ratio / self.sample_rate;
self.car_phase = (self.car_phase + car_inc).fract();
self.mod_phase = (self.mod_phase + mod_inc).fract();
}
}
impl Generator for FmChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
let modulator = self.mod_index * (2.0 * std::f32::consts::PI * self.mod_phase).sin();
let carrier = (2.0 * std::f32::consts::PI * self.car_phase + modulator).sin();
carrier * self.velocity
}
fn reset(&mut self) {
self.car_phase = 0.0;
self.mod_phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for FmChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fm_output_range() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_index(0.0); // no modulation → pure sine
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_fm_inactive() {
let mut ch = FmChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_fm_with_modulation() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_ratio(3.0);
ch.set_mod_index(2.0);
ch.note_on(440.0, 1.0);
// With modulation, output should have harmonics
let mut max_sample = 0.0f32;
for _ in 0..4410 {
let s = ch.tick();
max_sample = max_sample.max(s.abs());
}
assert!(max_sample > 0.5, "FM should produce audible output");
}
#[test]
fn test_fm_zero_mod_is_sine() {
let mut ch = FmChannel::new(44100.0);
ch.set_mod_index(0.0);
ch.note_on(440.0, 1.0);
// With 0 mod index, it's a pure sine wave
let s = ch.tick();
// First sample: car_phase ≈ 0, sin(0) ≈ 0
assert!(s.abs() < 0.1);
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Voice implementations: pulse, triangle, noise, dpcm, wavetable, fm.
pub mod dpcm;
pub mod fm;
pub mod noise;
pub mod pulse;
pub mod triangle;
pub mod wavetable;
pub use dpcm::DpcmChannel;
pub use fm::FmChannel;
pub use noise::{NoiseChannel, NoiseMode};
pub use pulse::{DutyCycle, PulseChannel};
pub use triangle::TriangleChannel;
pub use wavetable::WavetableChannel;
/// Discriminated union of all voice types.
/// Avoids dynamic dispatch — dispatch is a `match` in `tick()`.
pub enum VoiceKind {
Pulse(PulseChannel),
Triangle(TriangleChannel),
Noise(NoiseChannel),
Dpcm(DpcmChannel),
Wavetable(WavetableChannel),
Fm(FmChannel),
}
impl crate::Generator for VoiceKind {
#[inline]
fn tick(&mut self) -> f32 {
match self {
VoiceKind::Pulse(v) => v.tick(),
VoiceKind::Triangle(v) => v.tick(),
VoiceKind::Noise(v) => v.tick(),
VoiceKind::Dpcm(v) => v.tick(),
VoiceKind::Wavetable(v) => v.tick(),
VoiceKind::Fm(v) => v.tick(),
}
}
fn reset(&mut self) {
match self {
VoiceKind::Pulse(v) => v.reset(),
VoiceKind::Triangle(v) => v.reset(),
VoiceKind::Noise(v) => v.reset(),
VoiceKind::Dpcm(v) => v.reset(),
VoiceKind::Wavetable(v) => v.reset(),
VoiceKind::Fm(v) => v.reset(),
}
}
}
impl crate::Voice for VoiceKind {
#[inline]
fn note_on(&mut self, freq: f32, velocity: f32) {
match self {
VoiceKind::Pulse(v) => v.note_on(freq, velocity),
VoiceKind::Triangle(v) => v.note_on(freq, velocity),
VoiceKind::Noise(v) => v.note_on(freq, velocity),
VoiceKind::Dpcm(v) => v.note_on(freq, velocity),
VoiceKind::Wavetable(v) => v.note_on(freq, velocity),
VoiceKind::Fm(v) => v.note_on(freq, velocity),
}
}
fn note_off(&mut self) {
match self {
VoiceKind::Pulse(v) => v.note_off(),
VoiceKind::Triangle(v) => v.note_off(),
VoiceKind::Noise(v) => v.note_off(),
VoiceKind::Dpcm(v) => v.note_off(),
VoiceKind::Wavetable(v) => v.note_off(),
VoiceKind::Fm(v) => v.note_off(),
}
}
#[inline]
fn set_frequency(&mut self, freq: f32) {
match self {
VoiceKind::Pulse(v) => v.set_frequency(freq),
VoiceKind::Triangle(v) => v.set_frequency(freq),
VoiceKind::Noise(v) => v.set_frequency(freq),
VoiceKind::Dpcm(v) => v.set_frequency(freq),
VoiceKind::Wavetable(v) => v.set_frequency(freq),
VoiceKind::Fm(v) => v.set_frequency(freq),
}
}
fn is_active(&self) -> bool {
match self {
VoiceKind::Pulse(v) => v.is_active(),
VoiceKind::Triangle(v) => v.is_active(),
VoiceKind::Noise(v) => v.is_active(),
VoiceKind::Dpcm(v) => v.is_active(),
VoiceKind::Wavetable(v) => v.is_active(),
VoiceKind::Fm(v) => v.is_active(),
}
}
}
+164
View File
@@ -0,0 +1,164 @@
//! Noise channel with LFSR — NES-style percussion.
//!
//! 15-bit Linear Feedback Shift Register.
//! White noise mode: long period (~32767 samples).
//! Periodic mode: short period (~93 samples).
use crate::{Generator, Voice};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoiseMode {
White,
Periodic,
}
pub struct NoiseChannel {
sample_rate: f32,
freq: f32,
phase: f32,
lfsr: u16,
current_bit: f32,
velocity: f32,
active: bool,
mode: NoiseMode,
}
impl NoiseChannel {
pub fn new(sample_rate: f32, mode: NoiseMode) -> Self {
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
lfsr: 1,
current_bit: 1.0,
velocity: 0.0,
active: false,
mode,
}
}
pub fn set_mode(&mut self, mode: NoiseMode) {
self.mode = mode;
}
/// Clock the LFSR one step.
#[inline]
fn clock_lfsr(&mut self) {
let bit0 = (self.lfsr & 1) as u16;
let feedback_bit = match self.mode {
NoiseMode::White => 8, // bit 8
NoiseMode::Periodic => 6, // bit 6
};
let bit_n = (self.lfsr >> feedback_bit) & 1;
let feedback = bit0 ^ bit_n;
self.lfsr >>= 1;
self.lfsr |= feedback << 14;
self.current_bit = if (self.lfsr & 1) == 0 { 1.0 } else { -1.0 };
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
if self.phase >= 1.0 {
self.phase = self.phase.fract();
self.clock_lfsr();
}
}
}
impl Generator for NoiseChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
self.current_bit * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.lfsr = 1;
self.current_bit = 1.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for NoiseChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_noise_output_range() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
ch.note_on(8000.0, 1.0);
for _ in 0..10000 {
let s = ch.tick();
assert!(s == 1.0 || s == -1.0 || s == 0.0);
}
}
#[test]
fn test_noise_inactive() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_lfsr_not_stuck() {
let mut ch = NoiseChannel::new(44100.0, NoiseMode::White);
ch.note_on(44100.0, 1.0); // clock every sample
let mut values: Vec<f32> = Vec::new();
for _ in 0..1000 {
values.push(ch.tick());
}
let positives = values.iter().filter(|&&v| v > 0.0).count();
let negatives = values.iter().filter(|&&v| v < 0.0).count();
// Should produce both positive and negative values
assert!(positives > 100 && negatives > 100);
}
#[test]
fn test_periodic_mode_shorter_period() {
let mut white = NoiseChannel::new(44100.0, NoiseMode::White);
white.note_on(44100.0, 1.0);
let mut periodic = NoiseChannel::new(44100.0, NoiseMode::Periodic);
periodic.note_on(44100.0, 1.0);
// Collect unique patterns
let mut white_vals = Vec::new();
let mut periodic_vals = Vec::new();
for _ in 0..200 {
white_vals.push(white.tick());
periodic_vals.push(periodic.tick());
}
// Periodic should have fewer unique transitions (shorter loop)
let white_transitions = white_vals.windows(2).filter(|w| w[0] != w[1]).count();
let periodic_transitions = periodic_vals.windows(2).filter(|w| w[0] != w[1]).count();
// Both should have some transitions
assert!(white_transitions > 0);
assert!(periodic_transitions > 0);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Pulse / square wave channel with selectable duty cycle.
//!
//! Emulates the NES 2A03 pulse channels: phase accumulator + duty comparator.
use crate::{Generator, Voice};
/// NES duty cycle presets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DutyCycle {
D12_5,
D25,
D50,
D75,
}
impl DutyCycle {
pub fn from_percent(p: u8) -> Self {
match p {
0..=12 => DutyCycle::D12_5,
13..=37 => DutyCycle::D25,
38..=62 => DutyCycle::D50,
_ => DutyCycle::D75,
}
}
pub fn as_fraction(self) -> f32 {
match self {
DutyCycle::D12_5 => 0.125,
DutyCycle::D25 => 0.25,
DutyCycle::D50 => 0.5,
DutyCycle::D75 => 0.75,
}
}
}
pub struct PulseChannel {
sample_rate: f32,
freq: f32,
phase: f32,
duty: DutyCycle,
velocity: f32,
active: bool,
}
impl PulseChannel {
pub fn new(sample_rate: f32, duty: DutyCycle) -> Self {
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
duty,
velocity: 0.0,
active: false,
}
}
pub fn set_duty(&mut self, duty: DutyCycle) {
self.duty = duty;
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
}
}
impl Generator for PulseChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
let raw = if self.phase < self.duty.as_fraction() {
1.0
} else {
-1.0
};
raw * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for PulseChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duty_cycle_from_percent() {
assert_eq!(DutyCycle::from_percent(12), DutyCycle::D12_5);
assert_eq!(DutyCycle::from_percent(25), DutyCycle::D25);
assert_eq!(DutyCycle::from_percent(50), DutyCycle::D50);
assert_eq!(DutyCycle::from_percent(75), DutyCycle::D75);
}
#[test]
fn test_pulse_output_range() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D50);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s == 1.0 || s == -1.0 || s == 0.0);
}
}
#[test]
fn test_pulse_inactive() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D50);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_pulse_duty12_5_is_mostly_negative() {
let mut ch = PulseChannel::new(44100.0, DutyCycle::D12_5);
ch.note_on(100.0, 1.0);
let mut positive = 0;
let mut negative = 0;
for _ in 0..44100 {
if ch.tick() > 0.0 {
positive += 1;
} else {
negative += 1;
}
}
// 12.5% duty → ~12.5% positive, ~87.5% negative
assert!(positive < negative, "12.5% duty should be mostly negative");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Triangle wave channel — NES-style bass.
//!
//! 32-step triangle via phase accumulator. Linear, no aliasing reduction
//! (acceptable for 8-bit aesthetic).
use crate::{Generator, Voice};
pub struct TriangleChannel {
sample_rate: f32,
freq: f32,
phase: f32,
velocity: f32,
active: bool,
}
impl TriangleChannel {
pub fn new(sample_rate: f32) -> Self {
Self {
sample_rate,
freq: 220.0,
phase: 0.0,
velocity: 0.0,
active: false,
}
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq / self.sample_rate;
self.phase = self.phase.fract();
}
/// Triangle wave from phase [0, 1).
#[inline]
fn triangle(phase: f32) -> f32 {
if phase < 0.5 {
4.0 * phase - 1.0 // -1 → 1
} else {
3.0 - 4.0 * phase // 1 → -1
}
}
}
impl Generator for TriangleChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
self.advance();
Self::triangle(self.phase) * self.velocity
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for TriangleChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_triangle_waveform() {
assert!((TriangleChannel::triangle(0.0) - (-1.0)).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.25) - 0.0).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.5) - 1.0).abs() < 1e-6);
assert!((TriangleChannel::triangle(0.75) - 0.0).abs() < 1e-6);
}
#[test]
fn test_triangle_output_range() {
let mut ch = TriangleChannel::new(44100.0);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_triangle_inactive() {
let mut ch = TriangleChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
}
+128
View File
@@ -0,0 +1,128 @@
//! Wavetable channel — Game Boy wave-style 32-step wavetable.
//!
//! Each step is a value 0-15 (4-bit), normalized to -1..1.
//! The channel cycles through the wavetable at the given frequency.
use crate::{Generator, Voice};
/// Default Game Boy triangle-like wavetable.
const DEFAULT_WAVE: [u8; 32] = [
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4,
3, 2, 1, 0,
];
pub struct WavetableChannel {
sample_rate: f32,
freq: f32,
phase: f32,
wave: [f32; 32],
velocity: f32,
active: bool,
}
impl WavetableChannel {
pub fn new(sample_rate: f32) -> Self {
let mut wave = [0.0f32; 32];
for (i, &v) in DEFAULT_WAVE.iter().enumerate() {
wave[i] = (v as f32 / 7.5) - 1.0; // 0-15 → -1..1
}
Self {
sample_rate,
freq: 440.0,
phase: 0.0,
wave,
velocity: 0.0,
active: false,
}
}
/// Set a custom wavetable from 4-bit values (0-15).
pub fn set_wave(&mut self, wave: &[u8; 32]) {
for (i, &v) in wave.iter().enumerate() {
self.wave[i] = (v as f32 / 7.5) - 1.0;
}
}
/// Set a custom wavetable from float values (-1..1).
pub fn set_wave_f32(&mut self, wave: &[f32; 32]) {
self.wave = *wave;
}
#[inline]
fn advance(&mut self) {
self.phase += self.freq * 32.0 / self.sample_rate;
self.phase = self.phase.fract();
}
}
impl Generator for WavetableChannel {
#[inline]
fn tick(&mut self) -> f32 {
if !self.active {
return 0.0;
}
let step = (self.phase * 32.0) as usize % 32;
let sample = self.wave[step] * self.velocity;
self.advance();
sample
}
fn reset(&mut self) {
self.phase = 0.0;
self.velocity = 0.0;
self.active = false;
}
}
impl Voice for WavetableChannel {
fn note_on(&mut self, freq: f32, velocity: f32) {
self.freq = freq;
self.velocity = velocity.clamp(0.0, 1.0);
self.active = true;
}
fn note_off(&mut self) {
self.active = false;
}
fn set_frequency(&mut self, freq: f32) {
self.freq = freq;
}
fn is_active(&self) -> bool {
self.active
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wavetable_output_range() {
let mut ch = WavetableChannel::new(44100.0);
ch.note_on(440.0, 1.0);
for _ in 0..1000 {
let s = ch.tick();
assert!(s >= -1.01 && s <= 1.01);
}
}
#[test]
fn test_wavetable_inactive() {
let mut ch = WavetableChannel::new(44100.0);
assert_eq!(ch.tick(), 0.0);
}
#[test]
fn test_custom_wavetable() {
let mut ch = WavetableChannel::new(44100.0);
let mut wave = [0u8; 32];
wave[0] = 15; // peak at step 0
ch.set_wave(&wave);
ch.note_on(100.0, 1.0);
// First sample should be near max (step 0)
let s = ch.tick();
assert!(s > 0.5);
}
}