Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion mmm_audio/Oscillators.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,8 @@ struct OscBuffers(Movable, Copyable):
self.init_triangle() # Initialize triangle wave buffer using harmonics
self.init_sawtooth() # Initialize sawtooth wave buffer using harmonics
self.init_square() # Initialize square wave buffer using harmonics
self.init_cos() # Initialize cosine wave buffer
self.init_bell() # Initialize Gaussian bell curve buffer

self.init_basic_waveforms() # Initialize basic waveforms for quick access

Expand Down Expand Up @@ -1031,14 +1033,27 @@ struct OscBuffers(Movable, Copyable):
# Scale by 4/π for correct amplitude
self.buffers[3].append(4.0 / 3.141592653589793 * sample)

fn init_cos(mut self):
for i in range(OscBuffersSize):
v = cos(2.0 * 3.141592653589793 * Float64(i) / Float64(OscBuffersSize))
self.buffers[4].append(v)
Copy link
Copy Markdown
Collaborator

@tedmoore tedmoore May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the buffers list allocated to have 6 elements? I don't see where that happens?


fn init_bell(mut self):
for i in range(OscBuffersSize):
a = Float64((i - (OscBuffersSize/2)) / (OscBuffersSize/8))
b = exp(-1*a*a)
self.buffers[5].append(b)

@doc_private
fn init_basic_waveforms(mut self):
for i in range(OscBuffersSize):
self.basic_waveforms.append(MFloat[4](
Comment thread
tedmoore marked this conversation as resolved.
self.buffers[0][i], # sine
self.buffers[1][i], # triangle
self.buffers[2][i], # sawtooth
self.buffers[3][i] # square
self.buffers[3][i], # square
self.buffers[4][i], # cosine
self.buffers[5][i] # bell
))

fn __repr__(self) -> String:
Expand Down
205 changes: 205 additions & 0 deletions mmm_audio/Synthesizers.mojo
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
from mmm_audio import *

struct PAF[num_chans: Int = 1, interp: Int = Interp.linear, os_index: Int = 0, bell_bWrap: Bool = False](Representable, Movable, Copyable):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why bell_bWrap is False? For synthesis it seems like it would always be True? Also, why is this a Parameter that could be set by a user? Why would a user set it to True instead of False?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Miller Puckette's design in Pure Data only reads the second half of the bell curve table. It uses tabread4~ which does not wrap - instead it sends the first or last value in the table (depending on which direction the index value exceeds the bounds), which in the bell curve, is negligible (1e-7). In this case in MMMAudio, setting bWrap to False by default has the same effect, sending 0 if the index is out of range. I found in testing that the user can achieve different sounds by setting bWrap to true, thus reading the entire bell curve many times continuously rather than reading half of it only at the beginning and end of the sinusoidal phase.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. An example file demonstrating these options would be excellent. Also, a very brief description (basically what you've put above) can go in the mojo docstrings.

"""Phase-Aligned Formant generator using a single phasor to synthesize multiple wavetables . From Miller Puckette's "Theory and Technique of Electronic Music," page 170.

Parameters:
num_chans: Number of channels.
interp: Interpolation method. See [Interp](MMMWorld.md/#struct-interp) struct for options.
os_index: [Oversampling](Oversampling.md) index (0 = no oversampling, 1 = 2x, 2 = 4x, etc.).
bell_bWrap: Whether to wrap indices that go out of bounds in the bell wavetable.
"""
var world: World

var phasor: Phasor[Self.num_chans, Self.os_index]

var cos1_last_phase: MFloat[Self.num_chans]
var cos2_last_phase: MFloat[Self.num_chans]
var sin_last_phase: MFloat[Self.num_chans]
var bell_last_phase: MFloat[Self.num_chans]
var buffer: List[Float64]

var oversampling: Oversampling[Self.num_chans, 2**Self.os_index]

fn __init__(out self, world: World):
"""
Args:
world: Pointer to the MMMWorld instance.
"""
self.world = world

self.phasor = Phasor[self.num_chans, Self.os_index](self.world)

self.cos1_last_phase = MFloat[self.num_chans](0.0)
self.cos2_last_phase = MFloat[self.num_chans](0.0)
self.sin_last_phase = MFloat[self.num_chans](0.0)
self.bell_last_phase = MFloat[self.num_chans](0.0)
self.buffer = List[Float64]()

self.oversampling = Oversampling[self.num_chans, 2**Self.os_index](world)

self.init_half_sine()

fn init_half_sine(mut self):
for i in range(OscBuffersSize):
v = sin(3.141592653589793 * Float64(i) / Float64(OscBuffersSize))
self.buffer.append(v)

