Files
SplitBit-Emulator/Source/Emulator/sound.c
T
AnachronautandClaude Opus 5 d388cd3122 Give the machine a sound device
Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.

Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.

Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.

Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:

  - Only one voice had an oscillator switched on, so three of the four
    channels could not make a sound whatever was written to them.
  - That voice's oscillator arrived at full gain and every other one
    arrived at nothing, an asymmetry with no reason behind it.
  - A note with no sustain is silent but not over, so the obvious way to
    wait for a sound to finish waits for ever.

The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.

Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.

Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-29 20:59:17 -04:00

312 lines
12 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;
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:
if (parameter == SP_LEVEL_SOURCE) {
v->levelSource = sourceFor(value);
}
break;
// The LFOs belong to the device rather than to a channel, so whichever channel is
// selected makes no difference to these.
case SP_LFO0: setLfo(&synth.lfos[0], which, value); break;
case SP_LFO1: setLfo(&synth.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;
}