Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,13 @@
*.provisionprofile
*.keychain
*.keychain-db

# Local Waldorf Wave resources
/Sounds/
/System/
/Tools/
/Sounds.zip
/System.zip
/Tools.zip
/Waldorf-WAVE-Service-Manual.pdf
/wave_header_3840x1440px.png
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,11 @@ 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.
- Static trace of the WDV image (OS 1.700): each oscillator level reaches the ASIC as three bits (`ws level >> 4`, writer at `$9B8`/`$A58`), never a CV channel. The 7-bit level path in `WaldorfEngine` is therefore finer than the firmware contract. `WaldorfEngine::setQuantiseWaveLevels` (off by default, no UI) keeps only those three bits, scaled back to 0..`$70`; that scaling is a hypothesis about the ASIC gain law and needs an eight-step real-hardware level capture.
- The included procedural wavetable bank is a neutral fallback. The loader accepts a user-supplied exact 64 or 128 x 64 x 128 signed-eight-bit dump, a 32 x 64 x 128 first-table dump, or a raw PPG Wave 2.2/2.3 EPROM image. Raw PPG images are decoded from their 768-byte sparse table directory and 256 stored 64-sample half waves. The V6 firmware's recursive midpoint averaging, complemented half-cycle, calculated tables 28/29, and byte-exact triangle/pulse/square/saw tail are reproduced, yielding all 30 original lower tables. Native Wave SET images also install all 64 user Wavetables and their 1000-Wave pool. The lower 30 PPG tables are included as decoded samples. Remaining tables retain procedural fallback data until a user imports a bank.
- The filter path implements all four sound-record modes: analogue 24 dB low-pass, digital 12 dB high-pass, linked serial band-pass with width, and Dual mode with independently modulated HP/LP cutoffs. The WDV frequency table establishes semitone-spaced cutoff values beginning at 20 Hz. The fixed CEM3387 three-pole 1 dB Chebyshev reconstruction section and nonlinear resonant four-pole low-pass are separate from the pre-analogue ASIC high-pass. Envelope selection, velocity, key tracking, both modulation routes, resonance modulation, and maximum-resonance self-oscillation are active. Unknown ASIC arithmetic and unmeasured component tolerances remain explicit calibration hypotheses.
- The mixed-signal path explicitly models eight-bit multiplexed conversion, signed ES2 mixer wrap, sample-and-hold behaviour, three-pole reconstruction and AC coupling, the distinct VCF input saturation around 70% mixer output, AD7545-style 12-bit CV quantisation, nonlinear four-pole CEM3387 filtering, VCA bleed/noise, panning, output coupling, slew, and rails. Values without measurements remain documented calibration hypotheses.
Expand Down
8 changes: 8 additions & 0 deletions Source/Dsp/WaldorfAsic.h
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,14 @@ class AsicOutputMixer
int8_t oscillator2,
uint8_t level1,
uint8_t level2) noexcept;
// The WDV firmware sends each oscillator level to the ASIC as only three
// bits (ws level >> 4, OS 1.700 WDV 0x9B8/0xA58). Scaling that code back to
// the 0..0x70 level range is a hypothesis about the ASIC's gain law, not a
// documented fact.
[[nodiscard]] static uint8_t levelFromRegisterBits(uint8_t level) noexcept
{
return static_cast<uint8_t>(level & 0x70u);
}
[[nodiscard]] static int8_t quantiseOscillator(float sample) noexcept;
[[nodiscard]] static float normaliseOutput(int8_t sample) noexcept;
};
Expand Down
30 changes: 23 additions & 7 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 @@ -1078,10 +1089,15 @@ Cem3387::StereoSample WaldorfEngine::Voice::process(
}

