Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ This sample is honest about a hard distinction:
- The master 68000 runs continuously on a 16 MHz emulated clock with a 1 kHz timer source. The model exposes the documented UART, timer/mode, RTC/latch, front-panel switch/LED, ADC-selection/readback, shared-memory, and LCD windows used by the authenticated boot path.
- The WDV voice image's 32-byte loader header is decoded and its genuine 68000 body publishes at shared mailbox `$105086`, waits for the main CPU at `$10508A`, and reaches its service state after that externally visible acknowledgement. Production code no longer inserts a synthetic acknowledgement.
- Both CPU runtimes use the same shared program/work SRAM object. The genuine master `w2sys.bin` executes its cold-start hardware writes and its OS 1.700 post-copy loader handoff, writing the observed acknowledgement value `1` to `$10508A`; the WDV CPU then publishes service at `$10508E`.
- The decoded WDV service transport uses a per-board 16-voice update mask at `$105092`, per-voice TAS semaphores at `$1050A2`, and 256-byte voice records beginning at `$100000`. The WDV runtime maps its observed waveform RAM at `$600000`, CV banks at `$880000/$8A0000`, and two oscillator-chip register pages at `$980000/$980100`. All writes are timestamped on the WDV clock for correlation with captures.
- The decoded WDV service transport uses a per-board 16-voice update mask at `$105092`, per-voice TAS semaphores at `$1050A2`, and 256-byte voice records beginning at `$100000`. The WDV runtime maps its two wave RAMs at `$040000`/`$050000` (with a write-both alias at `$060000`), CV banks at `$880000/$8A0000`, and two oscillator-chip register pages at `$980000/$980100`. All writes are timestamped on the WDV clock for correlation with captures.
- The current bridge supplies `INIT.SND` and `INIT.PFM` through a narrowly scoped virtual system-disk service, and the genuine OS 1.700 loader opens, reads, and closes both files. Until original factory files are available, their contents are byte-exact copies of the genuine safe sound and performance records embedded in OS 1.700; they are conservative defaults, not claimed factory banks. The same records are preloaded as a defensive fallback. The CPU board's DP8473 floppy controller is modelled for mounted raw MS-DOS disk images; the separate boot-time `WDV.SYS` copy remains bypassed because the authenticated WDV body is installed directly into shared SRAM before the genuine master instructions resume at the verified post-copy handoff. This is not presented as a complete main-OS boot.
- The CPU-board LCD is represented as two byte-wide 5563 SRAMs forming a 16-bit word, four 4 KiB pages, two parallel 74LS166 shifters, and the firmware page latch at `$BE0001`. The genuine OS renderer proves a 64-byte hardware scanline: 60 visible bytes for 480 pixels followed by four blanking bytes. Writes in the `$FEA00000-$FEA03FFF` video window automatically replace the model diagnostics with correctly decoded firmware scanout.
- The oscillator ASIC is an undocumented black box. Its internal algorithms cannot be recovered or claimed from the available documentation. The DSP is explicitly an external behavioural proxy, using Waldorf's documented 250 kHz rate and ES2 eight-bit numerical mix-overflow boundary; its exact accumulator ordering and truncation remain to be validated against real-hardware measurements. Firmware register traces reveal only the external contract.
Expand Down
113 changes: 81 additions & 32 deletions Source/Dsp/WaldorfEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,33 @@
#include <juce_dsp/juce_dsp.h>

#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <utility>

