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
312 lines
12 KiB
C
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;
|
|
}
|