reconstruction.setAge(parameters.circuitAgeAmount);
const auto levelCode1 = static_cast<uint8_t>(juce::jlimit(
auto levelCode1 = static_cast<uint8_t>(juce::jlimit(
0, 127, juce::roundToInt(controlWaveLevels[0] * 112.0f)));
const auto levelCode2 = static_cast<uint8_t>(juce::jlimit(
auto levelCode2 = static_cast<uint8_t>(juce::jlimit(
0, 127, juce::roundToInt(controlWaveLevels[1] * 112.0f)));
if (quantiseWaveLevels)
{
levelCode1 = AsicOutputMixer::levelFromRegisterBits(levelCode1);
levelCode2 = AsicOutputMixer::levelFromRegisterBits(levelCode2);
}
asicClockPhase += OscillatorChipProxy::modelClockRate() / sampleRate;
while (asicClockPhase >= 1.0)
{
Expand Down
8 changes: 8 additions & 0 deletions Source/Dsp/WaldorfEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ class WaldorfEngine
[[nodiscard]] float firstActiveVcaControlValue() const noexcept;
[[nodiscard]] uint16_t filterCalibrationCode(int voice) const noexcept;
void setFilterCalibrationCode(int voice, uint16_t code) noexcept;
// Not synchronised: call with the callback lock held (or before audio starts).
void setQuantiseWaveLevels(bool enabled) noexcept
{
for (auto& voice : voices)
voice.quantiseWaveLevels = enabled;
}
void setVoiceCardThreadingEnabled(bool enabled) noexcept
{
voiceCardThreadingEnabled.store(enabled, std::memory_order_relaxed);
Expand Down Expand Up @@ -192,6 +198,8 @@ class WaldorfEngine
{
circuit.setCutoffCalibrationCode(code);
}
// Off by default; see AsicOutputMixer::levelFromRegisterBits.
bool quantiseWaveLevels = false;

[[nodiscard]] float sourceValue(int source, float ampEnvelope,
float waveEnvelopeValue,
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
25 changes: 16 additions & 9 deletions Source/Firmware/MasterFirmwareRuntime.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,21 @@ void MasterFirmwareRuntime::Acia6850::pushReceive(uint8_t value) noexcept
}
}

bool MasterFirmwareRuntime::setVoiceAllocationFix(bool enabled) noexcept
{
voiceAllocationFix = enabled;
// find_free_voice: cmpw 80(a0),d5 (BA68 0050) ; bcs.s +6 (65 06) ; movew 80(a0),d5 ; movew d7,d4
constexpr uint32_t branch = 0x2ee2u;
constexpr uint8_t prefix[] = { 0xba, 0x68, 0x00, 0x50 };
auto& ram = sharedMemory->mainRam;
const auto isOs1700 = std::equal(std::begin(prefix), std::end(prefix), ram.begin() + (branch - 4u))
&& ram[branch + 1u] == 0x06 && (ram[branch] == 0x65 || ram[branch] == 0x60);
if (!isOs1700)
return false;
ram[branch] = enabled ? 0x60 : 0x65;
return enabled;
}

bool MasterFirmwareRuntime::loadAndStart(const juce::MemoryBlock& masterImage)
{
loaded = false;
Expand Down Expand Up @@ -160,6 +175,7 @@ bool MasterFirmwareRuntime::loadAndStart(const juce::MemoryBlock& masterImage)
return false;

std::copy_n(bytes, masterImage.getSize(), sharedMemory->mainRam.begin() + imageBase);
setVoiceAllocationFix(voiceAllocationFix);

// INIT.PFM and INIT.SND are normally separate files on the Wave system
// disk. OS 1.700 also contains genuine safe fallback records, used after
Expand Down Expand Up @@ -1139,9 +1155,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 @@ -1225,12 +1238,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
8 changes: 7 additions & 1 deletion Source/Firmware/MasterFirmwareRuntime.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class MasterFirmwareRuntime final : private M68000Bus
public:
void attachSharedMemory(SharedFirmwareMemory& memory) noexcept { sharedMemory = &memory; }
bool loadAndStart(const juce::MemoryBlock& masterImage);
// Opt-in: OS 1.700's find_free_voice (0x2EAC) returns the quietest voice instead of
// letting the steal passes run, so a fresh note steals a held note of its chord
// (firmware-map.md section 5). One byte at 0x2EE2 (bcs -> bra) restores OS 1.680's
// allocation. Only applied to an image whose surrounding bytes are the 1.700 ones;
// returns whether it is applied now (it is re-applied on every loadAndStart).
bool setVoiceAllocationFix(bool enabled) noexcept;
int runCycles(int cycles);
int runForAudioSamples(int samples, double sampleRate);
bool runOs1700InitialisationFileLoad();
Expand Down Expand Up @@ -166,7 +172,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 Expand Up @@ -253,6 +258,7 @@ class MasterFirmwareRuntime final : private M68000Bus
int pendingPerformanceRefresh = -1;
bool voiceLoaderReached = false;
bool customInitialisationRecords = false;
bool voiceAllocationFix = false;
bool voiceBoardHandoffComplete = false;
int activeVoiceBoardCount = 0;
bool coldHardwareSetupComplete = false;
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