namespace wave::dsp
{
namespace
{
// WDV OS 1.700 stores modulation amounts in a 128-word table at 0x25e8.
// Apart from the saturated -64 endpoint, its exact relationship is
// sign(amount) * amount^2 / 4096. The pitch routine applies that curve twice,
// then both oscillator callers at $0e34/$0eea arithmetic-shift its result
// right by three before adding it to the pitch accumulator.
// Apart from the saturated -64 endpoint and three typos, its relationship is
// sign(amount) * amount^2 / 4096 (8 * n^2 of 32768). The typos are index 6
// (amount -58: -29912 instead of -26912), index 126 (+62: 31000 instead of
// 30752) and index 127 (+63: 32767 instead of 31752). The pitch routine
// applies the result squared, then both oscillator callers at $0e14/$0eca
// arithmetic-shift it right by three before adding it to the pitch
// accumulator.
float wdvAmountDepth(float amount) noexcept
{
const auto limited = juce::jlimit(-64.0f, 63.0f, amount);
return std::copysign(limited * limited / 4096.0f, limited);
static constexpr std::array<std::pair<float, float>, 3> tableTypos {{
{ -58.0f, -3000.0f }, { 62.0f, 248.0f }, { 63.0f, 1015.0f }
}};
auto depth = std::copysign(limited * limited / 4096.0f, limited);
for (const auto& [index, error] : tableTypos)
depth += error / 32768.0f * juce::jmax(0.0f, 1.0f - std::abs(limited - index));
return depth;
}

float wdvPitchDepthSemitones(float amount) noexcept
Expand Down Expand Up @@ -483,6 +494,32 @@ void WaldorfEngine::Voice::prepare(double newSampleRate, int index)
reset();
}

int WaldorfEngine::startPhaseCode(int programmed, int storedAmount, int source,
bool secondOscillator) noexcept
{
if (programmed <= 0)
return 0;

const auto word = [](int value) { return static_cast<int16_t>(static_cast<uint16_t>(value)); };
// WDV 0x246C: source word times the amount table at 0x25E8 (saturated at
// -32767), keeping the high word and doubling it in a 16-bit add.
auto modulation = 0;
if (storedAmount != 64 && source != 0)
{
const auto table = juce::jlimit(
-32767, 32767,
juce::roundToInt(wdvAmountDepth(static_cast<float>(storedAmount - 64)) * 32768.0f));
modulation = word(((source * table) >> 16) * 2);
}
const auto code = word(word(programmed << 8) + modulation) >> 8;
if (code == 0)
return 1;
// 0xFB8 compares signed (ble) in Wave 1, 0x109C unsigned (bcs) in Wave 2.
if (!secondOscillator)
return std::min(code, 127) & 0xff;
return code > 0 && code < 127 ? code : 127;
}

void WaldorfEngine::Voice::reset()
{
oscillator1.reset();
Expand Down Expand Up @@ -510,7 +547,6 @@ void WaldorfEngine::Voice::reset()
active = false;
currentWavePosition = 0.0f;
currentLfoValues.fill(0.0f);
waveStartOffsets.fill(0.0f);
currentPitchModulations.fill(0.0f);
currentGlideNote = 0.0f;
targetGlideNote = 0.0f;
Expand Down Expand Up @@ -544,7 +580,7 @@ void WaldorfEngine::Voice::reset()
vcaDrainSamplesRemaining = 0;
controlFilterMode = 0;
asicClockPhase = 1.0;
waveStartOffsetsPending = false;
startPhaseModPending = false;
filterEnvelopePending = false;
filterEnvelopeTriggered = false;
}
Expand Down Expand Up @@ -684,8 +720,7 @@ void WaldorfEngine::Voice::start(int midiNote, int midiChannel, float noteVeloci
for (size_t lfo = 0; lfo < lfos.size(); ++lfo)
lfos[lfo].noteOn(parameters.lfos[lfo].sync,
parameters.lfos[lfo].phaseDegrees);
waveStartOffsets.fill(0.0f);
waveStartOffsetsPending = true;
startPhaseModPending = true;
}

void WaldorfEngine::Voice::release(bool allowSustain)
Expand Down Expand Up @@ -889,24 +924,37 @@ Cem3387::StereoSample WaldorfEngine::Voice::process(
{
// Start modifiers are sampled at note-on; all other WDV targets are
// refreshed by the voice-board control interrupt rather than by the
// host audio clock.
if (waveStartOffsetsPending)
// host audio clock. WDV 0xF92/0x1074 add the route to Startphase (sound
// byte 27/43, one full cycle at full scale) in 16-bit arithmetic, see
// startPhaseCode; a Startphase of 0 is a free start and ignores it.
if (startPhaseModPending)
{
waveStartOffsets[0]
= 64.0f
* wdvAmountDepth(
parameters.modulationRoutes[parameters::wave1StartMod].amount)
* routeValue(parameters::wave1StartMod, parameters, env,
waveEnvelopeValue, currentLfoValues, modWheel,
channelPressure, pitchBend);
waveStartOffsets[1]
= 64.0f
* wdvAmountDepth(
parameters.modulationRoutes[parameters::wave2StartMod].amount)
* routeValue(parameters::wave2StartMod, parameters, env,
waveEnvelopeValue, currentLfoValues, modWheel,
channelPressure, pitchBend);
waveStartOffsetsPending = false;
constexpr std::array<parameters::ModulationRouteIndex, 2> startRoutes {
parameters::wave1StartMod, parameters::wave2StartMod
};
std::array<OscillatorChipProxy*, 2> oscillators { &oscillator1, &oscillator2 };
for (size_t i = 0; i < startRoutes.size(); ++i)
{
const auto& route = parameters.modulationRoutes[startRoutes[i]];
// ponytail: float sources map to words at 32768 full scale; the
// master's MIDI controller words are CC << 8 (max 0x7F00), so a
// controller-driven start can land one step off. Per-source word
// scales would fix every route, not just this one.
const auto source = juce::jlimit(
-32768, 32767,
juce::roundToInt(32768.0f
* routeValue(startRoutes[i], parameters, env,
waveEnvelopeValue, currentLfoValues,
modWheel, channelPressure, pitchBend)));
const auto code = startPhaseCode(juce::roundToInt(parameters.wavePhases[i]),
juce::roundToInt(route.amount) + 64,
source, i == 1);
// ponytail: the ASIC takes the code as 8 bits, but Startphase
// is documented 0..127 = one cycle; bit 7 is assumed ignored.
if (code != 0)
oscillators[i]->reset(static_cast<double>(code & 127) / 128.0);
}
startPhaseModPending = false;
}

const auto bend1 = pitchBend * parameters.oscillatorBendRanges[0] / 2.0f;
Expand Down Expand Up @@ -962,8 +1010,8 @@ Cem3387::StereoSample WaldorfEngine::Voice::process(
baseFrequency * performanceRatio
* std::exp2((pitch2 + detune[1] / 100.0f) / 12.0f));

const auto wave1Mod = waveStartOffsets[0]
+ 64.0f * wdvAmountDepth(
const auto wave1Mod
= 64.0f * wdvAmountDepth(
parameters.modulationRoutes[parameters::wave1Mod1].amount)
* routeValue(parameters::wave1Mod1, parameters, env,
waveEnvelopeValue, currentLfoValues, modWheel,
Expand All @@ -983,8 +1031,8 @@ Cem3387::StereoSample WaldorfEngine::Voice::process(
+ 64.0f * wdvAmountDepth(parameters.waveKeytrackAmounts[0]) * keyPosition
+ wave1Mod);
currentWavePosition = controlWavePositions[0];
const auto wave2Mod = waveStartOffsets[1]
+ 64.0f * wdvAmountDepth(
const auto wave2Mod
= 64.0f * wdvAmountDepth(
parameters.modulationRoutes[parameters::wave2Mod1].amount)
* routeValue(parameters::wave2Mod1, parameters, env,
waveEnvelopeValue, currentLfoValues, modWheel,
Expand Down Expand Up @@ -1641,7 +1689,8 @@ void WaldorfEngine::renderRangeChunk(juce::AudioBuffer<float>& output,
continue;
}
const auto& layer = performance.layers[static_cast<size_t>(voice.layerIndex)];
const auto audible = layer.enabled && !layer.muted && layer.audioOutput == 0
const auto audible = layer.enabled && !layer.muted
&& layer.audioOutput == PerformanceLayer::mainAudioOut
&& (!hasSoloedInstrument || layer.soloed);
voice.currentFreeWheel
= juce::jlimit(-1.0f, 1.0f,
Expand Down Expand Up @@ -1974,7 +2023,7 @@ WaldorfEngine::VoiceProbe WaldorfEngine::probeVoice(
voice.start(midiNote, 1, velocity, order, order, layerIndex, layer,
tunedNote, tunedNote, 0.0f,
false, false, 0.0f, 0, 0.0f, 0.0f, 0.0f);
const auto performanceGain = layer.audioOutput == 0 ? layer.gain : 0.0f;
const auto performanceGain = layer.audioOutput == PerformanceLayer::mainAudioOut ? layer.gain : 0.0f;
result.minimumCutoffHz = std::numeric_limits<float>::max();
double energy = 0.0;
const auto bank = wavetableBank.renderSnapshot();
Expand Down
17 changes: 14 additions & 3 deletions Source/Dsp/WaldorfEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ class WaldorfEngine
float detuneCents = 0.0f;
float gain = 1.0f;
float auxGain = 0.0f;
int audioOutput = 0;
// Firmware "Audio Out" (Instrument part byte +0xC): 0 Aux only,
// 1 main, 2 sub 1, 3 sub 2. Only main reaches the plugin output.
static constexpr int mainAudioOut = 1;
int audioOutput = mainAudioOut;
int keyLow = 0;
int keyHigh = 127;
int velocityLow = 1;
Expand Down Expand Up @@ -79,6 +82,15 @@ class WaldorfEngine
float circuitAgeAmount = 0.18f;
};

// Phase-load code the WDV writes to the ASIC at key-on (OS 1.700 0xF92 for
// Wave 1, 0x1074 for Wave 2). `programmed` is Startphase (sound byte 27/43,
// 0 = free start), `storedAmount` the route amount byte (0..127, 64 = off)
// and `source` the 16-bit modulation word. Returns 0 for a free start
// (nothing is written), otherwise the byte the firmware sends: the sum is
// formed in 16-bit words, so Wave 1 wraps to 8 bits where Wave 2 saturates.
[[nodiscard]] static int startPhaseCode(int programmed, int storedAmount, int source,
bool secondOscillator) noexcept;

struct VoiceProbe
{
float minimumCutoffHz = 0.0f;
Expand Down Expand Up @@ -257,7 +269,6 @@ class WaldorfEngine
bool active = false;
float currentWavePosition = 0.0f;
std::array<float, 2> currentLfoValues{};
std::array<float, 2> waveStartOffsets{};
std::array<float, 2> currentPitchModulations{};
float currentGlideNote = 0.0f;
float targetGlideNote = 0.0f;
Expand Down Expand Up @@ -297,7 +308,7 @@ class WaldorfEngine
int filterEnvelopeDelaySamples = 0;
int vcaDrainSamplesRemaining = 0;
int controlFilterMode = 0;
bool waveStartOffsetsPending = false;
bool startPhaseModPending = false;
bool filterEnvelopePending = false;
bool filterEnvelopeTriggered = false;
};
Expand Down
8 changes: 5 additions & 3 deletions Source/Firmware/FirmwareBundle.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ Bundle::Report Bundle::load(const juce::File& directoryOrImage)
{
clear();

const auto root = directoryOrImage.isDirectory()
? directoryOrImage
: directoryOrImage.getParentDirectory();
// A missing path must not fall back to its parent: the recursive image
// search below would then walk e.g. the whole filesystem for "/nonexistent".
const auto root = directoryOrImage.existsAsFile()
? directoryOrImage.getParentDirectory()
: directoryOrImage;
const auto masterFile = findImage(root, "w2sys.bin");
const auto voiceFile = findImage(root, "wdv.sys");

Expand Down
9 changes: 0 additions & 9 deletions Source/Firmware/MasterFirmwareRuntime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1177,9 +1177,6 @@ uint8_t MasterFirmwareRuntime::read8(uint32_t address) noexcept
return sharedMemory->program[address - sharedProgramBase];
if (address >= sharedWorkBase && address < sharedWorkBase + sharedMemory->work.size())
return sharedMemory->work[address - sharedWorkBase];
if (address >= sharedExtensionBase
&& address < sharedExtensionBase + sharedMemory->extension.size())
return sharedMemory->extension[address - sharedExtensionBase];
if (address >= lcdVideoBase && address < lcdVideoBase + lcdVideoRam.size())
return lcdVideoRam[address - lcdVideoBase].load(std::memory_order_relaxed);

Expand Down Expand Up @@ -1265,12 +1262,6 @@ void MasterFirmwareRuntime::write8(uint32_t address, uint8_t value) noexcept
sharedMemory->work[address - sharedWorkBase] = value;
return;
}
if (address >= sharedExtensionBase
&& address < sharedExtensionBase + sharedMemory->extension.size())
{
sharedMemory->extension[address - sharedExtensionBase] = value;
return;
}
if (address >= lcdVideoBase && address < lcdVideoBase + lcdVideoRam.size())
{
lcdVideoRam[address - lcdVideoBase].store(value, std::memory_order_relaxed);
Expand Down
1 change: 0 additions & 1 deletion Source/Firmware/MasterFirmwareRuntime.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,6 @@ class MasterFirmwareRuntime final : private M68000Bus
static constexpr uint32_t sharedProgramSize = SharedFirmwareMemory::programSize;
static constexpr uint32_t sharedWorkBase = 0x140000;
static constexpr uint32_t sharedWorkSize = SharedFirmwareMemory::workSize;
static constexpr uint32_t sharedExtensionBase = 0x180000;
static constexpr uint32_t lcdVideoBase = 0xa00000;
static constexpr uint32_t lcdVideoWindowSize = 0x4000;
using LcdVideoSnapshot = std::array<uint8_t, lcdVideoWindowSize>;
Expand Down
4 changes: 3 additions & 1 deletion Source/Firmware/SharedFirmwareMemory.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ struct SharedFirmwareMemory
static constexpr uint32_t mainRamSize = 0x100000;
static constexpr uint32_t programSize = 0x40000;
static constexpr uint32_t workSize = 0x40000;
static constexpr uint32_t extensionSize = 0x80000;
// The shared SRAM is 512 KB (0x100000-0x17FFFF = program + work); nothing
// is decoded above it, so accesses there count as unmapped.
static constexpr uint32_t extensionSize = 0;

void clear() noexcept
{
Expand Down
Loading