fn __repr__(self) -> String:
return String("PAF")

@always_inline
fn next(
mut self,
fundamental: MFloat[self.num_chans] = MFloat[self.num_chans](100.0),
center_freq: MFloat[self.num_chans] = MFloat[self.num_chans](440.0),
bandwidth: MFloat[self.num_chans] = MFloat[self.num_chans](1.0)
) -> MFloat[self.num_chans]:
"""Generate the next synthesized sample.

Args:
fundamental: Fundamental frequency of the phasor.
center_freq: Center frequency of the formant.
bandwidth: Bandwidth.

Returns:
The next sample of the synthesizer output.
"""

cos1 = MFloat[self.num_chans](0.0)
cos2 = MFloat[self.num_chans](0.0)
sin = MFloat[self.num_chans](0.0)
bell_phase = MFloat[self.num_chans](0.0)
bell = MFloat[self.num_chans](0.0)
mod = MFloat[self.num_chans](0.0)
out = MFloat[self.num_chans](0.0)

@parameter
if Self.os_index == 0:
phasor = self.phasor.next(fundamental)

a = center_freq/fundamental
b = wrap(a, 0.0,1.0)

cos1_phase = phasor*(a - b)
cos2_phase = cos1_phase + phasor
sin_phase = phasor
@parameter
for chan in range(self.num_chans):
cos1[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](4)[chan]],
f_idx=(cos1_phase[chan]*OscBuffersSize),
prev_f_idx=self.cos1_last_phase[chan]*OscBuffersSize
)

cos2[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](4)[chan]],
f_idx=cos2_phase[chan]*OscBuffersSize,
prev_f_idx=self.cos2_last_phase[chan]*OscBuffersSize
)

sin[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.buffer,
f_idx=sin_phase[chan]*OscBuffersSize,
prev_f_idx=self.sin_last_phase[chan]*OscBuffersSize
)

bell_phase = (sin*((bandwidth/fundamental)*0.25))+0.5
bell[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=self.bell_bWrap,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](5)[chan]],
f_idx=bell_phase[chan]*OscBuffersSize,
prev_f_idx=self.bell_last_phase[chan]*OscBuffersSize
)
self.cos1_last_phase = cos1_phase
self.cos2_last_phase = cos2_phase
self.sin_last_phase = sin_phase
self.bell_last_phase = bell_phase

mod = ((cos2 - cos1)*b)+cos1
out = mod * bell
return out
else:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a lot of duplicated code in either path of this if/else. Can it be tightened up to make the struct more maintainable?

@parameter
for _ in range(2**Self.os_index):
phasor = self.phasor.next(fundamental)

a = center_freq/fundamental
b = wrap(a, 0.0,1.0)

cos1_phase = phasor*(a - b)
cos2_phase = cos1_phase + phasor
sin_phase = phasor
for chan in range(self.num_chans):
cos1[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](4)[chan]],
f_idx=(cos1_phase[chan]*OscBuffersSize),
prev_f_idx=self.cos1_last_phase[chan]*OscBuffersSize
)

cos2[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](4)[chan]],
f_idx=cos2_phase[chan]*OscBuffersSize,
prev_f_idx=self.cos2_last_phase[chan]*OscBuffersSize
)

sin[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=True,
mask=OscBuffersMask
](
world=self.world,
data=self.buffer,
f_idx=sin_phase[chan]*OscBuffersSize,
prev_f_idx=self.sin_last_phase[chan]*OscBuffersSize
)

bell_phase[chan] = (sin[chan]*((bandwidth[chan]/fundamental[chan])*0.25))+0.5
bell[chan] = SpanInterpolator.read[
interp=self.interp,
bWrap=self.bell_bWrap,
mask=OscBuffersMask
](
world=self.world,
data=self.world[].osc_buffers[].buffers[MInt[](5)[chan]],
f_idx=bell_phase[chan]*OscBuffersSize,
prev_f_idx=self.bell_last_phase[chan]*OscBuffersSize
)
mod[chan] = ((cos2[chan] - cos1[chan])*b[chan])+cos1[chan]
out[chan] = mod[chan] * bell[chan]
self.cos1_last_phase = cos1_phase
self.cos2_last_phase = cos2_phase
self.sin_last_phase = sin_phase
self.bell_last_phase = bell_phase
self.oversampling.add_sample(out)

return self.oversampling.get_sample()
1 change: 1 addition & 0 deletions mmm_audio/__init__.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ from .Recorder_Module import *
from .ReverbsDelayFX import *
from .SincInterpolator_Module import *
from .sound_file import *
from .Synthesizers import *

from .Messenger_Module import *
from .Print_Module import *
Expand Down
Loading