Last active
June 12, 2025 09:02
-
-
Save ingoogni/4c85ff8add8bb1ea5214bbe38d00f626 to your computer and use it in GitHub Desktop.
Nim Modal synthesis
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Modal synthesis in Nim with a pool for modes, so one excite fast and they still properly decay. Cheesy bell, gong & mallet modes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#import std/[math] | |
import soundio | |
type | |
SoundSystem* = object | |
sio*: ptr SoundIo | |
indevice*: ptr SoundIoDevice | |
instream*: ptr SoundIoInStream | |
outdevice*: ptr SoundIoDevice | |
outstream*: ptr SoundIoOutStream | |
#outsource*: proc() | |
proc `=destroy`(s: SoundSystem) = | |
if not isNil s.outstream: | |
echo "destroy outstream" | |
s.outstream.destroy | |
dealloc(s.outstream.userdata) | |
if not isNil s.outdevice: | |
echo "destroy outdevice" | |
s.outdevice.unref | |
echo "destroy SoundSystem" | |
s.sio.destroy | |
echo "Quit" | |
proc writeCallback(outStream: ptr SoundIoOutStream, frameCountMin: cint, frameCountMax: cint) {.cdecl.} = | |
let csz = sizeof SoundIoChannelArea | |
var areas: ptr SoundIoChannelArea | |
var framesLeft = frameCountMax | |
var err: cint | |
while true: | |
var frameCount = framesLeft | |
err = outStream.beginWrite(areas.addr, frameCount.addr) | |
if err > 0: | |
quit "Unrecoverable stream error: " & $err.strerror | |
if frameCount <= 0: | |
break | |
let layout = outstream.layout | |
let ptrAreas = cast[int](areas) | |
for frame in 0..<frameCount: | |
let sample = tick() | |
for channel in 0..<layout.channelCount: | |
let ptrArea = cast[ptr SoundIoChannelArea](ptrAreas + channel*csz) | |
var ptrSample = cast[ptr float32](cast[int](ptrArea.pointer) + frame*ptrArea.step) | |
ptrSample[] = sample | |
err = outstream.endWrite | |
if err > 0 and err != cint(SoundIoError.Underflow): | |
quit "Unrecoverable stream error: " & $err.strerror | |
framesLeft -= frameCount | |
if framesLeft <= 0: | |
break | |
proc newSoundSystem*(): SoundSystem = | |
echo "SoundIO version : ", version_string() | |
result.sio = soundioCreate() | |
if isNil result.sio: quit "out of mem" | |
var err = result.sio.connect | |
if err > 0: quit "Unable to connect to backend: " & $err.strerror | |
echo "Backend: \t", result.sio.currentBackend.name | |
result.sio.flushEvents | |
echo "Output:" | |
let outdevID = result.sio.defaultOutputDeviceIndex | |
echo " Device Index: \t", outdevID | |
if outdevID < 0: quit "Output device is not found" | |
result.outdevice = result.sio.getOutputDevice(outdevID) | |
if isNil result.outdevice: quit "out of mem" | |
if result.outdevice.probeError > 0: quit "Cannot probe device" | |
echo " Device Name:\t", result.outdevice.name | |
result.outstream = result.outdevice.outStreamCreate | |
result.outstream.write_callback = writeCallback | |
err = result.outstream.open | |
if err > 0: quit "Unable to open output device: " & $err.strerror | |
if result.outstream.layoutError > 0: | |
quit "Unable to set channel layout: " & $result.outstream.layoutError.strerror | |
err = result.outstream.start | |
if err > 0: quit "Unable to start stream: " & $err.strerror | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#inspiration: https://nathan.ho.name/posts/exploring-modal-synthesis/ | |
import std/[math, random] | |
const | |
SampleRate {.intdefine.} = 44100 | |
SRate* = SampleRate.float32 | |
MaxModalObjects = 32 # Maximum slots in the pool | |
type | |
Ticker* = object | |
tick: uint | |
Mode* = object | |
partial*: seq[float32] | |
phase*: seq[float32] | |
amplitude*: seq[float32] | |
decayTime*: seq[float32] | |
proc initMode*(partial, phase, amplitude, decayTime: seq[float32]): Mode = | |
Mode( | |
partial: partial, | |
phase: phase, | |
amplitude: amplitude, | |
decayTime: decayTime | |
) | |
type | |
Modal* = object | |
frequency*: seq[float32] | |
phase: seq[float32] | |
amplitude*: seq[float32] | |
gain*: float32 | |
# State | |
phaseIncrement: seq[float32] | |
decayFactor*: seq[float32] | |
envelope*: seq[float32] | |
active*: bool | |
proc initModal*(mode: Mode, fundamental: float32, gain: float32): Modal = | |
let len = mode.partial.len | |
var modal = Modal( | |
frequency: newSeq[float32](len), | |
phase: mode.phase, | |
amplitude: mode.amplitude, | |
gain: gain, | |
phaseIncrement: newSeq[float32](len), | |
decayFactor: newSeq[float32](len), | |
envelope: mode.amplitude, | |
active: false | |
) | |
for i in 0..<len: | |
modal.frequency[i] = mode.partial[i] * fundamental | |
modal.phaseIncrement[i] = modal.frequency[i] * Tau.float32 / SRate | |
modal.decayFactor[i] = exp(-1.0'f32 / (mode.decayTime[i] * SRate)) | |
return modal | |
proc excite*(modal: var Modal) = | |
modal.active = true | |
for i in 0..<modal.frequency.len: | |
modal.envelope[i] = modal.amplitude[i] | |
proc isActive*(modal: Modal): bool = | |
if not modal.active: | |
return false | |
const threshold = 0.001'f32 | |
for env in modal.envelope: | |
if env > threshold: | |
return true | |
return false | |
proc next*(modal: var Modal): float32 = | |
if not modal.active: | |
return 0.0'f32 | |
var output = 0.0'f32 | |
var stillActive = false | |
for i in 0..<modal.frequency.len: | |
if modal.envelope[i] > 0.001'f32: # Threshold for silence | |
let sineWave = sin(modal.phase[i]) | |
output += sineWave * modal.envelope[i] | |
stillActive = true | |
modal.phase[i] = (modal.phase[i] + modal.phaseIncrement[i]) mod Tau.float32 | |
modal.envelope[i] *= modal.decayFactor[i] | |
modal.active = stillActive | |
return (output / modal.frequency.len.float32) * modal.gain | |
type | |
ModalPool* = object | |
modals: seq[Modal] | |
nextIndex: int | |
proc initModalPool*(): ModalPool = | |
ModalPool( | |
modals: newSeq[Modal](MaxModalObjects), | |
nextIndex: 0 | |
) | |
proc trigger*(pool: var ModalPool, mode: Mode, fundamental: float32, gain: float32) = | |
# Round-Robin, if no inactive slot, override the oldest | |
var startIndex = pool.nextIndex | |
var found = false | |
for i in 0..<MaxModalObjects: | |
let idx = (startIndex + i) mod MaxModalObjects | |
if not pool.modals[idx].isActive(): | |
pool.modals[idx] = initModal(mode, fundamental, gain) | |
pool.modals[idx].excite() | |
pool.nextIndex = (idx + 1) mod MaxModalObjects | |
found = true | |
break | |
if not found: | |
pool.modals[pool.nextIndex] = initModal(mode, fundamental, gain) | |
pool.modals[pool.nextIndex].excite() | |
pool.nextIndex = (pool.nextIndex + 1) mod MaxModalObjects | |
proc next*(pool: var ModalPool): float32 = | |
var output = 0.0'f32 | |
for modal in pool.modals.mitems: | |
output += modal.next() | |
return output | |
proc activeCount*(pool: ModalPool): int = | |
var count = 0 | |
for modal in pool.modals: | |
if modal.isActive(): | |
inc count | |
return count | |
let bellModes* = Mode( | |
partial: @[1.0, 2.0, 3.0, 4.2, 5.4, 6.8, 8.1 , 9.6], | |
phase: @[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 , 0.0], | |
amplitude: @[1.0, 0.8, 0.6, 0.4, 0.3, 0.2, 0.15, 0.3], | |
decayTime: @[3.0, 2.5, 2.0, 1.8, 1.5, 1.3, 1.0 , 0.8] | |
) | |
let gongModes* = Mode( | |
partial: @[1.0, 1.6, 2.3, 3.1, 4.7, 6.2, 7.8, 9.4 ], | |
phase: @[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 ], | |
amplitude: @[1.0, 0.9, 0.7, 0.6, 0.4, 0.3, 0.2, 0.15], | |
decayTime: @[8.0, 7.5, 7.0, 6.5, 6.0, 5.5, 5.0, 4.5 ] | |
) | |
let malletModes* = Mode( | |
partial: @[1.0, 2.0, 3.0, 4.0, 5.0 , 6.0, 7.0 , 8.0], | |
phase: @[0.0, 0.0, 0.0, 0.0, 0.0 , 0.0, 0.0 , 0.0], | |
amplitude: @[1.0, 0.7, 0.5, 0.4, 0.25, 0.2, 0.15, 0.1], | |
decayTime: @[4.0, 3.5, 3.0, 2.5, 2.0 , 1.8, 1.5 , 1.2] | |
) | |
when isMainModule: | |
var tickerTape = Ticker(tick: 0) | |
var modalPool = initModalPool() | |
proc tick*(): float = | |
# Trigger new modal every 0.005 seconds (200ms) | |
if tickerTape.tick.float32 mod (SRate * 1.5'f32) == 0: | |
let fundamental = 220'f32 | |
#init randomized mode(s) here | |
let amp = rand(0.5'f32..1.0'f32) | |
modalPool.trigger(bellModes, fundamental, amp) | |
inc tickerTape.tick | |
return modalPool.next() | |
include io #libSoundIO | |
var ss = newSoundSystem() | |
ss.outstream.sampleRate = SampleRate | |
let outstream = ss.outstream | |
let sampleRate = outstream.sampleRate.toFloat | |
echo "Format:\t\t", outstream.format | |
echo "Sample Rate:\t", sampleRate | |
echo "Latency:\t", outstream.softwareLatency | |
echo "Max simultaneous modals: ", MaxModalObjects | |
while true: | |
ss.sio.flushEvents | |
let s = stdin.readLine | |
if s == "q": | |
break |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment