Files
SplitBit-Emulator/Source/Emulator/sound.c
T
Anachronaut d361ea1e46 An LFO belongs to its channel, not to the whole device
The two LFOs lived in the Synth, so four channels shared them and whichever patch
loaded last owned them for every voice at once. A sound with its LFO switched off
silenced the trill under a sound that was still playing - which is what made Lunar
Porter's low fuel warning intermittent: the first landing, docking or crash of a run
took its trill away, and it was right again next time the machine started.

The engine fix went upstream to soundThing and has come back. synth.c and synth.h
are re-vendored at 71e3cb2, character for character bar the ASCII transliteration,
and now carry two changes: the LFOs moved into the Voice, and synthSyncVoices
carries a free LFO's cycle down alongside its rate. That second hunk does nothing
here - it only matters to a caller that syncs voices, and this device never does,
because syncing would flatten four channels into one instrument. It is taken so the
vendored file stays identical in both trees, and it is commented as such.

Upstream also found a bug in the original patch, in patchLoad, which is soundThing's
own file and does not travel.

Downstream the LFO parameter groups 0x60 and 0x70 now read the selected channel like
every parameter beside them, so an LFO written to one channel is inaudible on the
other three. Everything else about the device is unchanged.

Lunar Porter keeps loading each patch immediately before its note, but for the
smaller reason that now applies: the bang and the latch share channel three, and a
channel used by two sounds has to be told which of them it is about to be. The
comment that said otherwise, and the manual's warning about sharing, are rewritten
as history rather than as a caveat.

Tests/sound.sh's shared-LFO check is inverted to assert the fixed behaviour, with a
third leg added: after proving another channel's patch leaves this one alone, it
switches this channel's OWN LFO off and requires the pitch to move. Without that,
both checks would pass on a device where writing an LFO did nothing at all. Routing
either group back to voice 0 is caught.

Cost, measured: four channels sounding continuously for 400 seconds of audio takes
5.0 s of wall clock against 4.59 s before, about 9% of total emulator time. Half of
that is wasted on voices that cannot sound, since VOICE_COUNT is 8 and there are
four channels; recovering it would mean diverging the vendored file, which is not
worth it at this price.
2026-09-04 22:20:26 -04:00

325 lines
13 KiB
C

