From d361ea1e46117f07f9c989896f393d6bbc4e5a65 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Fri, 4 Sep 2026 22:20:26 -0400 Subject: [PATCH] An LFO belongs to its channel, not to the whole device The two LFOs lived in the Synth, so four channels shared them and whichever patch loaded last owned them for every voice at once. A sound with its LFO switched off silenced the trill under a sound that was still playing - which is what made Lunar Porter's low fuel warning intermittent: the first landing, docking or crash of a run took its trill away, and it was right again next time the machine started. The engine fix went upstream to soundThing and has come back. synth.c and synth.h are re-vendored at 71e3cb2, character for character bar the ASCII transliteration, and now carry two changes: the LFOs moved into the Voice, and synthSyncVoices carries a free LFO's cycle down alongside its rate. That second hunk does nothing here - it only matters to a caller that syncs voices, and this device never does, because syncing would flatten four channels into one instrument. It is taken so the vendored file stays identical in both trees, and it is commented as such. Upstream also found a bug in the original patch, in patchLoad, which is soundThing's own file and does not travel. Downstream the LFO parameter groups 0x60 and 0x70 now read the selected channel like every parameter beside them, so an LFO written to one channel is inaudible on the other three. Everything else about the device is unchanged. Lunar Porter keeps loading each patch immediately before its note, but for the smaller reason that now applies: the bang and the latch share channel three, and a channel used by two sounds has to be told which of them it is about to be. The comment that said otherwise, and the manual's warning about sharing, are rewritten as history rather than as a caveat. Tests/sound.sh's shared-LFO check is inverted to assert the fixed behaviour, with a third leg added: after proving another channel's patch leaves this one alone, it switches this channel's OWN LFO off and requires the pitch to move. Without that, both checks would pass on a device where writing an LFO did nothing at all. Routing either group back to voice 0 is caught. Cost, measured: four channels sounding continuously for 400 seconds of audio takes 5.0 s of wall clock against 4.59 s before, about 9% of total emulator time. Half of that is wasted on voices that cannot sound, since VOICE_COUNT is 8 and there are four channels; recovering it would mean diverging the vendored file, which is not worth it at this price. --- Programs/CosmOS/Apps/Lander.asm | 40 ++++++----- Programs/CosmOS/README.md | 2 +- Source/Emulator/sound.c | 16 +++-- Source/Emulator/synth.c | 114 +++++++++++++++++++++----------- Source/Emulator/synth.h | 36 +++++++--- SplitBit Programming Manual.md | 35 +++++----- Tests/sound.sh | 45 +++++++------ 7 files changed, 184 insertions(+), 104 deletions(-) diff --git a/Programs/CosmOS/Apps/Lander.asm b/Programs/CosmOS/Apps/Lander.asm index ade49fc..46e0783 100644 --- a/Programs/CosmOS/Apps/Lander.asm +++ b/Programs/CosmOS/Apps/Lander.asm @@ -2796,26 +2796,34 @@ runPend: ; ---- Why every sound loads its patch at the moment it plays ---- ; -; THE LFOs BELONG TO THE DEVICE AND NOT TO A CHANNEL. There are two of them against four -; voices, and a patch carries LFO settings the way it carries everything else - so whichever -; patch was loaded last owns them, for every channel at once. +; CHANNEL THREE IS TWO INSTRUMENTS. The bang and the latch share it, and a patch is the whole +; voice, so the channel has to be told which of them it is about to be. Loading at the moment +; of the note costs fifty writes at a moment already doing more than fifty, and means a sound +; is what its patch says whatever played before it. +; +; The other three channels have one sound each and could be set up once at startup. They are +; not, because a single rule is worth more here than the writes it saves: every sound loads +; its own patch, so there is no question of which sounds are safe to set up early and which +; are not. +; +; ---- What this used to be working around ---- +; +; The LFOs belonged to the DEVICE and not to a channel: two of them against four voices, with +; a patch carrying LFO settings the way it carries everything else, so whichever patch loaded +; last owned them for every channel at once. ; ; That is what made the low fuel warning sound right sometimes and not others. Its trill is a -; saw LFO on the pitch, and the patches on channel three - the bang and the latch - carry an -; LFO that is switched OFF. Those load at the moment they are used, so the first landing, -; docking or crash of a run took the warning's trill away and left a plain tone. Before any of -; them it was right, which is exactly the shape an intermittent fault has. +; saw LFO on the pitch, and the patches on channel three carry an LFO that is switched OFF, so +; the first landing, docking or crash of a run took the warning's trill away and left a plain +; tone. Before any of them it was right, which is exactly the shape an intermittent fault has. ; -; It also meant the rumble never had its own LFO at all: the instruments were set up in order -; at startup and the warning's settings, written last, sat on top of the rumble's. +; Loading at the moment of the note made each sound correct AS IT STARTED, which was as far as +; a program could get on its own. It could not fix two sounds OVERLAPPING - a warning going off +; mid-burn still re-tuned the rumble's LFO for as long as it lasted. ; -; So nothing is set up once any more. Each sound loads immediately before its note, which -; costs fifty writes at a moment already doing more than that, and means a sound is what its -; patch says whatever played before it. -; -; What that does not fix, because it cannot: two sounds overlapping still share one pair of -; LFOs, so a warning going off mid-burn re-tunes the rumble's LFO for as long as it lasts. -; With two between four, that is in the nature of the device. +; The device was fixed instead: an LFO now belongs to the channel it was written to, so four +; channels are four independent instruments in this respect as in every other. Overlapping +; sounds no longer interfere, and the paragraph above is history rather than a caveat. ; ---- Lit or not, and only the CHANGES matter ---- ; diff --git a/Programs/CosmOS/README.md b/Programs/CosmOS/README.md index afe2dda..ae5ce24 100644 --- a/Programs/CosmOS/README.md +++ b/Programs/CosmOS/README.md @@ -727,7 +727,7 @@ from every assembly file in it. Several are old programs written for the bare ma | Grid | The first program to use the screen as a screen. Redefines a tile above the font, fills all 128 map rows, and scrolls it diagonally a pixel at a time. | | Sprite | Moves a ball across the shell's own text, writing not one byte of the map to do it. It leaves the sprite in the table on the way out, because clearing them is the system's job - see below. | | Pad | Says what the controllers are doing, printing a line whenever one changes. It tells apart the three things that look identical from inside a game that is not responding: a pad nobody noticed, a pad mapped to nothing, and a mapping that is wrong. | -| Lander | Lunar Porter, rung one: a lander over a moon that wraps. Flown with a controller if there is one - a held thruster burns every tick it is held for - and with the arrow keys if there is not, where one press is one burn and that is the most the console can say. A bar at the bottom is the sideways drift, drawn as a sprite stretched to the speed - a moon has no air, so a drift never stops by itself and stopping one means cancelling it exactly, which is hard to do blind. It lands or crashes on arrival, and what decides is the speed at the moment it touches: gentler than three quarters of a pixel a frame downwards and half of one sideways, or it is a lander on its side. A fuel gauge sits in the window layer, where the moon turning underneath cannot scroll it away, and every thruster costs a unit of fuel every tick it fires. It turns from green to red at a quarter of a tank, and a warning sounds ONCE on the way down through it - once, because a lander is at its most careful in the last seconds before it touches and something repeating in its ear through that is a distraction rather than a warning. Filling up at a base allows it again. The sound is the moment it happened and the colour is how things stand, and both come off the same number so they cannot disagree. An empty tank is not an ending: it is a lander still flying that can no longer do anything about where. Four landing pads are carved into the moon after it is generated, levelled to whatever height their first column happened to have, and marked in cyan by an attribute rather than a tile of their own. The lander starts above one, because that is where a porter's day begins. Each pad is a base, told apart by the colour it is drawn in, and landing at one either loads cargo for the base across the moon or delivers what is aboard and pays eighty units of fuel. A landing is not an ending: the lander rests where it is until the throttle opens again, and A or Start says "read it" as readily as a key does. What a base says goes in the window rather than out of the console: the console draws into the map, so a message printed while flying is one the lander then flies over, and printing scrolls the whole world up a row. Opening the throttle wipes the line. Two bars read the speeds and are green while a landing would survive and red while it would not, so "can I put down" is a glance rather than a sum. A third runs up the left edge and is how much sky is under the lander, half a pixel of bar to a pixel of it; it reads nought the moment the lander is down, and it exists because the orbit takes the lander off the top of the screen and a panel that only works while the ground is in sight is no use above it. And gravity here is the pull MINUS the swing outwards, which is what makes an orbit rather than a one way trip: under four pixels a frame sideways the pull wins and the lander falls, over it the swing wins and the lander climbs, and at it they cancel and it circles. Falling buys sideways speed and climbing spends it - at a rate set by the product of the two, so the trade stops by itself as the sideways speed runs out - and that is the cycle: a fall carries the lander past orbital speed and becomes a climb, the climb pays it back and becomes a fall, periapse and apoapse, round and round. Orbital speed is marked on the drift bar, sixty four pixels either side of the middle, because a number nothing points at is folklore. Gravity never falls off and the moon wraps, so a circular orbit is the same length at any height and one orbital speed serves everywhere. There is a ceiling 192 pixels above the world's origin, which is the top of the wide view - it was sixty four while sixty four was all the sky a forty column screen had over the origin, which was a fact about the view rather than about the world. It pins rather than ends: leaving upward is recoverable, so the lander is held there and told so, and one sideways is spent every tick it tries to climb - without that the pin wiped the climb, the trade saw neither fall nor climb, and the speed pushing it up never changed. What kills you out here is running dry a long way from the ground. B, or z on the keyboard, swaps the screen between forty columns and eighty, which is the same map, the same cells and the same engine at half the size: nearly two thirds of the moon at once for the orbit, and twice as big for the landing. The twenty extra rows that buys go to the SKY and not to the moon - the wide view starts twenty four rows above the world's origin rather than at it, so the ground sits near the bottom and the whole flyable band is on the screen. Drawn down from the origin instead it was 71 per cent rock, which is a zoom that shows you more of the thing you cannot fly through. A lander at the ceiling is off the top of a forty column screen, which is where the orbit lives and why the altitude bar had to exist; zoomed out it is in the picture. And there is a station up there, twelve rows above the world's origin - about two thirds of the way from the ground to the ceiling, clear of work near the surface - going round at orbital speed - which needs no physics of its own, because a body at 64 sixteenths is exactly what a circular orbit IS under these rules, at any height, since gravity never falls off and the moon wraps. It is off the top of a forty column screen too, so it is somewhere to go that the zoom is needed to see. Docking it is landing on something that is moving: close enough, and slow enough RELATIVE TO THE STATION, or it is a wreck. Its speed is orbital speed, which is the number the marks on the drift bar point at, so the instrument for it was on the screen before there was anything to dock with. A dock pays eighty units of fuel and holds the lander a tile under the station until a thruster lets go, sliding it into line at half a pixel a frame rather than snapping - a dock is allowed eight pixels out either way, so putting the lander exactly in place the instant it took hold moved it a whole tile in one frame, right at the moment the player was being told they had been careful. It settles squarely under the port, which it did not used to: the lander is drawn from half a screen LESS HALF A TILE, because that is what centres it, and the station was drawn from half a screen exactly, so a perfectly flown dock still looked four pixels out. Getting there is a two burn manoeuvre and not a straight line: climbing SPENDS sideways speed, so a lander cannot rise while matched - it arrives slower than orbital every time, and has to raise the far side of its orbit and then circularise at the top. Which is also why it reads /lander.state if the disk has one: sixteen bytes of position, velocity, station, fuel and screen, laid straight over the numbers it would otherwise start with, so a rendezvous can be examined without flying one first. A disk without the file is the game as it always was. The buffer is a whole block wide because osFileRead lands a file in blocks of 256, and sixteen bytes of room for it wrote over everything that followed. A landing kicks up dust, sideways and up off the lander's feet, because that is where kicked dust goes and there is ground in the way of the rest of it. Letting go of the station vents gas instead, evenly in every direction, since nothing is in the way of a docking port - and that one does NOT stop the world the way the others do, because it goes off on the frame a thruster is pressed and freezing then would be felt as the controls sticking. There is none on docking: a dock is a catch and not a touchdown, and there is nothing under it to kick. A lander also has to get two tiles clear of the station before it can take hold of it again, because a docked one sits exactly one tile under it - so letting go upwards docked it straight back on, took the fuel again, and sat waiting to be told the message had been read. A thruster bangs when it lights and rumbles while it burns, and EVERY thruster that catches bangs - a pilot already burning upwards who then adds a sideways one has lit an engine, so what is watched is which of them are lit rather than whether any is. The rumble is struck once and runs until every one is out, since restriking it when a second joins would start its attack over, which is a stutter rather than an engine, and the rumble is a HELD note: its gate goes down when one lights and does not come up until every one is out - or until anything stops to wait, because a held note outlives the loop that was holding it and landing on the thruster used to leave it roaring under the verdict. Arriving somewhere latches two notes quickly, middle C then the C above for taking hold of the station and the same pair reversed for letting go, two octaves lower for the ground. The second note is pending rather than played, since at an undocking the pilot is mid-burn and stopping the world for an eighth of a second would be felt as the controls sticking. Four channels for five sounds: the bang and the latch share one, because a lander arriving either arrives or does not and a crash ends the run outright. No instrument is set up once at startup: each sound loads its patch immediately before its note, because an LFO belongs to the DEVICE and not to a channel, so whichever patch was loaded last owns both of them for every voice at once. Not doing that is what made the warning's trill vanish after the first landing of a run - the landing's patch carries an LFO switched off - which is an intermittent fault of the worst kind, right until something unrelated plays and right again next time the machine starts. A crash also makes a noise, which was the first sound this game had, and it is a TRIGGERED voice - struck once and then playing its own length, so nothing has to come back and end it. That the program says so rather than the patch is deliberate: the patch decides what the bang sounds like and the program decides that it is a bang. It is noise through a low pass that the modulation envelope shuts as the level falls, so the bright part is only at the front and it is a boom rather than a hiss. The instrument is built at startup the way the tiles are, because a patch is twenty odd writes and a note is two - which is what the selector and value registers are for. It plays on channel THREE: effects count down from the top so that music, if it ever arrives, can take nought and count up and the two never have to negotiate. A crash takes the lander apart: it goes, and six pieces of it leave in a rough hexagon at its own colour for about a second before the verdict is said. It used to be a line of text and a lander still sitting there in one piece, so a watcher had to read the words to know what had happened. The world is not running while that plays - it is a loop of its own, so nothing else has to know how to be half destroyed. A plume hangs off whichever side the engine is pushing from - under the lander to lift, over it to retro, and on the far side from the way it is being pushed sideways, since that is where the gas leaves. Every other reading here is a number drawn as a bar and all of them say what is happening TO the lander, so without this a watcher has to read gauges to work out that a thruster is even lit. It is drawn while the BUTTON IS HELD rather than while the engine fires: the engine fires one frame in ten, because that is the tick gravity is applied on, and a flame that honest is a fault lamp rather than a rocket. An empty tank draws none, and neither does the retro thruster on the ground, because in both cases the button really is doing nothing. Down is a retro thruster at half the strength of up - one sixteenth a tick against two, which is exactly gravity's own step, so it can stop a climb and hurry a descent and can never turn a landing approach into a crash faster than letting go would. Arresting a rise otherwise meant a sideways burn and a wait for the orbit to come round, which is how a rendezvous really is flown and a lot to ask of somebody who has not flown one. It does nothing at all to a lander on the ground, which is not politeness: touchdown has already had its say and will not speak again, so without that guard a landed lander holding it goes straight through the moon. | +| Lander | Lunar Porter, rung one: a lander over a moon that wraps. Flown with a controller if there is one - a held thruster burns every tick it is held for - and with the arrow keys if there is not, where one press is one burn and that is the most the console can say. A bar at the bottom is the sideways drift, drawn as a sprite stretched to the speed - a moon has no air, so a drift never stops by itself and stopping one means cancelling it exactly, which is hard to do blind. It lands or crashes on arrival, and what decides is the speed at the moment it touches: gentler than three quarters of a pixel a frame downwards and half of one sideways, or it is a lander on its side. A fuel gauge sits in the window layer, where the moon turning underneath cannot scroll it away, and every thruster costs a unit of fuel every tick it fires. It turns from green to red at a quarter of a tank, and a warning sounds ONCE on the way down through it - once, because a lander is at its most careful in the last seconds before it touches and something repeating in its ear through that is a distraction rather than a warning. Filling up at a base allows it again. The sound is the moment it happened and the colour is how things stand, and both come off the same number so they cannot disagree. An empty tank is not an ending: it is a lander still flying that can no longer do anything about where. Four landing pads are carved into the moon after it is generated, levelled to whatever height their first column happened to have, and marked in cyan by an attribute rather than a tile of their own. The lander starts above one, because that is where a porter's day begins. Each pad is a base, told apart by the colour it is drawn in, and landing at one either loads cargo for the base across the moon or delivers what is aboard and pays eighty units of fuel. A landing is not an ending: the lander rests where it is until the throttle opens again, and A or Start says "read it" as readily as a key does. What a base says goes in the window rather than out of the console: the console draws into the map, so a message printed while flying is one the lander then flies over, and printing scrolls the whole world up a row. Opening the throttle wipes the line. Two bars read the speeds and are green while a landing would survive and red while it would not, so "can I put down" is a glance rather than a sum. A third runs up the left edge and is how much sky is under the lander, half a pixel of bar to a pixel of it; it reads nought the moment the lander is down, and it exists because the orbit takes the lander off the top of the screen and a panel that only works while the ground is in sight is no use above it. And gravity here is the pull MINUS the swing outwards, which is what makes an orbit rather than a one way trip: under four pixels a frame sideways the pull wins and the lander falls, over it the swing wins and the lander climbs, and at it they cancel and it circles. Falling buys sideways speed and climbing spends it - at a rate set by the product of the two, so the trade stops by itself as the sideways speed runs out - and that is the cycle: a fall carries the lander past orbital speed and becomes a climb, the climb pays it back and becomes a fall, periapse and apoapse, round and round. Orbital speed is marked on the drift bar, sixty four pixels either side of the middle, because a number nothing points at is folklore. Gravity never falls off and the moon wraps, so a circular orbit is the same length at any height and one orbital speed serves everywhere. There is a ceiling 192 pixels above the world's origin, which is the top of the wide view - it was sixty four while sixty four was all the sky a forty column screen had over the origin, which was a fact about the view rather than about the world. It pins rather than ends: leaving upward is recoverable, so the lander is held there and told so, and one sideways is spent every tick it tries to climb - without that the pin wiped the climb, the trade saw neither fall nor climb, and the speed pushing it up never changed. What kills you out here is running dry a long way from the ground. B, or z on the keyboard, swaps the screen between forty columns and eighty, which is the same map, the same cells and the same engine at half the size: nearly two thirds of the moon at once for the orbit, and twice as big for the landing. The twenty extra rows that buys go to the SKY and not to the moon - the wide view starts twenty four rows above the world's origin rather than at it, so the ground sits near the bottom and the whole flyable band is on the screen. Drawn down from the origin instead it was 71 per cent rock, which is a zoom that shows you more of the thing you cannot fly through. A lander at the ceiling is off the top of a forty column screen, which is where the orbit lives and why the altitude bar had to exist; zoomed out it is in the picture. And there is a station up there, twelve rows above the world's origin - about two thirds of the way from the ground to the ceiling, clear of work near the surface - going round at orbital speed - which needs no physics of its own, because a body at 64 sixteenths is exactly what a circular orbit IS under these rules, at any height, since gravity never falls off and the moon wraps. It is off the top of a forty column screen too, so it is somewhere to go that the zoom is needed to see. Docking it is landing on something that is moving: close enough, and slow enough RELATIVE TO THE STATION, or it is a wreck. Its speed is orbital speed, which is the number the marks on the drift bar point at, so the instrument for it was on the screen before there was anything to dock with. A dock pays eighty units of fuel and holds the lander a tile under the station until a thruster lets go, sliding it into line at half a pixel a frame rather than snapping - a dock is allowed eight pixels out either way, so putting the lander exactly in place the instant it took hold moved it a whole tile in one frame, right at the moment the player was being told they had been careful. It settles squarely under the port, which it did not used to: the lander is drawn from half a screen LESS HALF A TILE, because that is what centres it, and the station was drawn from half a screen exactly, so a perfectly flown dock still looked four pixels out. Getting there is a two burn manoeuvre and not a straight line: climbing SPENDS sideways speed, so a lander cannot rise while matched - it arrives slower than orbital every time, and has to raise the far side of its orbit and then circularise at the top. Which is also why it reads /lander.state if the disk has one: sixteen bytes of position, velocity, station, fuel and screen, laid straight over the numbers it would otherwise start with, so a rendezvous can be examined without flying one first. A disk without the file is the game as it always was. The buffer is a whole block wide because osFileRead lands a file in blocks of 256, and sixteen bytes of room for it wrote over everything that followed. A landing kicks up dust, sideways and up off the lander's feet, because that is where kicked dust goes and there is ground in the way of the rest of it. Letting go of the station vents gas instead, evenly in every direction, since nothing is in the way of a docking port - and that one does NOT stop the world the way the others do, because it goes off on the frame a thruster is pressed and freezing then would be felt as the controls sticking. There is none on docking: a dock is a catch and not a touchdown, and there is nothing under it to kick. A lander also has to get two tiles clear of the station before it can take hold of it again, because a docked one sits exactly one tile under it - so letting go upwards docked it straight back on, took the fuel again, and sat waiting to be told the message had been read. A thruster bangs when it lights and rumbles while it burns, and EVERY thruster that catches bangs - a pilot already burning upwards who then adds a sideways one has lit an engine, so what is watched is which of them are lit rather than whether any is. The rumble is struck once and runs until every one is out, since restriking it when a second joins would start its attack over, which is a stutter rather than an engine, and the rumble is a HELD note: its gate goes down when one lights and does not come up until every one is out - or until anything stops to wait, because a held note outlives the loop that was holding it and landing on the thruster used to leave it roaring under the verdict. Arriving somewhere latches two notes quickly, middle C then the C above for taking hold of the station and the same pair reversed for letting go, two octaves lower for the ground. The second note is pending rather than played, since at an undocking the pilot is mid-burn and stopping the world for an eighth of a second would be felt as the controls sticking. Four channels for five sounds: the bang and the latch share one, because a lander arriving either arrives or does not and a crash ends the run outright. No instrument is set up once at startup: each sound loads its patch immediately before its note, because the bang and the latch share a channel and a channel used by two sounds has to be told which of them it is about to be. That rule arrived as a workaround for something worse - an LFO used to belong to the DEVICE and not to a channel, so whichever patch was loaded last owned both of them for every voice at once, which is what made the warning's trill vanish after the first landing of a run when the landing's patch carried an LFO switched off. An intermittent fault of the worst kind, right until something unrelated plays and right again next time the machine starts. Loading at the note fixed each sound as it STARTED and could not fix two of them overlapping, so the device was changed instead and an LFO now belongs to the channel it was written to. A crash also makes a noise, which was the first sound this game had, and it is a TRIGGERED voice - struck once and then playing its own length, so nothing has to come back and end it. That the program says so rather than the patch is deliberate: the patch decides what the bang sounds like and the program decides that it is a bang. It is noise through a low pass that the modulation envelope shuts as the level falls, so the bright part is only at the front and it is a boom rather than a hiss. The instrument is built at startup the way the tiles are, because a patch is twenty odd writes and a note is two - which is what the selector and value registers are for. It plays on channel THREE: effects count down from the top so that music, if it ever arrives, can take nought and count up and the two never have to negotiate. A crash takes the lander apart: it goes, and six pieces of it leave in a rough hexagon at its own colour for about a second before the verdict is said. It used to be a line of text and a lander still sitting there in one piece, so a watcher had to read the words to know what had happened. The world is not running while that plays - it is a loop of its own, so nothing else has to know how to be half destroyed. A plume hangs off whichever side the engine is pushing from - under the lander to lift, over it to retro, and on the far side from the way it is being pushed sideways, since that is where the gas leaves. Every other reading here is a number drawn as a bar and all of them say what is happening TO the lander, so without this a watcher has to read gauges to work out that a thruster is even lit. It is drawn while the BUTTON IS HELD rather than while the engine fires: the engine fires one frame in ten, because that is the tick gravity is applied on, and a flame that honest is a fault lamp rather than a rocket. An empty tank draws none, and neither does the retro thruster on the ground, because in both cases the button really is doing nothing. Down is a retro thruster at half the strength of up - one sixteenth a tick against two, which is exactly gravity's own step, so it can stop a climb and hurry a descent and can never turn a landing approach into a crash faster than letting go would. Arresting a rise otherwise meant a sideways burn and a wait for the orbit to come round, which is how a rendezvous really is flown and a lot to ask of somebody who has not flown one. It does nothing at all to a lander on the ground, which is not politeness: touchdown has already had its say and will not speak again, so without that guard a landed lander holding it goes straight through the moon. | | Depth | Four pillars at four distances and a ball walking past them, behind the near ones and in front of the far ones. The ball is sprite nought and every pillar is numbered after it, so table order puts it in front of all four - what actually decides is the depth buffer, asked a column at a time. | | Flip | Draws a whole screen into the bank that is not being shown, waits, and then shows it in one byte out of one port. It writes nothing else at all - not a tile, not a colour - so it does not ask for the screen to be saved, and the line it printed is still there when it comes back. It deliberately does not put the displayed screen back either, because that is the system's to restore: a program that faulted while flipped could not have. | | Edit | A line editor. | diff --git a/Source/Emulator/sound.c b/Source/Emulator/sound.c index f16f958..d3f7fa6 100644 --- a/Source/Emulator/sound.c +++ b/Source/Emulator/sound.c @@ -237,10 +237,18 @@ static void soundParameter(uint8_t value) { v->gate = value ? VOICE_TRIGGER : VOICE_GATE; } 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; + // ---- The LFOs belong to the channel ---- + // + // They used to belong to the device, so whichever channel was selected made no + // difference: two channels playing two different sounds shared one pair of LFOs, and + // whichever loaded its settings last owned them for both. A sound with its LFO + // switched off silenced the trill under a sound that was still playing. + // + // Four channels are four independent things, which is the rule the rest of this + // device already followed. So these read the selected channel like every parameter + // above them, and an LFO written to one channel is inaudible on the other three. + case SP_LFO0: setLfo(&v->lfos[0], which, value); break; + case SP_LFO1: setLfo(&v->lfos[1], which, value); break; default: // A parameter number nothing answers to does nothing. A sound device is a poor // place to stop the machine, the same as a screen. diff --git a/Source/Emulator/synth.c b/Source/Emulator/synth.c index dbc2adc..ecee525 100644 --- a/Source/Emulator/synth.c +++ b/Source/Emulator/synth.c @@ -10,11 +10,11 @@ // // It is copied rather than submoduled. Two files against tying this build to another // repository's history is not a close call, and what a copy costs is that changes have to be -// carried across on purpose - in BOTH directions, which has now happened once each way. +// carried across on purpose - in BOTH directions, which has now happened twice each way. // // ---- What was changed ---- // -// Nothing. This is soundThing's engine at b73e5c0, character for character, except that +// Nothing. This is soundThing's engine at 71e3cb2, character for character, except that // em-dashes and arrows in its comments are written as ASCII here because this tree is ASCII // only. That rule is local and is not an improvement, so it was not sent upstream. // @@ -24,6 +24,13 @@ // waiting for a key, and a triggered voice that re-arms its oscillators so a one-shot is the // same one-shot twice. A game is nearly all one-shots, which is why the traffic went that way. // +// The second round trip was the LFOs, which belonged to the Synth and so were shared by every +// voice at once. That is right for one instrument played polyphonically and wrong for four +// channels playing four different things: whichever patch loaded last owned the LFO for all of +// them. They now live in the Voice. It went up from here and came back with a bug fixed and +// one hunk that does nothing here - synthSyncVoices carrying the free cycle down, which only +// matters to a caller that syncs, and this device never does. +// // So the thing to keep current is no longer a list. It is this: if either copy changes, the // other one has to be told. // @@ -84,8 +91,18 @@ void synthInit(Synth *s, float sampleRate) // it is now said rather than assumed, and can be said differently. s->voices[i].levelSource = MOD_SOURCE_AMP_ENV; s->voices[i].gate = VOICE_GATE; - for (int l = 0; l < LFO_COUNT; l++) - lfoStateInit(&s->voices[i].lfoRun[l], lfoTriggerSeed(l)); + // Every voice is seeded from lfoTriggerSeed(l), which depends on the LFO and NOT on + // the voice: identical seeds, identical rates and identical arithmetic on every + // sample is what keeps the free cycles of a chord in step rather than merely near. + for (int l = 0; l < LFO_COUNT; l++) { + lfoStateInit(&s->voices[i].lfos[l].run, lfoTriggerSeed(l)); + s->voices[i].lfos[l].rate = 1.0f; + s->voices[i].lfos[l].waveform = WAVE_SINE; + s->voices[i].lfos[l].active = 0; + // Free unless a patch says otherwise, which is what every patch that exists was + // made against. + s->voices[i].lfos[l].mode = LFO_FREE; + } s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u; s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u; @@ -113,16 +130,6 @@ void synthInit(Synth *s, float sampleRate) s->voices[i].filter.resModDepth = 0.0f; } s->voices[0].oscillators[0].active = 1; - - for (int l = 0; l < LFO_COUNT; l++) { - lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l)); - s->lfos[l].rate = 1.0f; - s->lfos[l].waveform = WAVE_SINE; - s->lfos[l].active = 0; - // Free unless a patch says otherwise, which is what every patch that exists was - // made against. - s->lfos[l].mode = LFO_FREE; - } } void synthResetPatch(Synth *s) @@ -167,15 +174,23 @@ void synthResetPatch(Synth *s) v->filter.resModDepth = 0.0f; for (int l = 0; l < LFO_COUNT; l++) { - lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l)); - s->lfos[l].rate = 1.0f; - s->lfos[l].waveform = WAVE_SINE; - s->lfos[l].active = 0; + v->lfos[l].rate = 1.0f; + v->lfos[l].waveform = WAVE_SINE; + v->lfos[l].active = 0; // Free unless a patch says otherwise, which is what every patch that exists was // made against. - s->lfos[l].mode = LFO_FREE; + v->lfos[l].mode = LFO_FREE; } + // The rest of this function sets voice 0 and lets synthSyncVoices carry it down. The + // cycles are the exception, because sync deliberately does not touch them: restarting + // only voice 0's would leave it a fraction of a turn from every other voice, and a free + // LFO under a chord would stop being one sweep. So they all restart together, which is + // what the single cycle this replaced did. + for (int i = 0; i < VOICE_COUNT; i++) + for (int l = 0; l < LFO_COUNT; l++) + lfoStateInit(&s->voices[i].lfos[l].run, lfoTriggerSeed(l)); + s->volume = 0.8f; s->pitchBendRange = 2.0f; } @@ -377,8 +392,8 @@ const char *waveformName(Waveform w) // patch that exists was made against the old behaviour. // // Retriggered LFOs restart for THIS voice only, whatever the gate - an LFO starting fresh -// per note is wanted under held notes too, and it is the shared cycle that must not move. -static void voiceArm(Synth *s, Voice *v) +// per note is wanted under held notes too, and the other voices' cycles must not move. +static void voiceArm(Voice *v) { int oneShot = (v->gate == VOICE_TRIGGER); v->ampEnv.oneShot = oneShot; @@ -394,8 +409,8 @@ static void voiceArm(Synth *s, Voice *v) } for (int l = 0; l < LFO_COUNT; l++) - if (s->lfos[l].mode == LFO_RETRIGGER) - lfoStateInit(&v->lfoRun[l], lfoTriggerSeed(l)); + if (v->lfos[l].mode == LFO_RETRIGGER) + lfoStateInit(&v->lfos[l].run, lfoTriggerSeed(l)); envelopeNoteOn(&v->ampEnv); envelopeNoteOn(&v->modEnv); @@ -412,7 +427,7 @@ void synthNoteOn(Synth *s, int midiNote) s->voices[i].active = 1; s->voices[i].filter.low = 0.0f; s->voices[i].filter.band = 0.0f; - voiceArm(s, &s->voices[i]); + voiceArm(&s->voices[i]); return; } } @@ -425,7 +440,7 @@ void synthNoteOn(Synth *s, int midiNote) s->voices[i].active = 1; s->voices[i].filter.low = 0.0f; s->voices[i].filter.band = 0.0f; - voiceArm(s, &s->voices[i]); + voiceArm(&s->voices[i]); } @@ -460,7 +475,7 @@ void synthChannelOn(Synth *s, int channel, int midiNote) // where the last one left off is how a click gets into the front of every sound. v->filter.low = 0.0f; v->filter.band = 0.0f; - voiceArm(s, v); + voiceArm(v); } void synthChannelOff(Synth *s, int channel) @@ -481,13 +496,19 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) { for (int i = 0; i < frames; i++) { float mix = 0.0f; - // The shared cycle advances once per sample whatever is listening, so a free LFO is - // one sweep under everything and does not stall when nothing is sounding. - float freeLfo0 = lfoTick(&s->lfos[0], sr); - float freeLfo1 = lfoTick(&s->lfos[1], sr); - for (int v = 0; v < VOICE_COUNT; v++) { Voice *vv = &s->voices[v]; + + // ---- Before the silent voices are skipped, and that is the load-bearing part ---- + // + // A cycle that only advanced while its voice sounded would sit still between + // notes, and the voices of a chord would fall out of step the moment one of them + // ended. Advancing every voice's cycle on every sample is what makes a free LFO + // still one sweep under everything: identical seeds stepped by identical + // arithmetic the same number of times give identical numbers, not close ones. + float lfo0 = lfoTick(&vv->lfos[0], sr); + float lfo1 = lfoTick(&vv->lfos[1], sr); + if (!vv->active) continue; float amp = envelopeTick(&vv->ampEnv, sr); @@ -504,13 +525,6 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) { continue; } - // A retriggered LFO reads this voice's own cycle, started when it was struck. - // A free one reads the shared cycle above, exactly as it always did. - float lfo0 = (s->lfos[0].mode == LFO_RETRIGGER) - ? lfoTickState(&s->lfos[0], &vv->lfoRun[0], sr) : freeLfo0; - float lfo1 = (s->lfos[1].mode == LFO_RETRIGGER) - ? lfoTickState(&s->lfos[1], &vv->lfoRun[1], sr) : freeLfo1; - float oscMix = 0.0f; int activeOscs = 0; for (int o = 0; o < OSC_COUNT; o++) { @@ -643,6 +657,30 @@ void synthSyncVoices(Synth *s) s->voices[v].levelSource = s->voices[0].levelSource; s->voices[v].gate = s->voices[0].gate; + // Sync the LFO settings but NOT the cycle, for the same reason as the envelopes and + // the filter below: what the patch says is shared, where the voice has got to is its + // own. Copying the cycle here would also stamp on a retriggered LFO every frame. + for (int l = 0; l < LFO_COUNT; l++) { + s->voices[v].lfos[l].rate = s->voices[0].lfos[l].rate; + s->voices[v].lfos[l].waveform = s->voices[0].lfos[l].waveform; + s->voices[v].lfos[l].active = s->voices[0].lfos[l].active; + s->voices[v].lfos[l].mode = s->voices[0].lfos[l].mode; + + // ---- and, while it is free, the cycle too ---- + // + // A free LFO is DEFINED as one sweep under everything, and until now that was + // true only for as long as every writer of voice 0 remembered to write the other + // seven. synthResetPatch remembered; patchLoad did not, and a chord came out of + // a patch load permanently out of phase with itself. An invariant that decays the + // first time someone forgets is better made structural: carry the cycle down and + // it cannot drift, whoever writes what. + // + // Only while free. A retriggered LFO's cycle is its voice's own business, and + // copying it here would stamp on it every frame. + if (s->voices[0].lfos[l].mode == LFO_FREE) + s->voices[v].lfos[l].run = s->voices[0].lfos[l].run; + } + // Sync oscillator settings for (int o = 0; o < OSC_COUNT; o++) { s->voices[v].oscillators[o].waveform = s->voices[0].oscillators[o].waveform; diff --git a/Source/Emulator/synth.h b/Source/Emulator/synth.h index c1fd5b6..6c8c9c2 100644 --- a/Source/Emulator/synth.h +++ b/Source/Emulator/synth.h @@ -10,11 +10,11 @@ // // It is copied rather than submoduled. Two files against tying this build to another // repository's history is not a close call, and what a copy costs is that changes have to be -// carried across on purpose - in BOTH directions, which has now happened once each way. +// carried across on purpose - in BOTH directions, which has now happened twice each way. // // ---- What was changed ---- // -// Nothing. This is soundThing's engine at b73e5c0, character for character, except that +// Nothing. This is soundThing's engine at 71e3cb2, character for character, except that // em-dashes and arrows in its comments are written as ASCII here because this tree is ASCII // only. That rule is local and is not an improvement, so it was not sent upstream. // @@ -24,6 +24,13 @@ // waiting for a key, and a triggered voice that re-arms its oscillators so a one-shot is the // same one-shot twice. A game is nearly all one-shots, which is why the traffic went that way. // +// The second round trip was the LFOs, which belonged to the Synth and so were shared by every +// voice at once. That is right for one instrument played polyphonically and wrong for four +// channels playing four different things: whichever patch loaded last owned the LFO for all of +// them. They now live in the Voice. It went up from here and came back with a bug fixed and +// one hunk that does nothing here - synthSyncVoices carrying the free cycle down, which only +// matters to a caller that syncs, and this device never does. +// // So the thing to keep current is no longer a list. It is this: if either copy changes, the // other one has to be told. // @@ -119,7 +126,7 @@ typedef enum { } LfoMode; typedef struct { - LfoState run; // the free-running cycle, read by every voice in LFO_FREE + LfoState run; // this LFO's own cycle, free-running or reset at note on float rate; // Hz Waveform waveform; int active; @@ -194,10 +201,24 @@ typedef struct { // said what shapes the sound; this says who ends it. Without it a voice can only ever // finish because a key came up, which is no use to a drum. VoiceGate gate; - // One cycle per LFO per voice, used only by the LFOs set to LFO_RETRIGGER. Runtime state - // like the envelope stages and the filter's integrators, so synthSyncVoices leaves it - // alone - it is where this voice is, not what the patch says. - LfoState lfoRun[LFO_COUNT]; + // ---- Its own LFOs, like its own envelopes and its own filter ---- + // + // They used to belong to the Synth, which is right for ONE INSTRUMENT played polyphonically + // and wrong for anything else: a patch carries LFO settings, so loading a patch changed + // what every other voice heard. That is invisible here, because every voice is given the + // same patch - and it is the whole difficulty for anything driving these voices as separate + // parts, where two patches disagree about the rate and the last one loaded wins for both. + // + // A chord is unaffected. synthSyncVoices copies these like everything else, so every voice + // runs identical settings, and their cycles are started together in synthInit and advanced + // every sample whether the voice is sounding or not - so they do not merely stay close, + // they stay bit for bit identical, and one sweep still runs under the whole chord. + // + // Each LFO's own cycle lives inside it, like the envelope stages and the filter's + // integrators, so synthSyncVoices copies the settings and leaves the cycle alone - it is + // where this voice is, not what the patch says. LFO_RETRIGGER resets that same cycle at + // note on, which is what it always did, only now to a cycle nothing else is listening to. + LFO lfos[LFO_COUNT]; } Voice; typedef struct { @@ -207,7 +228,6 @@ typedef struct { int lastStolenVoice; Voice voices[VOICE_COUNT]; float volume; // 0.0 to 1.0, master output level - LFO lfos[LFO_COUNT]; } Synth; // Envelope functions: diff --git a/SplitBit Programming Manual.md b/SplitBit Programming Manual.md index 68fe857..3f75915 100644 --- a/SplitBit Programming Manual.md +++ b/SplitBit Programming Manual.md @@ -1075,9 +1075,9 @@ Anywhere a setting asks *what modulates* something, the answer is one of these: | 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. +**Each channel has its own pair of LFOs**, so writing 0x60 to 0x7F sets them on whichever +channel is selected, exactly like every parameter above. Four channels are four independent +instruments, and an LFO is part of how an instrument sounds. ### What A Byte Means: @@ -1148,25 +1148,30 @@ The consequence is worth knowing rather than fixing: a retriggered noise source repeatable, so every hit is literally the same noise, the way a sampler is. On a hi-hat that can read as machine-gunny. Where variation is wanted, leave that LFO or that voice free. -### A Warning About Sharing Them: +### They Used To Be Shared, And The Fault That Caused: -**An LFO belongs to the device and not to a channel.** There are two of them against four -voices, so setting one from a patch meant for channel three changes what channel nought hears, -immediately and for as long as nothing sets it back. +Worth knowing, because it is the shape of bug this device is built to avoid and the manual +said the opposite for a while. -That matters because a patch naturally carries LFO settings along with everything else - it is -one instrument, and its LFO is part of how it sounds. A program that loads its instruments once -at startup therefore ends up with whichever of them was written *last*, for all of them. +The two LFOs used to belong to the **device**, so setting one from a patch meant for channel +three changed what channel nought heard, immediately and for as long as nothing set it back. +A patch naturally carries LFO settings along with everything else, so a program that loaded +its instruments once at startup ended up with whichever of them was written *last*, for all +of them. -The failure this produces is unpleasant to diagnose, because it is intermittent by nature: a +The failure that produces is unpleasant to diagnose, because it is intermittent by nature: a sound is correct until some unrelated thing plays, and correct again next time the machine starts. A game here lost a warning's trill after the first landing of each run, because the landing's patch happened to carry an LFO that was switched off. -**Load a patch immediately before the note that needs it.** A patch is forty-odd writes and -this costs nothing at a moment that is already making a sound. What it cannot fix is two -sounds overlapping - they still share the LFOs, and with two between four voices that is in -the nature of the device. +A program could work around the first half of that by loading each patch immediately before +the note that needed it. It could not work around the second: two sounds **overlapping** still +interfered, and no amount of care in the program could separate them. So the device was +changed rather than the programs, and an LFO now belongs to the channel it was written to. + +**Loading a patch immediately before its note is still good practice**, for the different +reason that a channel used by two sounds has to be told which of them it is about to be. A +patch is forty-odd writes and costs nothing at a moment already making a sound. ### Knowing When It Has Finished: diff --git a/Tests/sound.sh b/Tests/sound.sh index 749ed3d..a1cbbce 100755 --- a/Tests/sound.sh +++ b/Tests/sound.sh @@ -496,20 +496,19 @@ PY && result ok "and two hits are the same hit" "sample for sample, noise and all" \ || result no "and two hits are the same hit" "$SAME" -# ---- An LFO belongs to the device, and a patch carries LFO settings ---- +# ---- An LFO belongs to its CHANNEL, and a patch carries LFO settings ---- # -# Which is a trap with teeth, and it bit a game before it was written down here. Four voices -# share two LFOs, so a patch loaded onto channel ONE re-tunes what channel NOUGHT hears - and -# a program that sets its instruments up once at startup gets whichever of them was written -# last, for all of them. +# This used to be the other way round, and it was a trap with teeth: four voices shared two +# LFOs, so a patch loaded onto channel ONE re-tuned what channel NOUGHT heard. The symptom was +# the worst kind - a sound that is right until some unrelated thing plays, and right again +# next time the machine starts. In the game it was a warning whose trill vanished after the +# first landing of a run, because the landing's patch carries an LFO switched off. # -# The symptom is the worst kind: a sound that is right until some unrelated thing plays, and -# right again next time the machine starts. In the game it was a warning whose trill vanished -# after the first landing of a run, because the landing's patch carries an LFO switched off. -# -# Three notes on channel nought, all identical in what THEY were told. Between the first and -# second, a patch is dropped on channel one that switches the LFO off; between the second and -# third, channel nought's own settings are written again. +# Three notes on channel nought, all identical in what THEY were told, and the third leg is +# what keeps this check honest. Between the first and second, a patch is dropped on channel +# ONE that switches the LFO off - which must now do nothing here. Between the second and third +# the same thing is written to channel NOUGHT itself - which must still work, or the two +# checks above would pass just as well on a device where writing an LFO does nothing at all. { printf '#Program\nstart:\n'; port 0x41 0x00; loud param 0x00 1 # triangle, so the LFO's work on the pitch is plain param 0x51 1 # triggered, so each note ends itself @@ -524,8 +523,8 @@ PY port 0x41 0x00 port 0x44 60; pause two 250 - port 0x41 0x00 # and its own settings said again, which is the fix - param 0x60 0x01; param 0x61 2; param 0x62 40; param 0x63 0x01 + port 0x41 0x00 # and now its OWN LFO switched off, which must be heard + param 0x60 0x00 port 0x44 60; spinForever printf '#Vectors\n Boot start\n'; } | run lfoshared 8000000 || exit 1 read -r FIRST SECOND THIRD <