From b2ff8d64e529d96be5e4b9493ef17ad2af9eabf5 Mon Sep 17 00:00:00 2001 From: Anachronaut Date: Fri, 4 Sep 2026 16:02:40 -0400 Subject: [PATCH] Fold soundThing's changes back down, and expose the two new switches The three changes that went up came back as part of soundThing, along with two more that they made possible. The engine here is now b73e5c0 character for character, except that em-dashes and arrows in comments are written as ASCII because this tree is ASCII only - a local rule, not an improvement, and not sent up. So synth.h's "what was changed" list is gone. There is nothing to list: what has to be kept current is only that if either copy changes, the other one has to be told. ---- What came back ---- A VOICE CAN END ITSELF. Naming the level's source said what shapes a voice; nothing said what ends one, so the only thing that could ever finish one was a key coming up. A game is nearly all one-shots and not one of them wants its length decided by how long a note was held. Exposed as parameter 0x51: 0 gated, 1 triggered. AND A ONE-SHOT IS THE SAME ONE-SHOT TWICE. A triggered voice re-arms its oscillators, and an LFO can be told to start over with each voice - parameter 3 of either LFO. Both halves are needed and the check proves it: with the LFO left free, two triggered hits still differ. Their note warned that whatever applies a patch to a channel has to set these or they hold synthInit's defaults. Checked: Voyager never calls synthSyncVoices, so their 0001 is a no-op here as they predicted, and nothing reaches into an LFO's phase, so the struct split is safe. ---- What it is for ---- Lander's crash is a triggered voice now, so boomOff is gone. Nothing has to remember to end a bang. SoundPatch learnt voice_levelSource, voice_gate and lfo_mode, which the new soundThing writes - without that it would have refused every patch saved from it, since an unknown field stops the tool on purpose. A patch from before those fields still converts, and says in its own comments that it predates the level routing. Three checks, each seen to fail on its own break: a gated voice still sounding with nothing holding it, a triggered one down to nothing with no gate ever dropped, and two hits identical sample for sample. One test bug worth keeping: the first version of the repeatability check struck the second note while the first was still ringing, so what it found and compared as "the second hit" was a point in the middle of the first one's tail. It now looks for sound after SILENCE rather than sound after an offset. --- Crash1.json | 51 +++++++ LowFuel.json | 54 ++++++++ Programs/CosmOS/Apps/Lander.asm | 23 ++-- Programs/CosmOS/README.md | 2 +- SoundPatch | Bin 24824 -> 24824 bytes Source/Emulator/sound.c | 5 + Source/Emulator/sound.h | 21 +++ Source/Emulator/synth.c | 206 ++++++++++++++++++++-------- Source/Emulator/synth.h | 94 +++++++++---- Source/Patch/SoundPatch.c | 35 ++++- SplitBit Programming Manual.md | 41 ++++++ Tests/expected/cosmosCrossDisk.out | 2 +- Tests/expected/cosmosDrives.out | 2 +- Tests/expected/cosmosFault.out | 2 +- Tests/expected/cosmosFlip.out | 2 +- Tests/expected/cosmosGrid.out | 2 +- Tests/expected/cosmosMonitor.out | 2 +- Tests/expected/cosmosMonitorRun.out | 2 +- Tests/expected/cosmosRun.out | 2 +- Tests/expected/cosmosSlowDisk.out | 2 +- Tests/expected/cosmosSprite.out | 2 +- Tests/sound.sh | 87 ++++++++++++ 22 files changed, 532 insertions(+), 107 deletions(-) create mode 100644 Crash1.json create mode 100644 LowFuel.json diff --git a/Crash1.json b/Crash1.json new file mode 100644 index 0000000..c38cb9d --- /dev/null +++ b/Crash1.json @@ -0,0 +1,51 @@ +{ + "osc0_waveform": 5, + "osc0_dutyCycle": 0.500000, + "osc0_detune": 0.000000, + "osc0_gain": 4.000000, + "osc0_active": 1, + "osc0_octave": 2, + "osc0_modRouting0": 0, + "osc0_modRouting1": 2, + "osc0_modRouting2": 0, + "osc0_modDepth0": 0.000000, + "osc0_modDepth1": 180.000000, + "osc0_modDepth2": 4.000000, + "osc1_waveform": 5, + "osc1_dutyCycle": 0.545000, + "osc1_detune": -15.000000, + "osc1_gain": 4.000000, + "osc1_active": 1, + "osc1_octave": 1, + "osc1_modRouting0": 0, + "osc1_modRouting1": 2, + "osc1_modRouting2": 0, + "osc1_modDepth0": -0.750000, + "osc1_modDepth1": 1104.000000, + "osc1_modDepth2": 4.000000, + "ampEnv_attack": 2.000000, + "ampEnv_decay": 0.100000, + "ampEnv_sustain": 0.000000, + "ampEnv_release": 0.001000, + "modEnv_attack": 0.001000, + "modEnv_decay": 0.603263, + "modEnv_sustain": 0.000000, + "modEnv_release": 2.000000, + "lfo0_rate": 8.796101, + "lfo0_waveform": 0, + "lfo0_active": 0, + "lfo1_rate": 1.000000, + "lfo1_waveform": 0, + "lfo1_active": 0, + "filter_cutoff": 13294.700195, + "filter_resonance": 0.574200, + "filter_type": 0, + "filter_active": 1, + "filter_modRouting": 2, + "filter_modDepth": 11600.000000, + "filter_resModRouting": 0, + "filter_resModDepth": 0.000000, + "voice_levelSource": 2, + "master_volume": 1.000000, + "master_pitchBendRange": 2.000000 +} diff --git a/LowFuel.json b/LowFuel.json new file mode 100644 index 0000000..58003f6 --- /dev/null +++ b/LowFuel.json @@ -0,0 +1,54 @@ +{ + "osc0_waveform": 1, + "osc0_dutyCycle": 0.500000, + "osc0_detune": 0.000000, + "osc0_gain": 4.000000, + "osc0_active": 1, + "osc0_octave": 2, + "osc0_modRouting0": 0, + "osc0_modRouting1": 3, + "osc0_modRouting2": 1, + "osc0_modDepth0": 0.000000, + "osc0_modDepth1": -420.000000, + "osc0_modDepth2": 4.000000, + "osc1_waveform": 1, + "osc1_dutyCycle": 0.500000, + "osc1_detune": 0.000000, + "osc1_gain": 4.000000, + "osc1_active": 1, + "osc1_octave": 0, + "osc1_modRouting0": 0, + "osc1_modRouting1": 3, + "osc1_modRouting2": 1, + "osc1_modDepth0": 0.000000, + "osc1_modDepth1": -420.000000, + "osc1_modDepth2": 4.000000, + "ampEnv_attack": 0.001000, + "ampEnv_decay": 0.577635, + "ampEnv_sustain": 1.000000, + "ampEnv_release": 0.065071, + "modEnv_attack": 0.005000, + "modEnv_decay": 0.500000, + "modEnv_sustain": 0.000000, + "modEnv_release": 0.100000, + "lfo0_rate": 3.398800, + "lfo0_waveform": 2, + "lfo0_active": 1, + "lfo0_mode": 1, + "lfo1_rate": 1.000000, + "lfo1_waveform": 0, + "lfo1_active": 0, + "lfo1_mode": 0, + "filter_cutoff": 8000.000000, + "filter_resonance": 0.000000, + "filter_type": 0, + "filter_active": 0, + "filter_modRouting": 0, + "filter_modDepth": 0.000000, + "filter_resModRouting": 0, + "filter_resModDepth": 0.000000, + "voice_levelSource": 1, + "voice_gate": 1, + "master_volume": 1.000000, + "master_pitchBendRange": 2.000000 +} diff --git a/Programs/CosmOS/Apps/Lander.asm b/Programs/CosmOS/Apps/Lander.asm index aba3f79..545fe6b 100644 --- a/Programs/CosmOS/Apps/Lander.asm +++ b/Programs/CosmOS/Apps/Lander.asm @@ -2537,7 +2537,6 @@ explode: CALL startBurst CALL boom CALL runBurst - CALL boomOff RET ; ---- Dust, for a landing that went right ---- @@ -2683,6 +2682,20 @@ putBoom: OUTA 0x41 ; Channel three. Every parameter write below lands on it. SETD.0 BoomPatch CALL playPatch + + ; ---- Struck, not held ---- + ; + ; A gated voice lasts as long as something keeps hold of it, and the gate coming up is what + ; starts its release - which is what a keyboard is. A crash is not a keyboard. It is struck + ; once and then plays its own length, and nothing should have to remember to end it. + ; + ; Said here rather than left to the patch because it is the PROGRAM that knows this is a + ; one-shot: the patch decides what the bang sounds like, and this decides that it is a bang. + ; A patch designed in soundThing carries a gate of its own, and this overrides it on purpose. + INIA 0x51 + OUTA 0x42 + INIA 0x01 ; Triggered. + OUTA 0x43 RET ; ---- A whole patch, out of a table ---- @@ -2716,14 +2729,6 @@ boom: OUTA 0x44 ; Writing the note is what starts it. RET -; And letting go of it, once there is nothing left to watch. -boomOff: - INIA 0x03 - OUTA 0x41 - RSTA - OUTA 0x45 ; Dropping the gate is the only thing that ends a note. - RET - ; ---- A key, or a button ---- ; ; Somebody flying on a controller should not have to reach for the keyboard to say "yes, I diff --git a/Programs/CosmOS/README.md b/Programs/CosmOS/README.md index 12aa102..926fc55 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. 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 crash also makes a noise, which is the first sound this game has: 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. 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 crash also makes a noise, which is the first sound this game has, 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/SoundPatch b/SoundPatch index 9a864e0f235a44473c941d6298d64da05033b834..701e848fce0fb6b9d0babe786fb222e71a695298 100755 GIT binary patch delta 5115 zcmexyknzVs#t9OP4HG4|bDj`l0D}_~Z|ZZ-WCybuW=B*8z>VgNLqHub0Ad@;4wVh1rLILIw6A<*| zLrifY=+?sc6QI_^XpnP32Cy(NFo-ZPFfcPPFu41LGTf_3`F~*M?mg2J@1Kudx%5of zs{1{gOPK#K*TYPL`kx^|9i*Rup#th*n79H|ya7#ooe@O+0yObGQ1KOL;+LV~Cm`bW z3@{7!7()!Y0Fi);--T-UfF}M1D*gja{3BFc!VO{`%>2JlaRn6dWCjL4sKpK_5)2H& zP;n15aVe;H2Aa5^8N|W@G;y`bf*j(K=1^TT&}4lkJ93CiMnh!}pvk6BF60oGtboeC zK$GpAybwvRfIGx~n1|;~zQ`fYxfZI|Vsay=_+$=F0k~ZWXzDLaHslnSya!d^fhPNP zaw4a=BrK9PpvnH9Jdsn}5*8&l&}8{cATIlXCJu`dhRK3l;+hH`5LGbu!@^$!OCST+hmsEku z?m&~hKADk6T+$dS`v6V$*a`du&?I2aZ9o&Zp8SzVT+$PwjbR0vY{+CqUUA6;sO$wa*_z3LyyB8wP}v`7 zva=^Q@`_8!K{K#|H)=qvnY@u#Tyh^&uLqj!mB|lz#U-CWWed<`e@zzT6R+oonmhwd zRs$-&15F%W9H5C?L)AY(6L*J-zd#cYgotY~aQL8l=@hgu6hIS?pL~%|Trv})jllv< zwqi0Pzqn)*R5k%kcEMyresRe)P}vSN*=>^(`Nbt+_HICvJu-PBKf>M{XtL)fA4Ia3 z!57spHzt4N7dLtZwO0d8mH}F!dZ3AOn{T!fVB_DUu!>Q^v-7KCNNBJ}kauB7+&=J|NsA`9}5G6N4Fb?M<1En$^-8?ED-L`^y3=I32Kt}e8&gWxbc*(@fz_1e( z4~7SPdQ~rpFfbUt_37QC!obMD;M4j4^JJBF5vH%+lh+_+auMB~^PiflrS70zPaKpZH@A zed3Qf^@%@{l}Utw;dQo0_F;`rj{I>hY!WXV{{8{P+KVQ1E&{gn#isgl(Y0h9F@`%yQ~5FnD&}0ENBiOCD$} z|3CV|@bCZsFFC>LYE(F0Z2I&6f9u;46Q5qzt=tR@&3_q7*?oFhpMdP^wyi&+&A`z4 z5}ZQ7CSB%XV0iKV&;S1~*MJg4uj*q31_oEdZ$6z58Y(yfN@N}XAM^!iWWo6F+A`h;rIXlk#U~?k9syA(eP~kYgD?z!&3H@CIdqWzenf& z7Z-m0|NokGFDPt1x?5B{SQr>mJhUN&NeSzVgTMa&_h>up#~4F(2} z)&nJ+{~z$nH-HQ|45dAKZ8bp(drg%lcdN-TozJ}0#-H77W~v?w{1p*$n8M4>n%u^_cr zp(GuH!v_T1ibqH z{{aI7gTed%|05U}7$hg_nrlm5c=!Lm8c4GXJGhsq!oa`)YC}a#E;LuK|M24ff3Pq} z1+*Jh!oa}r;swM^s4%#D*2BQS;Q9Xle?^d>BcDJ#9|r@-o+S(n3>h!~|Gy0C$GPzd z^fNi}N%S#0@hSAOxbkWAusZS?w6QtzSv0e|^G)Dn_F>_h!Rf}gfHRzrV+N-q-vmx3 zm&tq^+PWt3T zcX>fr|E3q(_u!no&`lh!X3pe|?(*<{%)!Ywk@R1f%;+I62o_ zs3@rOkIzf6UWzc-c9_qkCNK1m7lipt4HVA|3=E=^Ke~y-eP}fKqldg8%!kfUHR6*U z-4VVBn(XL_@M$tsjU?DESZEYYp6Dqr2UWG32ThkOfK}6=Y%!BXHTB#Ezb#SY_FMo&|97p*0{bh z`K7nKAgnR{3u>Ah*fdxpT4S=M4oCI$7$ zQh|DsuyGxjvN;S43?M}blM@5QnF?GdR|V=Z_Do(G=*|>iKKWDNJI0>P4};hl85<_Q z3^r%Hyjd~iI~&MZQqg*x2LvI;9M~Kf?as*fYVyn&eNLFSdM00tac2r(pDY!t$AvVW z6%=d6H38ZxpI|()!Cr^qq Q6NH5VjJ~k>W}G||05%d%zyJUM delta 4657 zcmexyknzVs#t9OP3KJ!_b8-kVfC0zEoBEuV>|i#7v$&yU!f)P+rI6XO#Nga#YP9}AsA7&5}5cK3j zOmQLT*24H7I3dbmG|0Ii5f%mp1`!4Z24)5Z26w+uh8wkhA_8lB+tLDWIsEVZJm=&- zzZsiLnEx=>!%TwupMgOgq@RI70_tIy_yIMDxB{B^Wh02V1Dg0lsJI83_z$Rf21Hzm z0cOENV~9Zo5DBa{?VEu4IiQ(UqEDw}{N+cWtir?})?sB8zC?54?z zT;iNCy&EQP$##J#W>$(YQ@g9vj6G}-dWhCGNc&p?yyn4E~DcLJL1^vM%>#3f{alF zcn}tO36l%?#3l2f_IjYnR!&~XCob6vl`TM%T{!t7pLqRxsO$_h*#l7V9cbdt(2RWm zP5dHM{R1@d$58PXXyQL1;#v$GKB!*egjyhgCN4jDBEPt#2~^esP1bkvL4HIkPC%1Q znEa7nTrvl$w*yVKY_g(&xa50i5xN0QwqPx;)Ut`^+}~5@6$>d_m~ICWTdu3XUP3onJzOJsRIwurM%m)~Lv& zcyzOpQ33&9HHVHB?_;h~v z=yXvLcoF^o|9=pR!=v>;skBG8t&|=}_TxS#kYT-|oqP-oFSj!>F!=PYQTf2Yz_3$~ zfq}vBfZAw*0sdBLkTJ=(KJg29Wz~9iflrS70zT{#pZH@Aed3Qf^@%@{b)7H+ z!|QC1?86$L9Qos1*d<=L{QLhOBr^phQv;O|2w@lC*EscwUob>P;}d`UiBJ4-r}#Av zzBu;x|9`L=enA%%1&{?UAPeGLR5V_!`uqPszs3o$D-}M~^GC8e3NtW#;*UG{TJ;mZ z0IxO3eIT3pBM(At|HL10@HNLL{s>k%VFrd5M$8Nhoi1}Mj=B@iO(_|Ns9F zc{U%=X#S45wq@4= z$GYKx|3`g#Tfq7~dPNWNFff1J1DX=9kx#+xY=3>(lw)qgS*EtlX!!UiF^>1H+5R zKmY&xbpG?`wVedA=htr-_biC}8qR$W;@MJ=cZ-S!3j;%n zhc=`XC}Dje0WzTR2)KZF5gqFo;~480=NQj&m|xyua=n^NK;rNJ|No0tYceo=Nz>z( zZvhun9=*0ZH9_Iax(P&C{wNXk=w^)rv0D$6aQ=V5FW&%Cco<51^xAfUBzsMpCcjpb zVbax{%&9J`RjUbduXd~h>CySLgvX=XR!x(E;l zl%}LAq~?{S=HwToPHs0+-n_=>JoDtmX4k)_7VNLqN$ur&L;mz=#$p_u!1z}D0O;F1@Co{Ssn(7B9 z3%bj5!kUTCCR@5A^nab4=q@kF3~7!rNI_}A$s1k8En!|#fr^5OH3{@u#wjJg( z`^k(R@`5m*c|+BRP7ZVvhx;&ma-fI2Ak2puP&ML{7b5wheDXpMgikx5Y9zsS!MYUF zCx7&i7liqA1yqgnWJh;IXl$SC=qb+$Yxq8xoargg32QmNojlP~UJ%w&gf>zclqVN@ ziVMP8h}@8N6@vj-6xJd&g^C)2MB(N+O*ZsGm=_3DV+>XUGcRFsqnErOtc6(#RpU7M zBa=8MtYx`y@`C;>F2|-twHVR^ZRcp5F3;uqK{7v;pJ>HVxLa z^PN1?8<7GMCLi>c7lbw4%Ah(D!8%|KtCq=vKJuKfCfeM|i9YgzuqN4NsE$Gu9S0|G z^pR)kGn?$?YbT@w%|DW$oC+S5m^{l@j!D98@-E+Xj0}@A{p6V@a8B;>%VUh6d{8KS zbAZ1BBV)wm!T^0n&dCb{#5rNzcZ10X1Jor0pq+ZCaSRR&3=B*Rd<+ef83VZAYw diff --git a/Source/Emulator/sound.c b/Source/Emulator/sound.c index ecd14fe..f16f958 100644 --- a/Source/Emulator/sound.c +++ b/Source/Emulator/sound.c @@ -213,6 +213,7 @@ static void setLfo(LFO *l, uint8_t which, uint8_t value) { case SP_LFO_ACTIVE: l->active = value != 0; break; case SP_LFO_WAVE: l->waveform = (Waveform)(value % WAVE_COUNT); break; case SP_LFO_RATE: l->rate = exponential(value, 0.05f, 20.0f); break; + case SP_LFO_MODE: l->mode = value ? LFO_RETRIGGER : LFO_FREE; break; default: break; } } @@ -228,8 +229,12 @@ static void soundParameter(uint8_t value) { case SP_MODENV: setEnvelope(&v->modEnv, which, value); break; case SP_FILTER: setFilter(&v->filter, parameter, value); break; case SP_LEVEL_SOURCE: + // Two settings share a group, because both are about the voice as a whole rather + // than about one of its parts. if (parameter == SP_LEVEL_SOURCE) { v->levelSource = sourceFor(value); + } else if (parameter == SP_VOICE_GATE) { + v->gate = value ? VOICE_TRIGGER : VOICE_GATE; } break; // The LFOs belong to the device rather than to a channel, so whichever channel is diff --git a/Source/Emulator/sound.h b/Source/Emulator/sound.h index 10445d1..2d48228 100644 --- a/Source/Emulator/sound.h +++ b/Source/Emulator/sound.h @@ -82,6 +82,17 @@ // LFOs. Nought is the one that could not be said before - see synth.h. #define SP_LEVEL_SOURCE 0x50 +// ---- Whether a note waits to be let go of ---- +// +// 0 is gated and 1 is triggered. Gated is what a keyboard wants: the sound lasts as long as +// the finger does, and dropping the gate starts the release. Triggered is what a GAME wants - +// a bang, a pickup, a door - where the note is struck and then plays its own length, and +// nothing has to remember to end it. Sustain and release have no meaning in a triggered +// voice, because both are answers to a question about a key that is not being asked. +// +// A program driving one-shots does not need port 0x45 at all once this is set. +#define SP_VOICE_GATE 0x51 + // 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 @@ -89,6 +100,16 @@ #define SP_LFO_ACTIVE 0x00 #define SP_LFO_WAVE 0x01 #define SP_LFO_RATE 0x02 +// ---- Whether it starts over when a voice does ---- +// +// 0 free and 1 retriggered. Free is one cycle running under everything, which is what vibrato +// across a held chord wants. Retriggered starts at the beginning of its shape every time a +// voice begins, which is the only way a one-shot sounds the SAME twice: a free LFO is wherever +// the wall clock left it, so the same drum caught at a different moment is a different drum. +// +// The mode belongs to the LFO and the cycle belongs to the voice, so retriggering costs +// nothing to a channel that is not using it. +#define SP_LFO_MODE 0x03 // ---- Samples come from the machine's clock ---- // diff --git a/Source/Emulator/synth.c b/Source/Emulator/synth.c index 77e87df..dbc2adc 100644 --- a/Source/Emulator/synth.c +++ b/Source/Emulator/synth.c @@ -10,25 +10,22 @@ // // 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. So the list below is the whole of the difference, kept current. +// carried across on purpose - in BOTH directions, which has now happened once each way. // // ---- What was changed ---- // -// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to -// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as -// well whether that was wanted or not - which is most of the trouble with making -// percussion. Every other destination in this synth chooses its source; now this one does -// too, and MOD_SOURCE_NONE means the level is simply full. +// Nothing. This is soundThing's engine at b73e5c0, 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. // -// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared -// with the whole process and varies between libraries - so the same program would sound -// different on different machines and every recorded result would be worthless. It is a -// generator inside the Synth now, and a machine that starts the same way sounds the same -// way. +// It did not start that way. Three changes were made here first - a routed voice level, a +// seeded noise generator, and channels asked for by number - and all three went up. What came +// back was those three plus what they made possible: a voice that can end itself rather than +// 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. // -// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals -// round-robin, which is what a keyboard wants. A hardware channel is asked for by number. -// The old calls are still here and still do what they did. +// 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. // // Written by Anachronaut @@ -42,6 +39,30 @@ #define M_PI 3.14159265358979323846 #endif +// ---- The seeds a retrigger goes back to ---- +// +// Deliberately NOT keyed on the voice, unlike the seeds synthInit hands out. A retriggered +// one-shot has to sound the same whichever voice happens to be free for it, and a seed that +// varied per voice would make the same drum a different drum eight ways. Keyed on the +// oscillator, though, because osc 0 and osc 1 drawing one stream are one noise heard twice. +static uint32_t oscTriggerSeed(int o) +{ + return (o == 0) ? 0x9E3779B9u : 0x7F4A7C15u; +} + +static uint32_t lfoTriggerSeed(int l) +{ + return 0x2545F491u + (uint32_t)l * 3266489917u; +} + +static void lfoStateInit(LfoState *st, uint32_t seed) +{ + st->phase = 0.0f; + st->noiseHeld = 0.0f; + st->noisePhase = 0.0f; + st->noiseState = seed; +} + void synthInit(Synth *s, float sampleRate) { s->sampleRate = sampleRate; @@ -58,14 +79,16 @@ void synthInit(Synth *s, float sampleRate) oscillatorInit(&s->voices[i].oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN); oscillatorInit(&s->voices[i].oscillators[1], WAVE_TRIANGLE, 0.5f, 0.0f, 0.0f); // A seed each, so that two noise oscillators sounding together are two noises rather - // than one heard twice. Any spread will do as long as none of them is zero. + // than one heard twice. The constants are arbitrary and non-zero. + // Envelope 0 shapes the level, which is what it always did - the difference is that + // 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)); s->voices[i].oscillators[0].noiseState = 0x9E3779B9u + (uint32_t)i * 2654435761u; s->voices[i].oscillators[1].noiseState = 0x7F4A7C15u + (uint32_t)i * 2246822519u; - // Envelope 0 shapes the level, which is what it always did - the difference is that - // this now says so, and can be told not to. - s->voices[i].levelSource = MOD_SOURCE_AMP_ENV; - envelopeInit(&s->voices[i].ampEnv, 0.005f, // attack 0.10f, // decay @@ -92,13 +115,13 @@ void synthInit(Synth *s, float sampleRate) s->voices[0].oscillators[0].active = 1; for (int l = 0; l < LFO_COUNT; l++) { - s->lfos[l].phase = 0.0f; + lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l)); s->lfos[l].rate = 1.0f; s->lfos[l].waveform = WAVE_SINE; s->lfos[l].active = 0; - s->lfos[l].noiseHeld = 0.0f; - s->lfos[l].noisePhase = 0.0f; - s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u; + // Free unless a patch says otherwise, which is what every patch that exists was + // made against. + s->lfos[l].mode = LFO_FREE; } } @@ -107,9 +130,14 @@ void synthResetPatch(Synth *s) Voice *v = &s->voices[0]; // The level is shaped by envelope 0 unless a patch says otherwise, which is what it - // always was - the difference is only that it can now be said otherwise. + // always was - now said out loud, so that a patch which routed it elsewhere does not + // leave the next one silent. v->levelSource = MOD_SOURCE_AMP_ENV; + // Held up by the key unless a patch says otherwise, which is what every patch that + // exists was made against. + v->gate = VOICE_GATE; + oscillatorInit(&v->oscillators[0], WAVE_TRIANGLE, 0.5f, 0.0f, OSC_MAX_GAIN); v->oscillators[0].active = 1; for (int m = 0; m < 3; m++) { @@ -139,13 +167,13 @@ void synthResetPatch(Synth *s) v->filter.resModDepth = 0.0f; for (int l = 0; l < LFO_COUNT; l++) { - s->lfos[l].phase = 0.0f; + lfoStateInit(&s->lfos[l].run, lfoTriggerSeed(l)); s->lfos[l].rate = 1.0f; s->lfos[l].waveform = WAVE_SINE; s->lfos[l].active = 0; - s->lfos[l].noiseHeld = 0.0f; - s->lfos[l].noisePhase = 0.0f; - s->lfos[l].noiseState = 0x2545F491u + (uint32_t)l * 3266489917u; + // Free unless a patch says otherwise, which is what every patch that exists was + // made against. + s->lfos[l].mode = LFO_FREE; } s->volume = 0.8f; @@ -191,19 +219,25 @@ static float getModValue(float ampEnv, float modEnv, float lfo0, float lfo1, Mod } } -float lfoTick(LFO *l, float sampleRate) +float lfoTickState(const LFO *l, LfoState *st, float sampleRate) { if (!l->active) return 0.0f; - l->phase += l->rate / sampleRate; - if (l->phase >= 1.0f) l->phase -= 1.0f; + st->phase += l->rate / sampleRate; + if (st->phase >= 1.0f) st->phase -= 1.0f; if (l->waveform == WAVE_NOISE) { - l->noisePhase += l->rate / sampleRate; - if (l->noisePhase >= 1.0f) { - l->noisePhase -= 1.0f; - l->noiseHeld = nextNoise(&l->noiseState); + st->noisePhase += l->rate / sampleRate; + if (st->noisePhase >= 1.0f) { + st->noisePhase -= 1.0f; + st->noiseHeld = nextNoise(&st->noiseState); } } - return waveformSample(l->waveform, l->phase, 0.5f, l->noiseHeld); + return waveformSample(l->waveform, st->phase, 0.5f, st->noiseHeld); +} + +// The LFO advancing its own cycle: the free-running one, the same for every voice. +float lfoTick(LFO *l, float sampleRate) +{ + return lfoTickState(l, &l->run, sampleRate); } float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate) @@ -330,6 +364,43 @@ const char *waveformName(Waveform w) } } +// Everything about a voice that a note begins rather than inherits. +// +// The envelopes are told, once, whether this note is waiting on a key - asked here rather +// than read live in the mixer so a note already sounding keeps the shape it began with. +// +// A TRIGGERED voice also starts its oscillators over. They are the larger half of why the +// same one-shot came out different every time: the envelopes restarted and the filter was +// cleared, but the oscillator phase carried on from wherever the last note left it, so a +// kick began a third of the way into its own cycle depending on what played before it. A +// gated voice is left alone, because a key being held is not a claim about phase and every +// 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) +{ + int oneShot = (v->gate == VOICE_TRIGGER); + v->ampEnv.oneShot = oneShot; + v->modEnv.oneShot = oneShot; + + if (v->gate == VOICE_TRIGGER) { + for (int o = 0; o < OSC_COUNT; o++) { + v->oscillators[o].phase = 0.0f; + v->oscillators[o].noiseHeld = 0.0f; + v->oscillators[o].noisePhase = 0.0f; + v->oscillators[o].noiseState = oscTriggerSeed(o); + } + } + + for (int l = 0; l < LFO_COUNT; l++) + if (s->lfos[l].mode == LFO_RETRIGGER) + lfoStateInit(&v->lfoRun[l], lfoTriggerSeed(l)); + + envelopeNoteOn(&v->ampEnv); + envelopeNoteOn(&v->modEnv); +} + void synthNoteOn(Synth *s, int midiNote) { float hz = 440.0f * powf(2.0f, (midiNote - 69) / 12.0f); @@ -341,8 +412,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; - envelopeNoteOn(&s->voices[i].ampEnv); - envelopeNoteOn(&s->voices[i].modEnv); + voiceArm(s, &s->voices[i]); return; } } @@ -355,29 +425,28 @@ 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; - envelopeNoteOn(&s->voices[i].ampEnv); - envelopeNoteOn(&s->voices[i].modEnv); + voiceArm(s, &s->voices[i]); } void synthNoteOff(Synth *s, int midiNote) { for (int i = 0; i < VOICE_COUNT; i++) { if (s->voices[i].active && s->voices[i].midiNote == midiNote) { + // A triggered voice plays its own length; the key coming up is not its business. + // Asked of the envelope rather than the voice, because that is what the note was + // started with and the switch may have moved since. + if (s->voices[i].ampEnv.oneShot) continue; envelopeNoteOff(&s->voices[i].ampEnv); envelopeNoteOff(&s->voices[i].modEnv); } } } -// ---- A channel is the channel you asked for ---- +// ---- Asked for by number, rather than allocated ---- // -// synthNoteOn hunts for a free voice and steals round-robin, which is what a keyboard wants: -// eight fingers and no say in which voice serves which. A hardware channel is not like that. -// Channel two is channel two, it holds its patch between notes, and a program driving it -// knows perfectly well what it is doing - so these say which one and nothing is stolen. -// -// The two above are left exactly as they were, because the standalone synthesizer still wants -// them and a keyboard has not stopped being a keyboard. +// Channel two is channel two, it holds its patch between notes, and a program driving this as +// hardware can rely on both. synthNoteOn above steals a voice round-robin, which is right for +// a keyboard and wrong for anything addressing a fixed set of parts. void synthChannelOn(Synth *s, int channel, int midiNote) { if (channel < 0 || channel >= VOICE_COUNT) { @@ -391,8 +460,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; - envelopeNoteOn(&v->ampEnv); - envelopeNoteOn(&v->modEnv); + voiceArm(s, v); } void synthChannelOff(Synth *s, int channel) @@ -413,8 +481,10 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) { for (int i = 0; i < frames; i++) { float mix = 0.0f; - float lfo0 = lfoTick(&s->lfos[0], sr); - float lfo1 = lfoTick(&s->lfos[1], sr); + // 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]; @@ -423,11 +493,24 @@ void synthFillBuffer(Synth *s, int16_t *out, int frames) { float amp = envelopeTick(&vv->ampEnv, sr); float mod = envelopeTick(&vv->modEnv, sr); - if (vv->ampEnv.stage == ENV_IDLE) { + // A gated voice is over when envelope 0 is, which is after the key came up. + // A triggered one has no key to wait for, so it is over only when BOTH envelopes + // are - envelope 0 alone would cut a level that envelope 1 is still shaping. + int finished = (vv->ampEnv.stage == ENV_IDLE); + if (vv->ampEnv.oneShot) + finished = finished && (vv->modEnv.stage == ENV_IDLE); + if (finished) { vv->active = 0; 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++) { @@ -491,6 +574,7 @@ void envelopeInit(Envelope *e, float attackSec, float decaySec, float sustainLev e->decaySec = decaySec; e->sustainLevel = sustainLevel; e->releaseSec = releaseSec; + e->oneShot = 0; } void envelopeNoteOn(Envelope *e) @@ -521,9 +605,13 @@ float envelopeTick(Envelope *e, float sampleRate) case ENV_DECAY: { float inc = (e->decaySec <= 0.0f) ? 1.0f : (1.0f / (e->decaySec * sampleRate)); e->value -= inc; - if (e->value <= e->sustainLevel) { - e->value = e->sustainLevel; - e->stage = ENV_SUSTAIN; + // Sustain is where the decay stops and waits for the key. A one-shot has no key + // to wait for, so it decays the whole way and is finished - and MUST, or a patch + // with a sustain above nothing would hold a triggered voice open forever. + float floorLevel = e->oneShot ? 0.0f : e->sustainLevel; + if (e->value <= floorLevel) { + e->value = floorLevel; + e->stage = e->oneShot ? ENV_IDLE : ENV_SUSTAIN; } break; } @@ -549,6 +637,12 @@ float envelopeTick(Envelope *e, float sampleRate) void synthSyncVoices(Synth *s) { for (int v = 1; v < VOICE_COUNT; v++) { + // Sync what shapes the level. Without this the voices below hold whatever synthInit + // gave them, so a patch that routes its level elsewhere is honoured by voice 0 and by + // nothing else - which sounds like it works until a second note is playing. + s->voices[v].levelSource = s->voices[0].levelSource; + s->voices[v].gate = s->voices[0].gate; + // 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 5064de3..c1fd5b6 100644 --- a/Source/Emulator/synth.h +++ b/Source/Emulator/synth.h @@ -10,25 +10,22 @@ // // 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. So the list below is the whole of the difference, kept current. +// carried across on purpose - in BOTH directions, which has now happened once each way. // // ---- What was changed ---- // -// 1. A VOICE'S LEVEL IS A ROUTING. Envelope 0 multiplied the output and there was no way to -// say otherwise, so routing it to a filter or an oscillator meant it shaped the volume as -// well whether that was wanted or not - which is most of the trouble with making -// percussion. Every other destination in this synth chooses its source; now this one does -// too, and MOD_SOURCE_NONE means the level is simply full. +// Nothing. This is soundThing's engine at b73e5c0, 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. // -// 2. NOISE COMES FROM A SEEDED GENERATOR. It drew from rand(), which is global state shared -// with the whole process and varies between libraries - so the same program would sound -// different on different machines and every recorded result would be worthless. It is a -// generator inside the Synth now, and a machine that starts the same way sounds the same -// way. +// It did not start that way. Three changes were made here first - a routed voice level, a +// seeded noise generator, and channels asked for by number - and all three went up. What came +// back was those three plus what they made possible: a voice that can end itself rather than +// 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. // -// 3. CHANNELS ARE NAMED, NOT ALLOCATED. synthNoteOn hunts for a free voice and steals -// round-robin, which is what a keyboard wants. A hardware channel is asked for by number. -// The old calls are still here and still do what they did. +// 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. // // Written by Anachronaut @@ -69,6 +66,17 @@ typedef enum { MOD_SOURCE_LFO2 = 4 } ModSource; +// ---- Whether the key holds the note up ---- +// +// Gated is what a keyboard wants: the sound lasts as long as the finger does, and lifting it +// starts the release. Triggered is what a drum wants - the note is struck and then plays its +// own length, and the key coming up is not its business. Sustain and release have no meaning +// in a triggered voice, because both of them are answers to a question about the key. +typedef enum { + VOICE_GATE = 0, + VOICE_TRIGGER = 1 +} VoiceGate; + typedef struct { EnvStage stage; float value; // current output value, 0.0 to 1.0 @@ -76,20 +84,46 @@ typedef struct { float decaySec; float sustainLevel; float releaseSec; + // Set from the voice's gate at note-on, not read from it live, so that flipping the + // switch under a sounding note cannot strand it half-way through a shape it was not + // started in. One-shot runs the decay to nothing and finishes there. + int oneShot; } Envelope; +// ---- Where an LFO is in its cycle ---- +// +// Split out of the LFO itself because there is now more than one answer at a time. A free +// LFO has one cycle that every voice reads, which is what makes it a single sweep across a +// chord. A retriggered one has a cycle PER VOICE, restarted when that voice is struck - and +// a voice resetting the shared one would drag every note already sounding along with it. typedef struct { float phase; - float rate; // Hz - Waveform waveform; - int active; float noiseHeld; float noisePhase; // Its own noise, seeded at init. rand() is global state shared with the whole process and - // varies between C libraries, so the same program sounded different on different machines + // varies between C libraries, so the same patch sounded different on different machines // and no recorded result could mean anything. One generator EACH rather than one shared, // because two noise sources drawing from the same stream are not two noise sources. uint32_t noiseState; +} LfoState; + +// ---- Whether an LFO keeps its own time or starts when struck ---- +// +// Free is one cycle running under everything, which is what vibrato across a held chord +// wants. Retriggered starts at the beginning of its shape every time a voice begins, which +// is the only way a one-shot sounds the same twice - a free LFO is wherever the wall clock +// left it, so the same drum caught at a different moment is a different drum. +typedef enum { + LFO_FREE = 0, + LFO_RETRIGGER = 1 +} LfoMode; + +typedef struct { + LfoState run; // the free-running cycle, read by every voice in LFO_FREE + float rate; // Hz + Waveform waveform; + int active; + LfoMode mode; } LFO; typedef enum { @@ -119,11 +153,10 @@ typedef struct { float noiseHeld; // last drawn random value for clocked noise float noisePhase; // tracks when to draw a new noise value // Its own noise, seeded at init. rand() is global state shared with the whole process and - // varies between C libraries, so the same program sounded different on different machines + // varies between C libraries, so the same patch sounded different on different machines // and no recorded result could mean anything. One generator EACH rather than one shared, // because two noise sources drawing from the same stream are not two noise sources. uint32_t noiseState; - float gain; int active; // whether this oscillator contributes to output int octave; // transposition in octaves, -2 to +2 @@ -155,6 +188,16 @@ typedef struct { // It also makes two things possible that were not: envelope 1 shaping the volume, and an // LFO doing it, which is tremolo. ModSource levelSource; + // ---- Whether the key holds this voice up ---- + // + // The other half of what envelope 0 used to decide on its own. Naming the level's source + // 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]; } Voice; typedef struct { @@ -184,11 +227,12 @@ void synthResetPatch(Synth *s); void synthNoteOn(Synth *s, int midiNote); void synthNoteOff(Synth *s, int midiNote); -// ---- 3. A channel is asked for by number ---- +// ---- A channel asked for by number ---- // -// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants -// and what the standalone synthesizer still does. A hardware channel is not allocated: it is -// the third one, and it is the third one every time. +// The two above hunt for a free voice and steal round-robin, which is what a keyboard wants: +// a player presses keys and does not care which voice sounds them. Something driving this as +// HARDWARE does care - channel two is channel two, it keeps its patch between notes, and +// nothing may take it away. Both ways of asking are here and neither changes the other. void synthChannelOn(Synth *s, int channel, int midiNote); void synthChannelOff(Synth *s, int channel); void synthFillBuffer(Synth *s, int16_t *out, int frames); @@ -196,6 +240,8 @@ void synthSyncVoices(Synth *s); // LFO functions: float lfoTick(LFO *l, float sampleRate); +// The same advance against a cycle that is not the LFO's own, so a voice can run its own. +float lfoTickState(const LFO *l, LfoState *st, float sampleRate); // Filter functions: float filterTick(Filter *f, float input, float cutoff, float resonance, float sampleRate); diff --git a/Source/Patch/SoundPatch.c b/Source/Patch/SoundPatch.c index f2ece02..96f9d9b 100644 --- a/Source/Patch/SoundPatch.c +++ b/Source/Patch/SoundPatch.c @@ -103,9 +103,19 @@ static const Mapping mappings[] = { { "lfo0_active", 0x60, AS_BOOL, 0, 0, "LFO 0, on" }, { "lfo0_waveform", 0x61, AS_DIRECT, 0, 0, "waveform" }, { "lfo0_rate", 0x62, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" }, + { "lfo0_mode", 0x63, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" }, { "lfo1_active", 0x70, AS_BOOL, 0, 0, "LFO 1, on" }, { "lfo1_waveform", 0x71, AS_DIRECT, 0, 0, "waveform" }, { "lfo1_rate", 0x72, AS_EXPONENTIAL, 0.05, 20.0, "rate, in hertz" }, + { "lfo1_mode", 0x73, AS_DIRECT, 0, 0, "0 free, 1 starts with a voice" }, + + // ---- The two that are about the voice as a whole ---- + // + // soundThing gained a field for each of these at the same time the engine did. A patch + // written before that has neither, which is why the level source has a default below: it + // was always envelope 0 when there was nothing to say otherwise. + { "voice_levelSource", 0x50, AS_DIRECT, 0, 0, "what shapes the level" }, + { "voice_gate", 0x51, AS_DIRECT, 0, 0, "0 gated, 1 triggered" }, }; #define MAPPING_COUNT ((int)(sizeof(mappings) / sizeof(mappings[0]))) @@ -194,12 +204,23 @@ int main(int argc, char **argv) { return 1; } + // ---- A patch from before the level could be routed ---- + // + // Those files have no voice_levelSource, and what it meant there was always envelope 0. + // Said out loud rather than left out, because a channel keeps its patch between notes and + // would otherwise carry a previous one's routing into this one. + int levelIndex = -1; + for (int i = 0; i < MAPPING_COUNT; i++) { + if (mappings[i].parameter == 0x50) { levelIndex = i; break; } + } + int assumedLevel = (levelIndex >= 0 && !seen[levelIndex]); + if (assumedLevel) { + values[levelIndex] = 1; // MOD_SOURCE_AMP_ENV + seen[levelIndex] = 1; + } + int count = 0; for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++; - // The level routing is soundThing's one blind spot: it has no field for it because there - // it is always the amplitude envelope. Said out loud here so a channel that was left set - // some other way by a previous patch does not carry it into this one. - count++; FILE *out = stdout; if (argc > 3) { @@ -215,11 +236,11 @@ int main(int argc, char **argv) { fprintf(out, "#Data\n\n"); fprintf(out, "%s:\n", label); fprintf(out, " 0d%-24d; how many pairs follow\n", count); - fprintf(out, " 0x50 0x01 ; the level is the amplitude envelope\n"); for (int i = 0; i < MAPPING_COUNT; i++) { if (!seen[i]) continue; - fprintf(out, " 0x%02X 0d%-3d ; %s\n", - mappings[i].parameter, values[i], mappings[i].note); + fprintf(out, " 0x%02X 0d%-3d ; %s%s\n", + mappings[i].parameter, values[i], mappings[i].note, + (i == levelIndex && assumedLevel) ? ", which this patch predates" : ""); } if (out != stdout) fclose(out); return 0; diff --git a/SplitBit Programming Manual.md b/SplitBit Programming Manual.md index 2fcb78b..77885b0 100644 --- a/SplitBit Programming Manual.md +++ b/SplitBit Programming Manual.md @@ -1026,6 +1026,7 @@ The high nibble says which part of the voice, the low nibble which setting of it | 0x30 - 0x3F | The modulation envelope. | | 0x40 - 0x4F | The filter. | | 0x50 | What shapes the channel's level. | +| 0x51 | Whether a note waits to be let go of. | | 0x60 - 0x6F | LFO 0. | | 0x70 - 0x7F | LFO 1. | @@ -1062,6 +1063,7 @@ The high nibble says which part of the voice, the low nibble which setting of it | 0 | On, or off at zero. | | 1 | Waveform, from the same six. | | 2 | Rate. | +| 3 | 0 free, 1 starts over with every voice. See The Same Sound Twice. | Anywhere a setting asks *what modulates* something, the answer is one of these: @@ -1107,6 +1109,45 @@ output, so an envelope routed somewhere useful - opening the filter, bending a p 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. +### Struck Or Held: + +Setting 0x51 says whether a note waits to be let go of: **0 gated**, which is how it has always +been, and **1 triggered**. + +A gated voice lasts as long as something keeps hold of it. Writing the gate port ends it, and +the release begins there. That is what a keyboard is, and it is right for anything a player +holds down. + +A triggered voice is struck and then plays its own length. Nothing has to remember to end it, +and the gate port need never be written at all. Sustain and release have no meaning in one, +because both of them are answers to a question about a key that is not being asked - so in a +triggered voice the decay runs to nothing rather than stopping at the sustain level, or a +patch with any sustain at all would hold the voice open for ever. + +**A game is nearly all one-shots.** A bang, a pickup, a door: not one of them wants its length +decided by how long a note was held, and every one of them would otherwise need a program to +come back later and let go of it. + +### The Same Sound Twice: + +Setting 3 of either LFO says whether it starts over when a voice does: **0 free**, which is how +it has always been, and **1 retriggered**. + +A free LFO is one cycle running under everything, which is what vibrato across a held chord +wants. A retriggered one starts at the beginning of its shape every time a voice begins, and +the cycle belongs to the voice rather than to the device - so retriggering costs nothing to a +channel not using it. + +This is the other half of a repeatable sound effect, and **neither half is sufficient alone**. +A triggered voice re-arms its oscillators, so a hit begins at the same point in its waveform +every time and a noise source draws the same noise. But an LFO left free is wherever the wall +clock happened to leave it, so the same drum caught at a different moment is still a different +drum. Set both and a one-shot is the same one-shot, sample for sample. + +The consequence is worth knowing rather than fixing: a retriggered noise source is *bit* +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. + ### 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 diff --git a/Tests/expected/cosmosCrossDisk.out b/Tests/expected/cosmosCrossDisk.out index 3a2dc11..5675367 100644 --- a/Tests/expected/cosmosCrossDisk.out +++ b/Tests/expected/cosmosCrossDisk.out @@ -25,7 +25,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosDrives.out b/Tests/expected/cosmosDrives.out index e28a67e..b14e538 100644 --- a/Tests/expected/cosmosDrives.out +++ b/Tests/expected/cosmosDrives.out @@ -15,7 +15,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosFault.out b/Tests/expected/cosmosFault.out index 9f939f4..c78ed48 100644 --- a/Tests/expected/cosmosFault.out +++ b/Tests/expected/cosmosFault.out @@ -32,7 +32,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosFlip.out b/Tests/expected/cosmosFlip.out index c9438aa..c742305 100644 --- a/Tests/expected/cosmosFlip.out +++ b/Tests/expected/cosmosFlip.out @@ -22,7 +22,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosGrid.out b/Tests/expected/cosmosGrid.out index 08e4f9d..6edccb9 100644 --- a/Tests/expected/cosmosGrid.out +++ b/Tests/expected/cosmosGrid.out @@ -22,7 +22,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosMonitor.out b/Tests/expected/cosmosMonitor.out index 844abe3..e3dc5be 100644 --- a/Tests/expected/cosmosMonitor.out +++ b/Tests/expected/cosmosMonitor.out @@ -108,7 +108,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosMonitorRun.out b/Tests/expected/cosmosMonitorRun.out index 8bec3e5..636d1fe 100644 --- a/Tests/expected/cosmosMonitorRun.out +++ b/Tests/expected/cosmosMonitorRun.out @@ -27,7 +27,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosRun.out b/Tests/expected/cosmosRun.out index b2c61be..f77fae1 100644 --- a/Tests/expected/cosmosRun.out +++ b/Tests/expected/cosmosRun.out @@ -15,7 +15,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosSlowDisk.out b/Tests/expected/cosmosSlowDisk.out index e85df47..1647cce 100644 --- a/Tests/expected/cosmosSlowDisk.out +++ b/Tests/expected/cosmosSlowDisk.out @@ -13,7 +13,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/expected/cosmosSprite.out b/Tests/expected/cosmosSprite.out index 79b5c74..373322f 100644 --- a/Tests/expected/cosmosSprite.out +++ b/Tests/expected/cosmosSprite.out @@ -22,7 +22,7 @@ Mode.sbx 48 Flip.sbx 173 Sprite.sbx 442 Depth.sbx 672 -Lander.sbx 8412 +Lander.sbx 8409 Pad.sbx 264 Crash.sbx 632 vars.script 50 diff --git a/Tests/sound.sh b/Tests/sound.sh index 7b0b9fc..7f16357 100755 --- a/Tests/sound.sh +++ b/Tests/sound.sh @@ -409,6 +409,93 @@ LATE="$(measure levelEnvelope peak 36000)" && 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" +# ---- A triggered voice ends itself ---- +# +# Gated is what a keyboard wants: the sound lasts as long as something holds it, and dropping +# the gate is what starts the release. A GAME is nearly all one-shots - a bang, a pickup, a +# door - and not one of them wants its length decided by how long a note was held. +# +# Neither program below ever drops the gate. The gated one is still sounding at the end of it, +# because a sustain above nothing is a voice waiting for a key that is never coming; the +# triggered one runs its decay to nothing and finishes. +for gate in 0 1; do + { printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x51 $gate # gated or triggered + param 0x20 0x00 # attack: instant + param 0x21 60 # decay: short + param 0x22 128 # sustain: half, which a gated voice will hold at + port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run gate$gate 3000000 || exit 1 +done +HELD="$(measure gate0 peak 96000)" +STRUCK="$(measure gate1 peak 96000)" +[ "$HELD" -gt 1000 ] \ + && result ok "a gated voice waits to be let go of" "still $HELD with nothing holding it" \ + || result no "a gated voice waits to be let go of" "faded to $HELD on its own" +[ "$STRUCK" -lt 100 ] \ + && result ok "and a triggered one ends itself" "down to $STRUCK, with no gate ever dropped" \ + || result no "and a triggered one ends itself" "still $STRUCK, so it is waiting for a key" + +# ---- And it is the same one-shot twice ---- +# +# The point of the whole thing. A triggered voice re-arms its oscillators, so a hit begins in +# the same place every time; a free LFO does not, and reading it at a different moment makes +# the same drum a different drum. Both halves are needed, which is why this asks for the LFO +# to restart as well. +# +# Two hits, compared SAMPLE FOR SAMPLE. Noise is the hard case and the reason the generator is +# seeded: with rand() this could not have been asked at all. +# +# THE GAP HAS TO OUTLAST THE DECAY. The first version struck the second note while the first +# was still ringing, so what it found and compared as "the second hit" was a point in the +# middle of the first one's tail. A short decay and a quarter second between them, and the two +# are found by looking for sound after silence rather than for sound after an offset. +{ printf '#Program\nstart:\n'; port 0x41 0x00; loud + param 0x00 5 # noise + param 0x51 0x01 # triggered + param 0x20 0x00; param 0x21 40; param 0x22 0x00 + param 0x60 0x01 # LFO 0 on, + param 0x62 40 # a slow rate, + param 0x63 0x01 # and it starts over with each voice + param 0x44 0x03 # which opens the filter + param 0x40 0x01; param 0x42 60; param 0x45 200 + port 0x44 60; pause one 250 + port 0x44 60; spinForever + printf '#Vectors\n Boot start\n'; } | run twice 4000000 || exit 1 +SAME="$(python3 - "$BUILD/twice.raw" <<'PY' +import struct, sys +data = open(sys.argv[1], "rb").read() +v = struct.unpack("<%dh" % (len(data) // 2), data) +# Find the two hits by where sound starts, then compare the same span of each. +def onsets(v, floor=200, quiet=2000): + # Sound after silence. A run of quiet samples has to separate two hits, or what is found + # is one hit and a place in the middle of its own tail. + at, run, sounding = [], 0, False + for i, x in enumerate(v): + if abs(x) > floor: + if not sounding: + at.append(i) + sounding = True + run = 0 + elif sounding: + run += 1 + if run >= quiet: + sounding = False + return at +hits = onsets(v) +if len(hits) < 2: + print("onehit" if hits else "silent"); raise SystemExit +first, second = hits[0], hits[1] +span = min(3000, len(v) - second) +a = v[first:first + span] +b = v[second:second + span] +print("same" if a == b else "differ") +PY +)" +[ "$SAME" = "same" ] \ + && 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" + echo if [ "$FAIL" -eq 0 ]; then echo "All $PASS sound checks passed."