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:
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user