soundThing's synth.c and synth.h, and nothing else of it: the voice engine pulls in maths, stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the interface, which is what made it liftable. Copied rather than submoduled - two files against tying this build to another repository's history is not a close call - so the header carries the whole of the difference and is meant to stay current. A VOICE'S LEVEL IS A ROUTING NOW. Envelope 0 was multiplied into the output unconditionally, so routing it to a filter or an oscillator meant it shaped the volume as well whether that was wanted or not, which is most of the trouble with making percussion. There was no flag to turn that off and there never had been: Envelope is the only one of the four modulating things without an active flag, where LFO, Filter and Oscillator all have one. It could not take the filter's shape either. filterTick returning its input unchanged is the right nothing-happens answer, but an envelope returning a constant would be useless as a mod source - and being a mod source while NOT being the volume is exactly the thing wanted. So the voice names the source that shapes its level, the way every other destination names its source, and MOD_SOURCE_NONE means nothing shapes it. Two things fall out that were not possible before: envelope 1 shaping the volume, and an LFO doing it, which is tremolo. Envelope 0 still decides how long a voice lasts, because it still ticks. A voice with its level unrouted will therefore cut rather than fade when the envelope reaches idle - fine or wanted for percussion, a click if the oscillators are loud at that moment, and a one line change to end on note-off instead if it turns out to matter. NOISE COMES FROM A SEEDED GENERATOR, one to each thing that makes it. rand() is global state shared with the whole process and varies between C libraries, so the same program sounded different on different machines and no recorded result could have meant anything. One generator each rather than one shared, because two noise sources drawing the same stream are not two noise sources. Checked rather than assumed: two synths from the same start produce identical samples, and 4,617 of 4,800 of them are non-zero, so it is noise and not silence. AND A CHANNEL IS THE CHANNEL YOU ASKED FOR. synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants and what the standalone synthesizer still does. Channel two is channel two. Both old calls are untouched. Nothing links it yet. It compiles clean and standalone under -std=c11 -pedantic, which is what make strict already checks, and the 169 tests are unmoved because nothing calls it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
209 lines
7.6 KiB
C
209 lines
7.6 KiB
C
// synth.h
|
|
// The Voyager's sound, vendored from soundThing.
|
|
//
|
|
// ---- Where this came from ----
|
|
//
|
|
// soundThing is a polyphonic subtractive synthesizer written by Anachronaut, and lives in its
|
|
// own repository. What is here is its VOICE ENGINE and nothing else: synth.c pulls in maths,
|
|
// stdlib, stdint and stdio and knows nothing about Raylib, MIDI, patches or the user
|
|
// interface, which is what made it liftable at all.
|
|
//
|
|
// It is copied rather than submoduled. Two files against tying this build to another
|
|
// repository's history is not a close call, and what a copy costs is that changes have to be
|
|
// carried across on purpose. So the list below is the whole of the difference, kept current.
|
|
//
|
|
// ---- What was changed ----
|
|
//
|
|
// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to
|
|
// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as
|
|
// well whether that was wanted or not - which is most of the trouble with making
|
|
// percussion. Every other destination in this synth chooses its source; now this one does
|
|
// too, and MOD_SOURCE_NONE means the level is simply full.
|
|
//
|
|
// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared
|
|
// with the whole process and varies between libraries - so the same program would sound
|
|
// different on different machines and every recorded result would be worthless. It is a
|
|
// generator inside the Synth now, and a machine that starts the same way sounds the same
|
|
// way.
|
|
//
|
|
// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals
|
|
// round-robin, which is what a keyboard wants. A hardware channel is asked for by number.
|
|
// The old calls are still here and still do what they did.
|
|
//
|
|
// Written by Anachronaut
|
|
|
|
#ifndef SYNTH_H
|
|
#define SYNTH_H
|
|
|
|
#include <stdint.h>
|
|
|
|
#define VOICE_COUNT 8
|
|
#define OSC_COUNT 2
|
|
#define LFO_COUNT 2
|
|
#define OSC_MAX_GAIN 4.0f
|
|
|
|
typedef enum {
|
|
WAVE_SINE,
|
|
WAVE_TRIANGLE,
|
|
WAVE_SAW,
|
|
WAVE_RAMP,
|
|
WAVE_PULSE,
|
|
WAVE_NOISE,
|
|
WAVE_COUNT // handy for the modulo wrap on waveform switching
|
|
} Waveform;
|
|
|
|
// Envelope structures:
|
|
typedef enum {
|
|
ENV_IDLE,
|
|
ENV_ATTACK,
|
|
ENV_DECAY,
|
|
ENV_SUSTAIN,
|
|
ENV_RELEASE
|
|
} EnvStage;
|
|
|
|
typedef enum {
|
|
MOD_SOURCE_NONE = 0,
|
|
MOD_SOURCE_AMP_ENV = 1,
|
|
MOD_SOURCE_MOD_ENV = 2,
|
|
MOD_SOURCE_LFO = 3,
|
|
MOD_SOURCE_LFO2 = 4
|
|
} ModSource;
|
|
|
|
typedef struct {
|
|
EnvStage stage;
|
|
float value; // current output value, 0.0 to 1.0
|
|
float attackSec;
|
|
float decaySec;
|
|
float sustainLevel;
|
|
float releaseSec;
|
|
} Envelope;
|
|
|
|
typedef struct {
|
|
float phase;
|
|
float rate; // Hz
|
|
Waveform waveform;
|
|
int active;
|
|
float noiseHeld;
|
|
float noisePhase;
|
|
// Its own noise, seeded at init. rand() is global state shared with the whole process and
|
|
// varies between C libraries, so the same program sounded different on different machines
|
|
// and no recorded result could mean anything. One generator EACH rather than one shared,
|
|
// because two noise sources drawing from the same stream are not two noise sources.
|
|
uint32_t noiseState;
|
|
} LFO;
|
|
|
|
typedef enum {
|
|
FILTER_LOWPASS,
|
|
FILTER_HIGHPASS,
|
|
FILTER_BANDPASS,
|
|
FILTER_COUNT
|
|
} FilterType;
|
|
|
|
typedef struct {
|
|
float cutoff; // Hz
|
|
float resonance; // 0.0 (flat) to 0.99 (near self-oscillation)
|
|
FilterType type;
|
|
int active;
|
|
float low, band; // TPT integrator states s1, s2
|
|
int modRouting;
|
|
float modDepth; // Hz
|
|
int resModRouting;
|
|
float resModDepth; // resonance units (-0.99..0.99)
|
|
} Filter;
|
|
|
|
typedef struct {
|
|
float phase;
|
|
float dutyCycle;
|
|
Waveform waveform;
|
|
float detune; // cents, 0 = no detune
|
|
float noiseHeld; // last drawn random value for clocked noise
|
|
float noisePhase; // tracks when to draw a new noise value
|
|
// Its own noise, seeded at init. rand() is global state shared with the whole process and
|
|
// varies between C libraries, so the same program sounded different on different machines
|
|
// and no recorded result could mean anything. One generator EACH rather than one shared,
|
|
// because two noise sources drawing from the same stream are not two noise sources.
|
|
uint32_t noiseState;
|
|
|
|
float gain;
|
|
int active; // whether this oscillator contributes to output
|
|
int octave; // transposition in octaves, -2 to +2
|
|
// Modulation routing: one source per destination by design.
|
|
// Each parameter (pwm, detune, gain) has exactly one mod source and one depth.
|
|
int modRouting[3]; // 0=off, 1=env0, 2=env1, 3=lfo0, 4=lfo1
|
|
float modDepth[3]; // Depth of modulation parameter
|
|
} Oscillator;
|
|
|
|
typedef struct {
|
|
Oscillator oscillators[OSC_COUNT];
|
|
float freqHz;
|
|
int active;
|
|
int midiNote;
|
|
Envelope ampEnv;
|
|
Envelope modEnv;
|
|
Filter filter;
|
|
// ---- What shapes how loud this voice is ----
|
|
//
|
|
// Envelope 0 used to, always, with no way to say otherwise - so routing it to a filter or
|
|
// an oscillator meant it shaped the volume too, whether that was wanted or not. Every
|
|
// other destination here names its source; this one does now as well.
|
|
//
|
|
// NOT the base-and-depth pair the others use, because a level is not a deviation from a
|
|
// resting value - it is a shape from nothing to full, and multiplying is what an amplitude
|
|
// envelope does. So this names a source outright, and MOD_SOURCE_NONE means the voice is
|
|
// simply at full and whatever env0 is doing is somebody else's business.
|
|
//
|
|
// It also makes two things possible that were not: envelope 1 shaping the volume, and an
|
|
// LFO doing it, which is tremolo.
|
|
ModSource levelSource;
|
|
} Voice;
|
|
|
|
typedef struct {
|
|
float sampleRate;
|
|
float pitchBend;
|
|
float pitchBendRange;
|
|
int lastStolenVoice;
|
|
Voice voices[VOICE_COUNT];
|
|
float volume; // 0.0 to 1.0, master output level
|
|
LFO lfos[LFO_COUNT];
|
|
} Synth;
|
|
|
|
// Envelope functions:
|
|
void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLevel, float releaseSec);
|
|
void envelopeNoteOn(Envelope *e);
|
|
void envelopeNoteOff(Envelope *e);
|
|
float envelopeTick(Envelope *e, float sampleRate);
|
|
|
|
// Oscillator functions:
|
|
void oscillatorInit(Oscillator *o, Waveform waveform, float dutyCycle, float detune, float gain);
|
|
float oscillatorTick(Oscillator *o, float freqHz, float bendMultiplier, float sampleRate,
|
|
float dutyCycle, float detune, float gain);
|
|
|
|
// Synth functions:
|
|
void synthInit(Synth *s, float sampleRate);
|
|
void synthResetPatch(Synth *s);
|
|
void synthNoteOn(Synth *s, int midiNote);
|
|
void synthNoteOff(Synth *s, int midiNote);
|
|
|
|
// ---- 3. A channel is asked for by number ----
|
|
//
|
|
// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants
|
|
// and what the standalone synthesizer still does. A hardware channel is not allocated: it is
|
|
// the third one, and it is the third one every time.
|
|
void synthChannelOn(Synth *s, int channel, int midiNote);
|
|
void synthChannelOff(Synth *s, int channel);
|
|
void synthFillBuffer(Synth *s, int16_t *out, int frames);
|
|
void synthSyncVoices(Synth *s);
|
|
|
|
// LFO functions:
|
|
float lfoTick(LFO *l, float sampleRate);
|
|
|
|
// Filter functions:
|
|
float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate);
|
|
const char *filterTypeName(FilterType t);
|
|
|
|
// Waveform functions:
|
|
float waveformSample(Waveform w, float phase, float dutyCycle, float noiseHeld);
|
|
const char *waveformName(Waveform w);
|
|
|
|
#endif
|