// sound.c
// The Voyager's sound device.
// Written by Anachronaut
#include "sound.h"
#include "synth.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <math.h>
// A megahertz, matching the machine. Kept here rather than reaching for machine.h, which
// would drag the whole front end into a device.
#define SOUND_CYCLE_RATE 1000000
static Synth synth;
static uint8_t channel;
static uint8_t parameter;
// Where the machine's clock was when the device started, and how many samples have been made
// since. The next sample is due at start + count * rate / samples, worked out in whole
// numbers each time rather than by adding an approximation over and over - twenty and five
// sixths does not add up to anything exact, and a drift of one part in a thousand is four
// seconds an hour.
static unsigned long startedAt;
static unsigned long samplesMade;
// ---- What has been made and not yet played ----
//
// A ring, written by the machine and read by whatever is playing it. One writer and one
// reader, which is the only sharing that needs no lock at all.
//
// IT DROPS WHEN IT IS FULL, and full means nobody is listening: a headless run makes
// forty-eight thousand samples a second of emulated time and there is nothing to take them.
// Dropping is right there. What must not drop is the COUNT, because that is the clock.
#define SOUND_RING 16384
static int16_t ring[SOUND_RING];
static int ringHead, ringTail;
// And a copy of everything, for --sound. Only kept when a file was asked for, because a long
// run makes millions of samples and a machine that hoarded them by default would be a machine
// that ran out of memory for no reason anybody asked for.
static int16_t *keeping = NULL;
static size_t keptCount, keptRoom;
void soundReset(void) {
synthInit(&synth, (float)SOUND_SAMPLE_RATE);
// ---- The device's own power-on state ----
//
// synthInit leaves soundThing's defaults, which are a patch EDITOR's: one voice set up to
// be heard and seven silent behind it, waiting for the edited patch to be copied over
// them. That is right for a program with one instrument on screen and wrong for a device
// whose four channels are four independent things.
//
// Two consequences if it were left alone, both of which the tests caught. Channels 1 to 3
// would be silent whatever gain was written to them, because their oscillators are not
// switched on. And channel 0's first oscillator would arrive at full gain while every
// other one arrived at nothing - an asymmetry with no reason a programmer could work out.
//
// So: EVERY CHANNEL ARRIVES ABLE TO MAKE A SOUND. Oscillator 0 on, at full gain;
// oscillator 1 off, because two oscillators is a choice and one is the plain case. A
// program that writes a note number hears that note, which is the shortest useful thing
// this device can be asked to do.
for (int i = 0; i < SOUND_CHANNELS; i++) {
synth.voices[i].oscillators[0].active = 1;
synth.voices[i].oscillators[0].gain = OSC_MAX_GAIN;
// Off rather than on-and-silent, because the two oscillators are AVERAGED and not
// added: a second one that is switched on halves the first whatever its gain is.
// "Active" is structural, and there is no setting of it that costs nothing.
synth.voices[i].oscillators[1].active = 0;
synth.voices[i].oscillators[1].gain = 0.0f;
}
channel = 0;
parameter = 0;
startedAt = 0;
samplesMade = 0;
ringHead = 0;
ringTail = 0;
keptCount = 0;
}
void soundKeepSamples(void) {
keptRoom = 1 << 16;
keeping = malloc(keptRoom * sizeof(*keeping));
keptCount = 0;
}
static void pushSample(int16_t sample) {
const int next = (ringTail + 1) % SOUND_RING;
if (next != ringHead) {
ring[ringTail] = sample;
ringTail = next;
}
if (keeping != NULL) {
if (keptCount == keptRoom) {
size_t bigger = keptRoom * 2;
int16_t *grown = realloc(keeping, bigger * sizeof(*keeping));
if (grown == NULL) {
return;
}
keeping = grown;
keptRoom = bigger;
}
keeping[keptCount++] = sample;
}
}
void soundTick(unsigned long now) {
if (startedAt == 0 && samplesMade == 0) {
startedAt = now;
}
for (;;) {
// When the next one is due, in whole numbers: no accumulated fraction to drift.
// Sample n is due n periods after the device started, so sample nought is due the
// moment it starts. Making the first one a period late would put every sample after
// it a period late too, which is a whole sample of lag for nothing.
const unsigned long due = startedAt
+ (unsigned long)(samplesMade * (uint64_t)SOUND_CYCLE_RATE
/ SOUND_SAMPLE_RATE);
if (now < due) {
return;
}
int16_t sample;
synthFillBuffer(&synth, &sample, 1);
pushSample(sample);
samplesMade++;
}
}
// ---- A byte, and what it means ----
//
// Everything on this machine is a byte, and a synthesizer wants seconds, hertz and ratios. So
// each parameter says how its 0 to 255 becomes what the engine needs, and the shapes are
// chosen for where the USEFUL part of the range is rather than for arithmetic convenience.
//
// Times are squared, because the difference between five and fifty milliseconds is the whole
// character of a percussive sound and the difference between three and four seconds is
// nothing anybody can hear. Cutoff is exponential for the same reason: pitch is logarithmic
// and so is where a filter sounds like it is.
static float overRange(uint8_t value, float lowest, float highest) {
return lowest + (highest - lowest) * ((float)value / 255.0f);
}
static float squared(uint8_t value, float highest) {
const float part = (float)value / 255.0f;
return part * part * highest;
}
static float exponential(uint8_t value, float lowest, float highest) {
const float part = (float)value / 255.0f;
return lowest * powf(highest / lowest, part);
}
// Centred on 128, so that half of nothing is no change and either side of it is a direction.
static float signedRange(uint8_t value, float reach) {
return ((float)value - 128.0f) / 128.0f * reach;
}
static ModSource sourceFor(uint8_t value) {
return (value <= MOD_SOURCE_LFO2) ? (ModSource)value : MOD_SOURCE_NONE;
}
static void setOscillator(Oscillator *o, uint8_t which, uint8_t value) {
switch (which) {
case SP_OSC_WAVE: o->waveform = (Waveform)(value % WAVE_COUNT); break;
case SP_OSC_GAIN: o->gain = overRange(value, 0.0f, OSC_MAX_GAIN); break;
case SP_OSC_DUTY: o->dutyCycle = overRange(value, 0.05f, 0.95f); break;
// An octave either way, so a step of the byte is 1200/128, about nine cents. Fine
// enough for the shimmer of two oscillators just apart, which is what detune is
// mostly for, and wide enough to transpose one of them a whole octave.
case SP_OSC_DETUNE: o->detune = signedRange(value, 1200.0f); break;
case SP_OSC_OCTAVE: o->octave = (int)value - 128 < -2 ? -2
: ((int)value - 128 > 2 ? 2 : (int)value - 128); break;
case SP_OSC_ACTIVE: o->active = value != 0; break;
case SP_OSC_PWM_SRC: o->modRouting[0] = sourceFor(value); break;
case SP_OSC_PWM_DEPTH: o->modDepth[0] = signedRange(value, 0.5f); break;
case SP_OSC_DET_SRC: o->modRouting[1] = sourceFor(value); break;
case SP_OSC_DET_DEPTH: o->modDepth[1] = signedRange(value, 1200.0f); break;
case SP_OSC_GAIN_SRC: o->modRouting[2] = sourceFor(value); break;
case SP_OSC_GAIN_DEPTH: o->modDepth[2] = signedRange(value, OSC_MAX_GAIN); break;
default: break;
}
}
static void setEnvelope(Envelope *e, uint8_t which, uint8_t value) {
switch (which) {
case SP_ENV_ATTACK: e->attackSec = squared(value, 4.0f); break;
case SP_ENV_DECAY: e->decaySec = squared(value, 4.0f); break;
case SP_ENV_SUSTAIN: e->sustainLevel = overRange(value, 0.0f, 1.0f); break;
case SP_ENV_RELEASE: e->releaseSec = squared(value, 4.0f); break;
default: break;
}
}
static void setFilter(Filter *f, uint8_t which, uint8_t value) {
switch (which) {
case SP_FILTER_ACTIVE: f->active = value != 0; break;
case SP_FILTER_TYPE: f->type = (FilterType)(value % FILTER_COUNT); break;
case SP_FILTER_CUTOFF: f->cutoff = exponential(value, 20.0f, 20000.0f); break;
case SP_FILTER_RES: f->resonance = overRange(value, 0.0f, 0.99f); break;
case SP_FILTER_CUT_SRC: f->modRouting = sourceFor(value); break;
case SP_FILTER_CUT_DEP: f->modDepth = signedRange(value, 8000.0f); break;
case SP_FILTER_RES_SRC: f->resModRouting = sourceFor(value); break;
case SP_FILTER_RES_DEP: f->resModDepth = signedRange(value, 0.99f); break;
default: break;
}
}
static void setLfo(LFO *l, uint8_t which, uint8_t value) {
switch (which) {
case SP_LFO_ACTIVE: l->active = value != 0; break;
case SP_LFO_WAVE: l->waveform = (Waveform)(value % WAVE_COUNT); break;
case SP_LFO_RATE: l->rate = exponential(value, 0.05f, 20.0f); break;
case SP_LFO_MODE: l->mode = value ? LFO_RETRIGGER : LFO_FREE; break;
default: break;
}
}
static void soundParameter(uint8_t value) {
Voice *v = &synth.voices[channel];
const uint8_t group = parameter & 0xF0;
const uint8_t which = parameter & 0x0F;
switch (group) {
case SP_OSC0: setOscillator(&v->oscillators[0], which, value); break;
case SP_OSC1: setOscillator(&v->oscillators[1], which, value); break;
case SP_AMPENV: setEnvelope(&v->ampEnv, which, value); break;
case SP_MODENV: setEnvelope(&v->modEnv, which, value); break;
case SP_FILTER: setFilter(&v->filter, parameter, value); break;
case SP_LEVEL_SOURCE:
// Two settings share a group, because both are about the voice as a whole rather
// than about one of its parts.
if (parameter == SP_LEVEL_SOURCE) {
v->levelSource = sourceFor(value);
} else if (parameter == SP_VOICE_GATE) {
v->gate = value ? VOICE_TRIGGER : VOICE_GATE;
}
break;
// ---- The LFOs belong to the channel ----
//
// They used to belong to the device, so whichever channel was selected made no
// difference: two channels playing two different sounds shared one pair of LFOs, and
// whichever loaded its settings last owned them for both. A sound with its LFO
// switched off silenced the trill under a sound that was still playing.
//
// Four channels are four independent things, which is the rule the rest of this
// device already followed. So these read the selected channel like every parameter
// above them, and an LFO written to one channel is inaudible on the other three.
case SP_LFO0: setLfo(&v->lfos[0], which, value); break;
case SP_LFO1: setLfo(&v->lfos[1], which, value); break;
default:
// A parameter number nothing answers to does nothing. A sound device is a poor
// place to stop the machine, the same as a screen.
break;
}
}
uint8_t soundWrite(uint8_t value, uint8_t port) {
switch (port) {
case SOUND_CHANNEL: channel = value % SOUND_CHANNELS; break;
case SOUND_PARAMETER: parameter = value; break;
case SOUND_VALUE: soundParameter(value); break;
case SOUND_NOTE: synthChannelOn(&synth, channel, value); break;
case SOUND_GATE:
if (value) {
synthChannelOn(&synth, channel, synth.voices[channel].midiNote);
} else {
synthChannelOff(&synth, channel);
}
break;
case SOUND_VOLUME: synth.volume = overRange(value, 0.0f, 1.0f); break;
default: break;
}
return 0;
}
uint8_t soundRead(uint8_t port) {
switch (port) {
case SOUND_STATUS: {
uint8_t status = 0;
for (int i = 0; i < SOUND_CHANNELS; i++) {
if (synth.voices[i].active) {
status |= SOUND_STATUS_SOUNDING;
}
}
return status;
}
case SOUND_CHANNEL: return channel;
case SOUND_PARAMETER: return parameter;
case SOUND_NOTE: return (uint8_t)synth.voices[channel].midiNote;
default: return 0;
}
}
int soundTake(int16_t *into, int wanted) {
int taken = 0;
while (taken < wanted && ringHead != ringTail) {
into[taken++] = ring[ringHead];
ringHead = (ringHead + 1) % SOUND_RING;
}
return taken;
}
int soundWriteSamples(const char *path) {
// Nothing was kept, which happens if the file was asked for after the machine ran. An
// empty file is the honest answer: the run made no sound anybody asked to hear.
if (keeping == NULL) {
keptCount = 0;
}
FILE *file = fopen(path, "wb");
if (file == NULL) {
fprintf(stderr, "Error: Couldn't write the sound to: %s\n", path);
return 1;
}
const size_t written = keptCount == 0
? 0 : fwrite(keeping, sizeof(*keeping), keptCount, file);
fclose(file);
if (written != keptCount) {
fprintf(stderr, "Error: The sound was not written whole to: %s\n", path);
return 1;
}
return 0;
}