From d388cd3122ae697560455cbedf37d156316112d5 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Sat, 29 Aug 2026 20:59:17 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW --- Programs/Examples/tune.asm | 203 ++++++++++++++++ Source/Emulator/io.c | 14 ++ Source/Emulator/io.h | 1 + Source/Emulator/machine.c | 12 + Source/Emulator/sound.c | 311 ++++++++++++++++++++++++ Source/Emulator/sound.h | 121 ++++++++++ Source/Emulator/utility.c | 10 +- Source/Emulator/utility.h | 1 + SplitBit Programming Manual.md | 199 +++++++++++++++- SplitBit Test Manual.md | 26 +- Tests/expected/tune.out | 2 + Tests/manifest | 5 + Tests/sound.sh | 418 +++++++++++++++++++++++++++++++++ makefile | 17 +- 14 files changed, 1332 insertions(+), 8 deletions(-) create mode 100644 Programs/Examples/tune.asm create mode 100644 Source/Emulator/sound.c create mode 100644 Source/Emulator/sound.h create mode 100644 Tests/expected/tune.out create mode 100755 Tests/sound.sh diff --git a/Programs/Examples/tune.asm b/Programs/Examples/tune.asm new file mode 100644 index 0000000..6c3bac0 --- /dev/null +++ b/Programs/Examples/tune.asm @@ -0,0 +1,203 @@ +; tune.asm +; Playing a melody, which needs a sound device and a clock and has neither by halves. +; Written by Anachronaut +; +; ---- Two devices, because one is not enough ---- +; +; The sound device knows how to make a note and knows nothing about when. It has no timer and +; does not interrupt, so a program that only had the sound device could play a tune at +; whatever speed the machine happened to run at, which is not a tune. +; +; The screen finishes a frame sixty times a second and will say so. That is the only regular +; beat on this machine, and it is counted in the machine's own cycles, so this plays at the +; same speed whether the emulator is running at a megahertz or as fast as it can go. Every +; duration below is in frames: 30 is half a second. +; +; A programmable timer is the device that ought to be doing this, and it does not exist yet. +; Borrowing the screen's frame costs nothing and works, which is the whole reason to notice +; that a beat is a beat wherever it comes from. +; +; ---- What a patch costs and what a note costs ---- +; +; Setting the sound up is twenty-odd writes, done once before a single note is played. After +; that the inner loop is two: the note, and letting go of it. That split is what the selector +; and value registers are for - see Making A Noise in the Programming Manual. + +#Program + +start: + + ; ---- The instrument ---- + ; + ; Channel 0, selected once. Every parameter write below lands on it. + RSTA + OUTA 0x41 + + ; A saw wave, which has all the harmonics and so is the one to hear a filter on. Writing a + ; port leaves A alone, so the nothing that selected the channel also selects parameter 0, + ; which is oscillator 0's waveform. SplitLint will point out any attempt to put it there + ; twice. + OUTA 0x42 + INIA 0d2 ; Saw + OUTA 0x43 + + INIA 0x01 ; Oscillator 0, gain + OUTA 0x42 + INIA 0xFF ; All of it. A channel arrives at full gain already, so this is + OUTA 0x43 ; saying so rather than changing it. + + ; A second oscillator a little out of tune with the first, which is the oldest trick there + ; is for making one voice sound like more than one. + ; + ; SWITCHING IT ON IS A SEPARATE WRITE from setting its gain, and it is the one that matters: + ; the two oscillators are averaged rather than added, so `active` is structural. Setting a + ; gain on an oscillator that is off does nothing at all, silently, which is how the first + ; draft of this program came to have a detune in it that could not be heard. + INIA 0x15 ; Oscillator 1, on + OUTA 0x42 + INIA 0x01 + OUTA 0x43 + INIA 0x11 ; Oscillator 1, gain + OUTA 0x42 + INIA 0xC0 + OUTA 0x43 + INIA 0x13 ; Oscillator 1, detune + OUTA 0x42 + INIA 0d129 ; Centred on 128, and a step is about nine cents, so this is + ; nine cents sharp - a shimmer rather than a wrong note. + OUTA 0x43 + + ; Plucked: no attack to speak of, most of a second of decay, and nothing held. + ; + ; A sustain of nothing does NOT end the note. It goes quiet and keeps sounding, because a + ; voice holding at nothing is what a held key is. Dropping the gate is the only thing that + ; ends a note, which is why the loop below does it whether the sound has faded or not. + INIA 0x20 ; Amplitude envelope, attack + OUTA 0x42 + INIA 0d10 + OUTA 0x43 + INIA 0x21 ; Decay + OUTA 0x42 + INIA 0d120 + OUTA 0x43 + INIA 0x22 ; Sustain: nothing + OUTA 0x42 + RSTA + OUTA 0x43 + INIA 0x23 ; Release + OUTA 0x42 + INIA 0d40 + OUTA 0x43 + + ; A low pass with the modulation envelope opening it, so each note starts bright and closes + ; down. This is what the second envelope is for, and it can only be spent this way because + ; the level is shaped by the first one and not by whichever happens to be wired to the + ; output. + INIA 0x40 ; Filter, on + OUTA 0x42 + INIA 0x01 + OUTA 0x43 + INIA 0x42 ; Cutoff, low to start with + OUTA 0x42 + INIA 0d90 + OUTA 0x43 + INIA 0x43 ; A little resonance, to hear it move + OUTA 0x42 + INIA 0d150 + OUTA 0x43 + INIA 0x44 ; What opens it: the modulation envelope + OUTA 0x42 + INIA 0d2 + OUTA 0x43 + INIA 0x45 ; And how far, upwards from centre + OUTA 0x42 + INIA 0d220 + OUTA 0x43 + INIA 0x31 ; That envelope's decay, which is the sweep's length + OUTA 0x42 + INIA 0d70 + OUTA 0x43 + INIA 0x32 ; and it closes all the way + OUTA 0x42 + RSTA + OUTA 0x43 + + INIA 0xC0 ; The device's volume, with room left over the top + OUTA 0x46 + + ; ---- The beat ---- + ; + ; Ask the screen to interrupt at each frame, and let interrupts in. The screen does not do + ; this unless it is asked. + INIA 0x01 + OUTA 0x35 + SIF + + ; ---- The tune ---- + ; + ; Data Pointer 0 walks the table, and nothing in this loop is a CALL, so it stays where it + ; was left without being saved anywhere. + SETD.0 Tune + +nextNote: + LDA.0 ; The note. Zero is the end of the tune. + BRA finished + OUTA 0x44 ; Writing the note is what starts it. + INCD.0 + LDB.0 ; How many frames it lasts. + INCD.0 + +holdNote: + WAIT ; Nothing at all until the screen says a frame has gone by. + DECB + BNB holdNote + + ; Let go. The note is already fading on its own decay, but dropping the gate is what a + ; keyboard does and what the release time is waiting for. + RSTA + OUTA 0x45 + BRI nextNote + +finished: + ; Let the last note ring out rather than cutting it off, then put the screen back the way it + ; was found - and stop asking to be interrupted before taking away what catches it. + INIB 0d45 +lastRing: + WAIT + DECB + BNB lastRing + + CIF + RSTA + OUTA 0x35 + HALT + +; Sixty times a second, and it has nothing to do. WAIT only needs something to have happened, +; and this is the something. A handler still has to exist: an interrupt with nothing installed +; to catch it is a fault. +frame: + RETI + +#Data + +; ---- Notes and how long they last ---- +; +; Pairs: a MIDI note, then a count of frames. 60 is middle C and every 12 is an octave. A zero +; note ends it, which is why there are no rests in here - a rest would want a duration with no +; note, and this table has no way to say that. Adding one is a byte of flag or a note number +; nothing plays, and this program did not need it. +Tune: + 0d60 0d15 ; C + 0d64 0d15 ; E + 0d67 0d15 ; G + 0d72 0d30 ; C, an octave up, held twice as long + 0d71 0d15 ; B + 0d67 0d15 ; G + 0d64 0d15 ; E + 0d60 0d45 ; and home + 0x00 + +#Vectors + + Boot start + Device 0x30 frame diff --git a/Source/Emulator/io.c b/Source/Emulator/io.c index 044013f..08cc227 100644 --- a/Source/Emulator/io.c +++ b/Source/Emulator/io.c @@ -7,6 +7,7 @@ #include "../Assembler/assembly.h" // For the fault vector numbers. #include "controller.h" #include "video.h" +#include "sound.h" #include "font.h" #include #include @@ -898,6 +899,9 @@ void deviceTick(unsigned long now) { // The screen blinks its cursor on the machine's own clock rather than the host's, so the // picture is the same at the same cycle count however fast anything ran. videoTick(now); + // And the sound, which makes whatever samples are due by now. On the machine's clock, + // so the same program makes the same sound in the same cycles. + soundTick(now); if (diskPending && now >= diskReadyAt) { uint8_t command = diskPending; diskPending = 0; @@ -962,6 +966,7 @@ static const DeviceRecord deviceTable[] = { { PORT_MEMORY, DEVICE_MEMORY, DEVICE_FLAG_HAS_MEMORY }, { PORT_DISK, DEVICE_DISK, DEVICE_FLAG_HAS_MEMORY }, { PORT_VIDEO, DEVICE_VIDEO, DEVICE_FLAG_HAS_MEMORY }, + { PORT_SOUND, DEVICE_SOUND, 0 }, { PORT_REGISTRY, DEVICE_REGISTRY, 0 }, }; static const int deviceCount = (int)(sizeof(deviceTable) / sizeof(deviceTable[0])); @@ -994,6 +999,9 @@ static const DeviceRecord *deviceOnPort(uint8_t port) { // Sixteen ports, one device, and the same rule again. return deviceOnPort(PORT_VIDEO); } + if (port > PORT_SOUND && port <= PORT_SOUND_TOP) { + return deviceOnPort(PORT_SOUND); + } for (int i = 0; i < deviceCount; i++) { if (deviceTable[i].port == port) { return &deviceTable[i]; @@ -1027,6 +1035,9 @@ uint8_t OutputHandler(uint8_t DataByte, uint8_t Address) { if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) { return videoWrite(DataByte, Address); } + if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) { + return soundWrite(DataByte, Address); + } // This function sends the DataByte to the appropriate place based on the Port Address. switch(Address) { case CONSOLE_DATA: @@ -1121,6 +1132,9 @@ uint8_t InputHandler(uint8_t Address) { if (Address >= PORT_VIDEO && Address <= PORT_VIDEO_TOP) { return videoRead(Address); } + if (Address >= PORT_SOUND && Address <= PORT_SOUND_TOP) { + return soundRead(Address); + } switch(Address) { case CONSOLE_DATA: // If data is sent here, it should be read from STDIN. diff --git a/Source/Emulator/io.h b/Source/Emulator/io.h index d5c5e3d..69e9179 100644 --- a/Source/Emulator/io.h +++ b/Source/Emulator/io.h @@ -202,6 +202,7 @@ void consoleSetInputHook(int (*hook)(int mayWait)); #define DEVICE_MEMORY 0x12 #define DEVICE_DISK 0x13 #define DEVICE_VIDEO 0x14 +#define DEVICE_SOUND 0x15 // What a device brings besides itself. This means memory that somebody has to register // with the controller, so the controller's own bank 2 does not count: it is already there. diff --git a/Source/Emulator/machine.c b/Source/Emulator/machine.c index e5bb23b..5cd4fee 100644 --- a/Source/Emulator/machine.c +++ b/Source/Emulator/machine.c @@ -9,6 +9,7 @@ #include "controller.h" #include "io.h" #include "video.h" +#include "sound.h" #include "utility.h" #include "../Assembler/assembly.h" #include @@ -133,6 +134,7 @@ static int machineRestart(Machine *m) { return 0; } videoReset(); + soundReset(); consoleHome(); consoleResetInput(); initializeCPU(&m->cpu, Program, Data); @@ -184,6 +186,10 @@ uint8_t machineStart(Machine *m, const EmulatorOptions *options, const char *pro // the device's, and a reset that left last program's screen up would be a reset that // did not happen. videoReset(); + soundReset(); + if (options->sound != NULL) { + soundKeepSamples(); + } consoleHome(); // The controller has to know where the memories are before anything can reach // them through it. Banks 0 and 1 are those two arrays. @@ -287,6 +293,12 @@ void machineStop(Machine *m) { if (m->options.screen != NULL) { videoWriteImage(m->options.screen); } + // Every sample the machine made, for the same reason a picture is saved: there is no + // speaker on a machine running tests, and a sound nothing can hear is a sound nothing + // can check. + if (m->options.sound != NULL) { + soundWriteSamples(m->options.sound); + } if (keyboardFile != NULL) { consoleSetInputHook(NULL); fclose(keyboardFile); diff --git a/Source/Emulator/sound.c b/Source/Emulator/sound.c new file mode 100644 index 0000000..ecd14fe --- /dev/null +++ b/Source/Emulator/sound.c @@ -0,0 +1,311 @@ +// sound.c +// The Voyager's sound device. +// Written by Anachronaut + +#include "sound.h" +#include "synth.h" +#include +#include +#include +#include + +// 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; +} diff --git a/Source/Emulator/sound.h b/Source/Emulator/sound.h new file mode 100644 index 0000000..10445d1 --- /dev/null +++ b/Source/Emulator/sound.h @@ -0,0 +1,121 @@ +// sound.h +// The Voyager's sound device. +// Written by Anachronaut + +#ifndef SOUND_H +#define SOUND_H + +#include + +// ---- What this is ---- +// +// Four channels, each one a full soundThing voice: two oscillators, two envelopes and a +// filter. A channel is asked for by number and keeps its patch between notes, which is what +// makes it hardware rather than a keyboard - channel two is channel two. +// +// ---- Why it is not thirty ports ---- +// +// A voice has some forty parameters and the machine has 256 ports, so giving each one a port +// of its own would spend a sixth of the whole address space on one device. Instead there is a +// SELECTOR AND A VALUE: say which channel, say which parameter, write it. Three writes to +// change one thing, which is the right price for something a program does when it loads a +// patch and not when it plays a note. +// +// What a program does per NOTE is cheap on purpose, because that happens in a music routine's +// inner loop: select the channel, write the note, write the gate. Three writes and no +// parameter machinery at all. +#define PORT_SOUND 0x40 +#define PORT_SOUND_TOP 0x4F + +#define SOUND_STATUS 0x40 +#define SOUND_CHANNEL 0x41 +#define SOUND_PARAMETER 0x42 +#define SOUND_VALUE 0x43 +#define SOUND_NOTE 0x44 +#define SOUND_GATE 0x45 +#define SOUND_VOLUME 0x46 + +// Set while any channel is still sounding, so a routine can wait for a note to finish +// rather than counting. +#define SOUND_STATUS_SOUNDING 0x01 + +#define SOUND_CHANNELS 4 + +// ---- The parameters ---- +// +// Grouped so that the number says which part of a voice it belongs to: the high nibble picks +// the part and the low one picks the setting. Everything is a byte, because everything on +// this machine is - what each byte means is in the manual and in soundParameter below. +#define SP_OSC0 0x00 // 0x00-0x0F, and 0x10-0x1F for the second oscillator +#define SP_OSC1 0x10 +#define SP_OSC_WAVE 0x00 +#define SP_OSC_GAIN 0x01 +#define SP_OSC_DUTY 0x02 +#define SP_OSC_DETUNE 0x03 +#define SP_OSC_OCTAVE 0x04 +#define SP_OSC_ACTIVE 0x05 +#define SP_OSC_PWM_SRC 0x06 +#define SP_OSC_PWM_DEPTH 0x07 +#define SP_OSC_DET_SRC 0x08 +#define SP_OSC_DET_DEPTH 0x09 +#define SP_OSC_GAIN_SRC 0x0A +#define SP_OSC_GAIN_DEPTH 0x0B + +#define SP_AMPENV 0x20 // 0x20-0x2F amp, 0x30-0x3F mod +#define SP_MODENV 0x30 +#define SP_ENV_ATTACK 0x00 +#define SP_ENV_DECAY 0x01 +#define SP_ENV_SUSTAIN 0x02 +#define SP_ENV_RELEASE 0x03 + +#define SP_FILTER 0x40 +#define SP_FILTER_ACTIVE 0x40 +#define SP_FILTER_TYPE 0x41 +#define SP_FILTER_CUTOFF 0x42 +#define SP_FILTER_RES 0x43 +#define SP_FILTER_CUT_SRC 0x44 +#define SP_FILTER_CUT_DEP 0x45 +#define SP_FILTER_RES_SRC 0x46 +#define SP_FILTER_RES_DEP 0x47 + +// Which source shapes the channel's level: 0 none, 1 envelope 0, 2 envelope 1, 3 and 4 the +// LFOs. Nought is the one that could not be said before - see synth.h. +#define SP_LEVEL_SOURCE 0x50 + +// The LFOs belong to the whole device rather than to a channel, so these ignore whichever +// channel is selected. +#define SP_LFO0 0x60 // 0x60-0x6F and 0x70-0x7F +#define SP_LFO1 0x70 +#define SP_LFO_ACTIVE 0x00 +#define SP_LFO_WAVE 0x01 +#define SP_LFO_RATE 0x02 + +// ---- Samples come from the machine's clock ---- +// +// Forty-eight thousand a second against a million cycles: one sample every twenty and five +// sixths, worked out in whole numbers so it never drifts. THE HOST'S CLOCK IS NOT INVOLVED, +// which is what makes a recorded sound something a test can compare - the same program makes +// the same samples in the same cycles however fast anything really ran. +#define SOUND_SAMPLE_RATE 48000 + +void soundReset(void); + +// Asks the device to keep every sample it makes, for soundWriteSamples. Off unless something +// wants a file, because a long run makes millions of them. +void soundKeepSamples(void); + +// Called with the machine's clock, and generates whatever samples are due by now. +void soundTick(unsigned long now); + +uint8_t soundWrite(uint8_t value, uint8_t port); +uint8_t soundRead(uint8_t port); + +// Takes up to `wanted` samples for something that is going to play them, and says how many +// there were. A front end with a speaker calls this; nothing else has to. +int soundTake(int16_t *into, int wanted); + +// Writes every sample generated so far to a file, as raw signed 16 bit. What --screen is for +// a picture: the only way to check a sound on a machine with no speaker. +int soundWriteSamples(const char *path); + +#endif // SOUND_H diff --git a/Source/Emulator/utility.c b/Source/Emulator/utility.c index d444796..4b1bd44 100644 --- a/Source/Emulator/utility.c +++ b/Source/Emulator/utility.c @@ -34,6 +34,9 @@ void printHelp(const char *programName) { printf(" keyboard rather than a terminal. Which means the console does\n"); printf(" its own line editing, the way it must when a window is open\n"); printf(" and there is no terminal behind it to do it.\n"); + printf(" -N, --sound FILE Save every sample the machine made, as raw signed 16 bit\n"); + printf(" at 48kHz. What --screen is for a picture: the only way to\n"); + printf(" check a sound on a machine with no speaker.\n"); printf(" -h, --help Display this help message.\n"); } @@ -47,6 +50,7 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { {"disk-cycles", required_argument, 0, 'L'}, {"screen", required_argument, 0, 'S'}, {"keyboard", required_argument, 0, 'K'}, + {"sound", required_argument, 0, 'N'}, {"help", no_argument, 0, 'h'}, {0, 0, 0, 0 } }; @@ -61,9 +65,10 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { options->diskCycles = 0; options->screen = NULL; options->keyboard = NULL; + options->sound = NULL; // Parse options - while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:", long_options, &option_index)) != -1) { + while ((opt = getopt_long(argc, argv, "dc:fhD:WL:S:K:N:", long_options, &option_index)) != -1) { switch (opt) { case 'd': options->debug = 1; @@ -99,6 +104,9 @@ uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options) { case 'K': options->keyboard = optarg; break; + case 'N': + options->sound = optarg; + break; case 'h': printHelp(argv[0]); return OPTIONS_HELP; diff --git a/Source/Emulator/utility.h b/Source/Emulator/utility.h index a716e47..cffcb30 100644 --- a/Source/Emulator/utility.h +++ b/Source/Emulator/utility.h @@ -24,6 +24,7 @@ typedef struct { uint8_t writeProtect; // Attach the disk read only, the way a tab on a floppy would. const char *screen; // Where to save a picture of the screen when the machine stops. const char *keyboard; // Feed the console from this file as a keyboard, not a terminal. + const char *sound; // Where to save the samples the machine made, as raw 16 bit. } EmulatorOptions; uint8_t parseOptions(int argc, char *argv[], EmulatorOptions *options); diff --git a/SplitBit Programming Manual.md b/SplitBit Programming Manual.md index b949d9b..c53a85e 100644 --- a/SplitBit Programming Manual.md +++ b/SplitBit Programming Manual.md @@ -513,6 +513,7 @@ If nothing is installed for the vector a device refused with, the machine stops | 0x13 | The machine itself. Writing 1 asks it to start over: whatever put the first instruction in memory does it again, and the CPU begins where the boot vector points. A port rather than a service, because a reset has to work when the system does not - and a program that owns the whole machine has no system to ask. The disk is not unplugged and keeps what was written to it; the vector table is cleared, because a handler left behind would aim an interrupt into a program that is no longer running. | 0x04 | | 0x12 | A device that owns 256 bytes of memory. Writing to its port fills that memory with the byte written, standing in for a disk controller reading a sector. Its memory is unreachable until it is registered as a bank. | 0x12 | | 0x30 - 0x3F | The screen. See The Screen. It brings video memory, which is unreachable until it is registered as a bank. | 0x14 | +| 0x40 - 0x4F | The sound device. See Making A Noise. Four channels, played by writing to ports; it brings no memory. | 0x15 | | 0xE0 - 0xEF | The memory controller. See The Memory Controller. | 0x03 | | 0xFF | The bus registry. See Asking What Is There. | 0x01 | @@ -677,6 +678,200 @@ What a program on the other end of an actual serial line sees is a different que **It is one screen.** A program that writes its own tiles and its own map has taken the screen, and a console still writing characters into it will scribble on what that program drew. This is not an oversight to be worked around - it is what one screen means, and it is why a program that wants the screen takes it. +## Making A Noise: + +Four channels on ports 0x40 to 0x4F. Each one is a whole voice - two oscillators, two +envelopes, a filter and the routing between them - and it keeps its settings between notes. +Channel two is channel two: a program sets up a sound once and then plays it, the same way it +sets up a tile once and then places it. + +### Why It Is Six Ports And Not Forty: + +A voice has around forty settings and there are four of them, so a port for each would spend +more than half of the machine's whole port space on one device. Instead there is a **selector +and a value**: say which channel, say which setting, write it. Three writes to change one +thing. + +That is the right price because of *when* a program pays it. Patches are loaded; notes are +played. Changing a setting happens when a program starts or when an instrument changes, and +three writes there costs nothing anybody can hear. Playing a note happens in the inner loop of +a music routine, and that is two writes with no selector machinery at all. + +### Registers: + +| Port | Register | +| --- | --- | +| 0x40 | Status. Bit 0, some channel is still sounding. | +| 0x41 | Channel, 0 to 3. Anything larger wraps, so a program cannot select a channel that is not there. | +| 0x42 | Which setting the next write to 0x43 means. | +| 0x43 | The value of that setting, for the selected channel. | +| 0x44 | Note. Writing a MIDI note number **starts it**: 60 is middle C, and every 12 is an octave. | +| 0x45 | Gate. Writing zero releases the note and lets it fade; writing anything else starts the last note again. | +| 0x46 | Volume, for the whole device. | + +Reading 0x41, 0x42 and 0x44 gives back what is in them, so a routine can save and restore the +selection around an interrupt. + +### The Shortest Program That Makes A Sound: + +``` + RSTA + OUTA 0x41 ; Channel 0 + INIA 0d60 + OUTA 0x44 ; Middle C, which starts it +``` + +**Every channel arrives able to make a sound**: one oscillator switched on at full gain, a +plain triangle wave, an envelope that fades in and holds. Writing a note number is the whole +of playing a note, and a program only reaches for the settings when it wants a different +sound rather than a sound at all. + +The second oscillator arrives switched off, and that is not the same as arriving silent. **The +two oscillators are averaged rather than added**, so switching the second one on halves the +first whatever gain it has - which is what keeps two of them from clipping, and which means +there is no setting of `active` that costs nothing. One oscillator is the plain case, and +asking for two is something a program says out loud: + +``` + INIA 0x15 + OUTA 0x42 ; Oscillator 1, on + INIA 0x01 + OUTA 0x43 + INIA 0x11 + OUTA 0x42 ; and how loud + INIA 0xC0 + OUTA 0x43 +``` + +### Settings: + +The high nibble says which part of the voice, the low nibble which setting of it. + +| Number | Part | +| --- | --- | +| 0x00 - 0x0F | Oscillator 0. | +| 0x10 - 0x1F | Oscillator 1. | +| 0x20 - 0x2F | The amplitude envelope. | +| 0x30 - 0x3F | The modulation envelope. | +| 0x40 - 0x4F | The filter. | +| 0x50 | What shapes the channel's level. | +| 0x60 - 0x6F | LFO 0. | +| 0x70 - 0x7F | LFO 1. | + +| Oscillator | Setting | +| --- | --- | +| 0 | Waveform: 0 sine, 1 triangle, 2 saw, 3 ramp, 4 pulse, 5 noise. Anything larger wraps. | +| 1 | Gain. Silent at zero, which is where it starts. | +| 2 | Pulse width, for the pulse wave. | +| 3 | Detune, centred on 128, an octave either way. A step is about nine cents. | +| 4 | Octave, centred on 128, two either way. | +| 5 | On, or off at zero. | +| 6, 7 | What modulates the pulse width, and how much. | +| 8, 9 | What modulates the detune, and how much. | +| 10, 11 | What modulates the gain, and how much. | + +| Envelope | Setting | +| --- | --- | +| 0 | Attack. | +| 1 | Decay. | +| 2 | Sustain, the level it holds at while the note is held. | +| 3 | Release. | + +| Filter | Setting | +| --- | --- | +| 0x40 | On, or off at zero. | +| 0x41 | Type: 0 low pass, 1 high pass, 2 band pass. Anything larger wraps. | +| 0x42 | Cutoff. | +| 0x43 | Resonance. | +| 0x44, 0x45 | What modulates the cutoff, and how much. | +| 0x46, 0x47 | What modulates the resonance, and how much. | + +| LFO | Setting | +| --- | --- | +| 0 | On, or off at zero. | +| 1 | Waveform, from the same six. | +| 2 | Rate. | + +Anywhere a setting asks *what modulates* something, the answer is one of these: + +| Value | Source | +| --- | --- | +| 0 | Nothing. | +| 1 | The amplitude envelope. | +| 2 | The modulation envelope. | +| 3 | LFO 0. | +| 4 | LFO 1. | + +**The two LFOs belong to the device and not to a channel**, so writing 0x60 to 0x7F ignores +whichever channel is selected. That is what makes them useful: a vibrato that every voice +shares is one wobble rather than four that drift apart. + +### What A Byte Means: + +Everything here is a byte, and a synthesizer wants seconds and hertz. How the one becomes the +other is chosen for **where the useful part of the range is**, not for whatever arithmetic is +tidiest. + +| Kind of setting | 0 to 255 becomes | +| --- | --- | +| Times: attack, decay, release | Nought to four seconds, squared. | +| Levels: gain, sustain, resonance, volume | Nought to the most there is, evenly. | +| Cutoff, LFO rate | 20 Hz to 20 kHz, and 0.05 Hz to 20 Hz: exponential. | +| Detune, octave, and every modulation depth | Centred on 128, so half is no change and either side is a direction. | + +Times are squared because the difference between five and fifty milliseconds is the whole +character of a percussive sound, and the difference between three seconds and four is nothing +anybody can hear. A byte spread evenly over four seconds would spend nine tenths of itself on +the part that does not matter. Cutoff and rate are exponential for the same reason, since +pitch is logarithmic and so is where a filter sounds like it is. + +### Level: + +Setting 0x50 says what shapes the channel's level, out of the same list of sources. It is +normally the amplitude envelope, which is what an amplitude envelope is for, and it can be set +to **nothing** - a channel whose level nothing shapes plays flat out until it is gated off. + +That sounds like a small thing and is not. Without it the amplitude envelope is welded to the +output, so an envelope routed somewhere useful - opening the filter, bending a pitch - still +has to be shaped like something you would want to hear, and a snare that wants a click of +filter sweep and a flat body cannot have both. + +### Knowing When It Has Finished: + +The status port's bit 0 is set while any channel is still sounding, so a routine can wait for +a sound to end rather than counting cycles. + +There is one rule about when a note ends, and it is worth stating on its own because the +obvious guess is wrong. **A note sounds until the gate is dropped.** What the envelope is +doing does not come into it. + +In particular, a note whose sustain is nothing goes quiet and *keeps sounding*. Silence and +being finished look identical from outside and are not the same thing: the voice is holding at +nothing, which is exactly what a held key does on any instrument. A program that plays such a +note and then waits for the status bit waits for ever. + +So a routine that means to wait for a sound does this, in this order: play the note, wait +however long the note is meant to last, write nothing to the gate at 0x45, and *then* wait for +the bit to come down - which it does when the release has finished. `Programs/Examples/tune.asm` +is that loop with the waiting done on the screen's frame. + +### The Sound Comes From The Machine's Clock: + +Samples are made against cycles, not against however fast the host really ran: forty-eight +thousand a second of *emulated* time, worked out in whole numbers so it never drifts. Three +million cycles make exactly one hundred and forty-four thousand samples. + +This is the same decision as the screen writing a picture out, and it buys the same thing. A +sound is **something a test can compare**: the same program makes the same samples every time, +on any host, at any speed, and `Tests/sound.sh` reads them back and measures the pitch. It +also means a machine that is paused makes no sound rather than a held note, which is right - a +stopped machine's oscillators are stopped too. + +**The device does not interrupt.** Nothing about a note finishing needs the CPU's attention +urgently enough to be worth a line, and a program that wants to play in time has the screen's +frame interrupt, which is 60 a second and already there. A programmable timer is the proper +answer and is a device that does not exist yet. + ## Asking What Is There: A program that only ever runs on one machine can be told where everything is. A program meant to run on more than one has to ask, and the bus registry on port 0xFF is what it asks. @@ -718,7 +913,9 @@ One thing to be careful of: the registry remembers which port it was asked about | 0x11 | Test device, which refuses everything. | | 0x12 | Test device, which owns memory. | | 0x13 | Disk. | -| 0x14 - 0xFF | Peripherals. | +| 0x14 | Screen. | +| 0x15 | Sound. | +| 0x16 - 0xFF | Peripherals. | ## The Memory Controller: diff --git a/SplitBit Test Manual.md b/SplitBit Test Manual.md index 9196615..ae7db99 100644 --- a/SplitBit Test Manual.md +++ b/SplitBit Test Manual.md @@ -9,7 +9,7 @@ believe them. ## What The Suite Claims: -The suite is not one thing. It is ten scripts making five different kinds of claim, and +The suite is not one thing. It is eleven scripts making five different kinds of claim, and knowing which claim you are relying on is the whole point of this document. A recorded transcript and a byte-for-byte comparison against a second implementation both print `[ok ]`, and they are worth wildly different amounts. @@ -58,6 +58,7 @@ Individual scripts can be run on their own, from anywhere: ./Tests/disk.sh The disk tool against the format. ./Tests/cycles.sh What the memory controller charges. ./Tests/video.sh What the video device draws. +./Tests/sound.sh What the sound device makes. ./Tests/terminal.sh The things a recorded file cannot see. ./Tests/native.sh The two assemblers against each other. ./Tests/agree.sh The two filesystems against each other. @@ -78,7 +79,7 @@ from `make`, not from here. ### 1. Recorded output `Tests/run.sh` assembles each program named in `Tests/manifest`, runs it, and compares -everything it printed against a file in `Tests/expected`. 169 tests, of which 107 run, 35 +everything it printed against a file in `Tests/expected`. 170 tests, of which 108 run, 35 only assemble, 16 are expected to fail to assemble, and 11 boot from ROM with no image given at all. @@ -158,7 +159,26 @@ the palette is what colours it, that the attribute nibble adds sixteen, that scr which row is on top, that the map wraps, and that an impossible mode is refused without stopping the machine. -**Half of it is about the console rather than the device.** Those programs ask the video +`Tests/sound.sh` is the same argument again, one device along: the suite has no speaker, and +a sound nothing can hear is a sound nothing checks. Its samples come from the machine's clock +rather than the host's, so `--sound` writes a file that is a pure function of the program and +the cycle count - a million cycles is exactly forty-eight thousand samples, on any host, at +any speed. The checks read that file back and measure it: that a note is the note that was +asked for, that twelve of them is an octave, that gain is a level and volume is over the top +of it, and that two runs are identical byte for byte. + +**It found three real defects the first time it ran**, all of them 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 +while every other one arrived at nothing. And a note with no sustain is silent but not over, +so the obvious way to wait for a sound to end waits for ever. + +The last of those had already caught the person writing the device, an hour before the check +existed, and had been written into the manual as advice. That is the argument for a suite in +one sentence: **the check disagreed with the documentation, and the check was right.** + +**Half of `video.sh` is about the console rather than the device.** Those programs ask the video device for nothing at all: they write bytes to port 0x00, the way every SplitBit program always has, and the picture is what is checked. A character lands at the cursor and the cursor moves along, a newline starts the next row, backspace rubs out, the line wraps at the diff --git a/Tests/expected/tune.out b/Tests/expected/tune.out new file mode 100644 index 0000000..6062395 --- /dev/null +++ b/Tests/expected/tune.out @@ -0,0 +1,2 @@ +Execution halted. +[exit 0] diff --git a/Tests/manifest b/Tests/manifest index 25fc346..64fc7c6 100644 --- a/Tests/manifest +++ b/Tests/manifest @@ -52,6 +52,11 @@ frames | Examples/frames.asm | run | - # picture draws a whole 320 by 200 bitmap and prints nothing, so what is recorded is only # that it ran and what it cost. Tests/video.sh is where the pixels are checked. picture | Examples/picture.asm | run | - | - +# tune plays eight notes and prints nothing, so what is recorded is that it ran, that it +# finished on its own, and how much of its time it spent asleep - which is nearly all of it, +# because the tempo is the screen's frame and a program waiting on a frame is not using the +# bus. Tests/sound.sh is where the samples are checked. +tune | Examples/tune.asm | run | - | 5000000 8bitSieve | Examples/primeSieve/8bitSieve.asm | run | - | - 16bitSegmentedSieve | Examples/primeSieve/16bitSieve.asm | run | - | - # The four pointer rewrite. It emits exactly the same primes as the line above, which diff --git a/Tests/sound.sh b/Tests/sound.sh new file mode 100755 index 0000000..7b0b9fc --- /dev/null +++ b/Tests/sound.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# Checks what the sound device actually makes. +# +# THE SUITE HAS NO SPEAKER, and a sound nothing can hear is a sound nothing checks. So the +# device makes its samples against the machine's clock rather than the host's, and the machine +# can be asked to save them with --sound. Every check below runs a program for a fixed number +# of cycles, saves the samples and measures them - no audio hardware, no timing luck, and the +# same answer every time. +# +# This is the same argument as Tests/video.sh, and it has the same consequence: each check is +# a named claim about one behaviour rather than a comparison against a recorded waveform. A +# recorded waveform would say "it sounds different" and leave which of the oscillator, the +# envelope, the filter, the channel selector or the sample clock broke to be found by ear. +# +# Written by Anachronaut + +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD="$ROOT/Tests/build/sound" +ASM="$ROOT/Assembler" +EMU="$ROOT/SplitBit" + +for tool in "$ASM" "$EMU"; do + [ -x "$tool" ] || { echo "$(basename "$tool") is not built."; exit 1; } +done + +rm -rf "$BUILD"; mkdir -p "$BUILD" + +PASS=0 +FAIL=0 +FAILED_NAMES=() + +GREEN=$'\033[32m'; RED=$'\033[31m'; RESET=$'\033[0m' +[ -t 1 ] || { GREEN=""; RED=""; RESET=""; } + +result() { + # result + if [ "$1" = "ok" ]; then + PASS=$((PASS + 1)); printf " [%sok %s] %-40s %s\n" "$GREEN" "$RESET" "$2" "$3" + else + FAIL=$((FAIL + 1)); FAILED_NAMES+=("$2") + printf " [%sFAIL%s] %-40s %s\n" "$RED" "$RESET" "$2" "$3" + fi +} + +# ---- Writing to the device from a program ---- + +port() { + # port + printf ' INIA 0x%02X\n OUTA 0x%02X\n' $(( $2 & 0xFF )) $(( $1 & 0xFF )) +} + +param() { + # param - the selector and value pair, which is three writes. + printf ' INIA 0x%02X\n OUTA 0x42\n INIA 0x%02X\n OUTA 0x43\n' \ + $(( $1 & 0xFF )) $(( $2 & 0xFF )) +} + +# The one write a sounding program cannot skip: an oscillator arrives silent. +loud() { + param 0x01 0xFF +} + +# About a tenth of a second each hundred, in cycles: the inner loop is a DECA and a BNA, which +# is four cycles, 256 times round. The tag is so that more than one can sit in a program. +pause() { + # pause + printf ' INIB 0d%d\npauseOuter%s:\n RSTA\npauseInner%s:\n DECA\n BNA pauseInner%s\n DECB\n BNB pauseOuter%s\n' \ + "$2" "$1" "$1" "$1" "$1" +} + +# Says whether anything is sounding, as a letter, because the status bit is not a character +# and a test reads the console. +sounding() { + # sounding + printf ' INA 0x40\n INIB 0x01\n AND\n BRQ quiet%s\n INIA 0d89\n OUTA 0x00\n BRI after%s\nquiet%s:\n INIA 0d78\n OUTA 0x00\nafter%s:\n' \ + "$1" "$1" "$1" "$1" +} + +# Most programs here never finish on purpose: they set a sound going and then spin, and the +# cycle limit decides how long was recorded. That makes the sample count exact rather than +# dependent on how long the program took to get there. +spinForever() { + printf 'spinEnd:\n BRI spinEnd\n' +} + +epilogue() { + printf ' HALT\n#Vectors\n Boot start\n' +} + +# Assembles what is on standard input, runs it for a fixed number of cycles, and leaves the +# samples in $BUILD/.raw. +run() { + # run + local name="$1" + cat > "$BUILD/$name.asm" + "$ASM" "$BUILD/$name.asm" -o "$BUILD/$name.bin" >"$BUILD/$name.log" 2>&1 || { + echo "could not assemble $name"; sed 's/^/ /' "$BUILD/$name.log"; return 1; } + timeout 20 "$EMU" --fast --cycles "$2" --sound "$BUILD/$name.raw" "$BUILD/$name.bin" \ + > "$BUILD/$name.out" 2>&1 + if [ $? -eq 124 ]; then + echo " $name did not finish within twenty seconds"; return 1 + fi + return 0 +} + +# ---- Reading the samples back ---- +# +# Raw signed 16 bit, little endian, which is what --sound writes. +measure() { + # measure [from] [to] - samples are given in samples, not seconds. + python3 - "$BUILD/$1.raw" "$2" "${3:-0}" "${4:-0}" <<'PY' +import struct, sys +data = open(sys.argv[1], "rb").read() +count = len(data) // 2 +values = struct.unpack("<%dh" % count, data) +what = sys.argv[2] +start = int(sys.argv[3]) +stop = int(sys.argv[4]) or count +window = values[start:stop] + +if what == "count": + print(count) +elif what == "peak": + print(max((abs(v) for v in window), default=0)) +elif what == "pitch": + # ---- Counted with hysteresis, not on the sign ---- + # + # A waveform fading through nothing crosses zero many times on its way, and counting + # every sign change would hear a decaying note as a very high one. So a crossing is only + # counted after the signal has been convincingly on one side: above a tenth of the peak, + # then below minus a tenth. + peak = max((abs(v) for v in window), default=0) + if peak == 0: + print("0.0") + else: + gate = peak // 10 + crossings, side = 0, 0 + for v in window: + if side <= 0 and v > gate: + side = 1 + crossings += 1 + elif side >= 0 and v < -gate: + side = -1 + crossings += 1 + print("%.1f" % (crossings / 2.0 / (len(window) / 48000.0))) +PY +} + +# A number against another, within a percentage of it. +near() { + # near + python3 -c "import sys; a,b,p = (float(x) for x in sys.argv[1:4]); sys.exit(0 if b and abs(a-b) <= b*p/100.0 else 1)" \ + "$1" "$2" "$3" +} + +said() { + grep -q -- "$2" "$BUILD/$1.out" +} + +echo "Checking what the sound device makes." +echo + +# ---- Nothing, until something asks ---- +# +# A machine that hummed on its own would make every check below meaningless, and the samples +# would still all be there to count. +{ printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \ + | run quiet 1000000 || exit 1 +PEAK="$(measure quiet peak)" +[ "$PEAK" = "0" ] \ + && result ok "silent until something asks" "every sample is nothing" \ + || result no "silent until something asks" "peak was $PEAK" + +# ---- The sample clock is the machine's clock ---- +# +# Forty-eight thousand a second against a million cycles. This is the claim the whole file +# rests on: if samples came from the host, nothing below would be reproducible. +COUNT="$(measure quiet count)" +[ "$COUNT" = "48000" ] \ + && result ok "samples keep the machine's time" "1,000,000 cycles made exactly 48,000" \ + || result no "samples keep the machine's time" "1,000,000 cycles made $COUNT, wanted 48,000" + +{ printf '#Program\nstart:\n'; spinForever; printf '#Vectors\n Boot start\n'; } \ + | run quietLonger 3000000 || exit 1 +COUNT="$(measure quietLonger count)" +[ "$COUNT" = "144000" ] \ + && result ok "and keeps it over a longer run" "3,000,000 cycles made exactly 144,000" \ + || result no "and keeps it over a longer run" "3,000,000 cycles made $COUNT, wanted 144,000" + +# ---- A channel arrives able to make a sound ---- +# +# Writing a note number and hearing that note is the shortest useful thing this device can be +# asked to do, and it works from a cold start. soundThing's own defaults do not do this - they +# are a patch editor's, where one voice is set up and the rest wait to be copied over - so the +# device sets its own power-on state and this is the check that it did. +{ printf '#Program\nstart:\n'; port 0x41 0x00; port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run bareNote 1000000 || exit 1 +PEAK="$(measure bareNote peak)" +[ "$PEAK" -gt 1000 ] \ + && result ok "a channel arrives ready to sound" "a note and nothing else made $PEAK" \ + || result no "a channel arrives ready to sound" "peak was only $PEAK" + +# ---- And the second oscillator arrives off ---- +# +# Which is not the same thing as silent, because the two are averaged rather than added: one +# that is switched on halves the other whatever its gain. So the plain case is one oscillator, +# and asking for two is a thing a program says out loud. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x15 0x01 # oscillator 1, switched on + param 0x11 0x00 # and silent + port 0x44 60; spinForever; printf '#Vectors\n Boot start\n'; } | run twoOscs 1000000 || exit 1 + +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run middleC 1000000 || exit 1 +BOTH="$(measure twoOscs peak)" +ONE="$(measure middleC peak)" +near "$BOTH" "$((ONE / 2))" 10 \ + && result ok "two oscillators share the level" "$ONE alone, $BOTH with a silent one beside it" \ + || result no "two oscillators share the level" "$ONE alone and $BOTH with a second, wanted about half" +PEAK="$(measure middleC peak)" +[ "$PEAK" -gt 1000 ] \ + && result ok "a note makes a sound" "peak $PEAK" \ + || result no "a note makes a sound" "peak was only $PEAK" + +# ---- The note it was asked for ---- +# +# 60 is middle C, 261.63 Hz. Two per cent is well inside a semitone, which is six. +PITCH="$(measure middleC pitch 24000)" +near "$PITCH" 261.63 2 \ + && result ok "and it is the note asked for" "$PITCH Hz, middle C is 261.63" \ + || result no "and it is the note asked for" "$PITCH Hz, wanted 261.63" + +# ---- Twelve is an octave ---- +# +# The pitch check above would pass on a device that played one fixed tone. This one would not. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 72; spinForever + printf '#Vectors\n Boot start\n'; } | run octaveUp 1000000 || exit 1 +OCTAVE="$(measure octaveUp pitch 24000)" +near "$OCTAVE" 523.25 2 \ + && result ok "twelve notes up is an octave" "$OCTAVE Hz, twice $PITCH" \ + || result no "twelve notes up is an octave" "$OCTAVE Hz, wanted 523.25" + +# ---- The same program makes the same sound ---- +# +# The point of a clock that is not the host's. Without this every check above is a check on +# how busy the machine running the suite happened to be. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run middleCAgain 1000000 || exit 1 +cmp -s "$BUILD/middleC.raw" "$BUILD/middleCAgain.raw" \ + && result ok "the same program makes the same sound" "two runs, byte for byte" \ + || result no "the same program makes the same sound" "the two runs differ" + +# ---- Gain is a level and not a switch ---- +{ printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run quarterGain 1000000 || exit 1 +FULL="$(measure middleC peak)" +QUARTER="$(measure quarterGain peak)" +near "$QUARTER" "$((FULL / 4))" 20 \ + && result ok "gain sets the level" "a quarter of $FULL is $QUARTER" \ + || result no "gain sets the level" "a quarter of $FULL came out $QUARTER" + +# ---- And the device's own volume is over the top of it ---- +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud; port 0x46 0x00; port 0x44 60 + spinForever; printf '#Vectors\n Boot start\n'; } | run noVolume 1000000 || exit 1 +PEAK="$(measure noVolume peak)" +[ "$PEAK" = "0" ] \ + && result ok "volume nothing is silence" "a full note at volume nothing makes nothing" \ + || result no "volume nothing is silence" "peak was $PEAK" + +# ---- A channel is its own voice ---- +# +# Turn channel 0's gain down, then play on channel 1. Channel 1 should still be at the gain it +# arrived with, and anything else would mean the settings are the device's rather than the +# channel's - the difference between four voices and one that four things fight over. +# +# This is deliberately a comparison of LEVELS and not a check that channel 1 is silent. It was +# the latter once, and it passed for a year's worth of the wrong reason: channel 1 could not +# make a sound at all, so a device with one shared set of settings would have passed it too. +{ printf '#Program\nstart:\n'; port 0x41 0x00; param 0x01 0x40; port 0x41 0x01; port 0x44 60 + spinForever; printf '#Vectors\n Boot start\n'; } | run otherChannel 1000000 || exit 1 +QUIETED="$(measure quarterGain peak)" +OTHER="$(measure otherChannel peak)" +near "$OTHER" "$FULL" 5 \ + && result ok "a channel keeps its own settings" "channel 0 turned down to $QUIETED, channel 1 still $OTHER" \ + || result no "a channel keeps its own settings" "channel 1 came out $OTHER, channel 0's own gain gives $QUIETED" + +# ---- All four of them are there ---- +{ printf '#Program\nstart:\n'; port 0x41 0x03; port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run lastChannel 1000000 || exit 1 +PEAK="$(measure lastChannel peak)" +[ "$PEAK" -gt 1000 ] \ + && result ok "the fourth channel is a channel" "peak $PEAK" \ + || result no "the fourth channel is a channel" "peak was only $PEAK" + +# ---- Asking for a channel that is not there ---- +# +# It wraps rather than faulting or writing past the end of the voices. A sound device is a +# poor place to stop the machine, and a poor place to corrupt memory. +{ printf '#Program\nstart:\n'; port 0x41 6 + printf ' INA 0x41\n INIB 0d48\n CCF\n ADD\n OUTQ 0x00\n' + epilogue; } | run wrapChannel 200000 || exit 1 +said wrapChannel "2" \ + && result ok "a channel number wraps" "6 selected channel 2" \ + || result no "a channel number wraps" "$(cat "$BUILD/wrapChannel.out")" + +# ---- The registers read back ---- +# +# So a handler can save the selection and put it back, which it has to, since an interrupt in +# the middle of a patch load would otherwise land the rest of the patch on another channel. +{ printf '#Program\nstart:\n'; port 0x42 0x2A + printf ' INA 0x42\n OUTA 0x00\n'; epilogue; } | run readBack 200000 || exit 1 +said readBack '\*' \ + && result ok "the selectors read back" "0x2A came back as itself" \ + || result no "the selectors read back" "$(od -c "$BUILD/readBack.out" | head -1)" + +# ---- Status: nothing is sounding until something is ---- +{ printf '#Program\nstart:\n'; sounding Before + port 0x41 0x00; loud; port 0x44 60; sounding After; epilogue; } \ + | run statusBit 500000 || exit 1 +said statusBit "NY" \ + && result ok "status says what is sounding" "nothing before the note, something after" \ + || result no "status says what is sounding" "said $(cat "$BUILD/statusBit.out")" + +# ---- Not even a sustain of nothing ends a note ---- +# +# THE TRAP, and it caught the person writing this device before it caught anybody else. A +# sustain of nothing is silent, and silence looks exactly like a finished note, so the obvious +# way to play a note and wait for it is to give it no sustain and watch the status bit. It +# never comes down: the voice is holding at nothing, which is a thing a held key does. +# +# There is one rule and this is the check that there are not two. A NOTE SOUNDS UNTIL IT IS +# GATED OFF. What the envelope is doing does not enter into it. +{ printf '#Program\nstart:\n'; port 0x41 0x00 + param 0x20 0x00 # attack: none + param 0x21 40 # decay: short + param 0x22 0x00 # sustain: nothing at all + port 0x44 60 + printf 'waitOut:\n INA 0x40\n INIB 0x01\n AND\n BNQ waitOut\n' + printf ' INIA 0d90\n OUTA 0x00\n' # 'Z', reached only if the bit came down + epilogue; } | run silentSustain 3000000 || exit 1 +said silentSustain "Z" \ + && result no "silence is not the end of a note" "the bit came down without a gate off" \ + || result ok "silence is not the end of a note" "silent for three million cycles and still sounding" + +# And the sound really did go quiet, so the check above is about the status bit rather than +# about an envelope that never decayed. +PEAK="$(measure silentSustain peak 24000)" +[ "$PEAK" -lt 100 ] \ + && result ok "even though there is nothing to hear" "faded to $PEAK while still sounding" \ + || result no "even though there is nothing to hear" "still $PEAK, it never decayed" + +# ---- A note that is held is not one ---- +# +# The trap the manual warns about: a note with sustain sounds until the gate is dropped, so a +# program that waits for it waits for ever. Here the wait is bounded by the cycle limit, and +# the marker not being printed is the whole point. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x22 0xFF # sustain: all of it + port 0x44 60 + printf 'held:\n INA 0x40\n INIB 0x01\n AND\n BNQ held\n' + printf ' INIA 0d90\n OUTA 0x00\n' + epilogue; } | run heldNote 3000000 || exit 1 +said heldNote "Z" \ + && result no "a held note keeps sounding" "the status bit came down on its own" \ + || result ok "a held note keeps sounding" "still sounding after three million cycles" + +# ---- And dropping the gate is what ends it ---- +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x22 0xFF # sustain: all of it + param 0x23 20 # release: short + port 0x44 60 + pause A 100 + port 0x45 0x00 # let go + printf 'released:\n INA 0x40\n INIB 0x01\n AND\n BNQ released\n' + printf ' INIA 0d90\n OUTA 0x00\n' + epilogue; } | run gateOff 3000000 || exit 1 +said gateOff "Z" \ + && result ok "dropping the gate ends it" "the note released and the bit came down" \ + || result no "dropping the gate ends it" "it was still sounding at the cycle limit" + +# ---- The level can be shaped by nothing at all ---- +# +# soundThing welded the first envelope to the output, so an envelope spent on a filter sweep +# still had to be shaped like something worth hearing. Setting 0x50 to nothing is what unwelds +# it. The check is that an amplitude envelope which decays to silence immediately does NOT +# silence a channel whose level nothing shapes. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x20 0x00 # attack: none + param 0x21 20 # decay: very short + param 0x22 0x00 # sustain: nothing, so the envelope is at zero + param 0x50 0x00 # and nothing shapes the level + port 0x44 60 + spinForever; printf '#Vectors\n Boot start\n'; } | run levelSource 1000000 || exit 1 +LATE="$(measure levelSource peak 36000)" +[ "$LATE" -gt 1000 ] \ + && result ok "the level can be shaped by nothing" "still $LATE long after the envelope let go" \ + || result no "the level can be shaped by nothing" "peak $LATE, the envelope silenced it anyway" + +# And the same patch with the envelope back on the level is silent by then, which is what +# makes the check above about the setting rather than about the envelope not working. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x20 0x00; param 0x21 20; param 0x22 0x00 + param 0x50 1 # the amplitude envelope, which is the normal case + port 0x44 60 + spinForever; printf '#Vectors\n Boot start\n'; } | run levelEnvelope 1000000 || exit 1 +LATE="$(measure levelEnvelope peak 36000)" +[ "$LATE" -lt 100 ] \ + && result ok "and by the envelope, which is the normal case" "faded to $LATE" \ + || result no "and by the envelope, which is the normal case" "still $LATE, it never faded" + +echo +if [ "$FAIL" -eq 0 ]; then + echo "All $PASS sound checks passed." + exit 0 +fi +echo "$PASS passed, $FAIL failed: ${FAILED_NAMES[*]}" +exit 1 diff --git a/makefile b/makefile index d63bbb9..c41178d 100644 --- a/makefile +++ b/makefile @@ -38,7 +38,7 @@ OBJ_DIR = Object # SplitBit from Voyager is one file each: a terminal or a window. Anything that drifts out # of the shared list and into one of those is behaviour the other does not have, which is # the thing this split exists to prevent. -MACHINE_SRCS = machine.c io.c controller.c video.c font.c utility.c cpu.c bootstrap.c assembly.c rom.c +MACHINE_SRCS = machine.c io.c controller.c video.c font.c sound.c synth.c utility.c cpu.c bootstrap.c assembly.c rom.c EMU_SRCS = emulator.c $(MACHINE_SRCS) VOY_SRCS = voyager.c $(MACHINE_SRCS) ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c @@ -70,6 +70,9 @@ LINT_TARGET = SplitLint RAYLIB_CFLAGS := $(shell pkg-config --cflags raylib 2>/dev/null) RAYLIB_LIBS := $(shell pkg-config --libs raylib 2>/dev/null) ifeq ($(strip $(RAYLIB_LIBS)),) +# The -lm is not spare, even though MATHLIB names it again on the link line below. This +# variable is what the probe underneath test-links with, and Raylib does not link without it, +# so taking it out here does not tidy a duplicate - it makes the probe say Raylib is missing. RAYLIB_LIBS := -lraylib -lm endif HAVE_RAYLIB := $(shell printf '#include \nint main(void){return (int)GetTime();}\n' \ @@ -121,14 +124,18 @@ $(SRC_DIR_EMU)/rom.c: Programs/Boot/stage1.asm $(ASM_TARGET) $(OBJ_DIR)/rom.o: $(SRC_DIR_EMU)/rom.c $(SRC_DIR_EMU)/rom.h +# The maths library, which the machine wants now that it has a synthesizer in it: the voice +# engine works in hertz and seconds and reaches for powf and tanf to do it. +MATHLIB = -lm + # Emulator binary $(EMU_TARGET): $(EMU_OBJS) - $(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS) + $(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS) $(MATHLIB) # Voyager: the same machine with a screen and a speaker. Its own object for the front end, # and the shared ones for everything that is actually the machine. $(VOY_TARGET): $(VOY_OBJS) - $(CC) $(CFLAGS) -o $(VOY_TARGET) $(VOY_OBJS) $(RAYLIB_LIBS) + $(CC) $(CFLAGS) -o $(VOY_TARGET) $(VOY_OBJS) $(RAYLIB_LIBS) $(MATHLIB) $(OBJ_DIR)/voyager.o: $(SRC_DIR_EMU)/voyager.c mkdir -p $(OBJ_DIR) @@ -211,6 +218,8 @@ test: all strict @echo @./Tests/video.sh @echo + @./Tests/sound.sh + @echo @./Tests/terminal.sh @echo @./Tests/native.sh @@ -256,6 +265,8 @@ sanitize: @echo @./Tests/video.sh @echo + @./Tests/sound.sh + @echo @./Tests/terminal.sh @echo @./Tests/native.sh