Add Henri's patch

This commit is contained in:
tomcw committed 2026-10-07 21:36:06 +01:00
1 parent 6c579da5bd
commit 7d6cc50b65
12 files changed
+2342 -20061

No files matched your search

+4 -1
View File
@@ -121,7 +121,7 @@
<ClInclude Include="source\Speaker.h" />
<ClInclude Include="source\Speech.h" />
<ClInclude Include="source\SSI263.h" />
<ClInclude Include="source\SSI263Phonemes.h" />
<ClInclude Include="source\SSI263Synth.h" />
<ClInclude Include="source\StdAfx.h" />
<ClInclude Include="source\StrFormat.h" />
<ClInclude Include="source\SynchronousEventManager.h" />
@@ -228,6 +228,9 @@
<ClCompile Include="source\Speaker.cpp" />
<ClCompile Include="source\Speech.cpp" />
<ClCompile Include="source\SSI263.cpp" />
<ClCompile Include="source\SSI263Synth.cpp">
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<ClCompile Include="source\StdAfx.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">Create</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">Create</PrecompiledHeader>
+6 -3
View File
@@ -217,6 +217,9 @@
<ClCompile Include="source\SSI263.cpp">
<Filter>Source Files\Emulator</Filter>
</ClCompile>
<ClCompile Include="source\SSI263Synth.cpp">
<Filter>Source Files\Emulator</Filter>
</ClCompile>
<ClCompile Include="source\Debugger\Debugger_Disassembler.cpp">
<Filter>Source Files\Debugger</Filter>
</ClCompile>
@@ -465,9 +468,6 @@
<ClInclude Include="source\Speech.h">
<Filter>Source Files\Emulator</Filter>
</ClInclude>
<ClInclude Include="source\SSI263Phonemes.h">
<Filter>Source Files\_Headers</Filter>
</ClInclude>
<ClInclude Include="source\StdAfx.h">
<Filter>Source Files</Filter>
</ClInclude>
@@ -564,6 +564,9 @@
<ClInclude Include="source\SSI263.h">
<Filter>Source Files\Emulator</Filter>
</ClInclude>
<ClInclude Include="source\SSI263Synth.h">
<Filter>Source Files\Emulator</Filter>
</ClInclude>
<ClInclude Include="source\Debugger\Debugger_Disassembler.h">
<Filter>Source Files\Debugger</Filter>
</ClInclude>
+2 -2
View File
@@ -39,9 +39,9 @@
<h4>Speech chips:</h4>
Both the Mockingboard C and Phasor cards support up to two SSI263s and an aftermarket hardware modification for a Votrax SC-01 (the predecessor to the SSI263).<br>
Emulation of the SSI263 is basic at the moment and only plays back phonemes at a fixed rate without any support for inflection or filters.<br>
SSI263 speech uses a circuit model with pitch, articulation, amplitude and filter controls. It does not use recorded phonemes.<br>
Two types of SSI263 are supported: SSI263P (original, with a reset bug) and SSI263AP (updated with the reset bug fixed).<br>
Additionally the Votrax SC-01 is <em>simply</em> emulated by remapping SC-01 phonemes to the SSI263's.<br>
Votrax SC-01 speech maps its phonemes to a separate SSI263 synth with a fixed voice. This is an approximation; SC-01 pitch inputs are not modelled.<br>
<br>
<p>There are a number of games and demos that support Mockingboard (or Phasor), eg:</p>
+7 -1
View File
@@ -5,6 +5,7 @@ Copyright (C) 1994-1996, Michael O'Brien
Copyright (C) 1999-2001, Oliver Schmidt
Copyright (C) 2002-2005, Tom Charlesworth
Copyright (C) 2006-2007, Tom Charlesworth, Michael Pohoreski
Copyright (C) 2026, Henri Asseily (henri@asseily.com)
AppleWin is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -1212,7 +1213,8 @@ UINT MockingboardCard::AY8910_LoadSnapshot(YamlLoadHelper& yamlLoadHelper, BYTE
// Removed SS_YAML_KEY_VOTRAX_PHONEME (as this has been present in the SC01 subunit since v12!)
//14: Added: SSI263: Type
//15: Added: AY891x: Type
const UINT kUNIT_VERSION = 15;
//16: Added native SSI263 synthesis and timing state
const UINT kUNIT_VERSION = 16;
#define SS_YAML_KEY_MB_UNIT "Unit"
#define SS_YAML_KEY_AY_CURR_REG "AY Current Register"
@@ -1262,6 +1264,10 @@ std::string MockingboardCard::GetSnapshotCardNameSDMusic()
void MockingboardCard::SaveSnapshot(YamlSaveHelper& yamlSaveHelper)
{
// Set speech IRQs before saving the 6522 interrupt flags.
for (UINT i = 0; i < NUM_SSI263; i++)
m_MBSubUnit[i].ssi263.Update();
if (QueryType() == CT_Phasor)
return Phasor_SaveSnapshot(yamlSaveHelper);
+530 -401
View File
File diff suppressed because it is too large. Load diff
+57 -36
View File
@@ -1,6 +1,8 @@
#pragma once
#include "MockingboardDefs.h"
#include "SoundCore.h"
#include "SSI263Synth.h"
class SSI263
{
@@ -11,14 +13,9 @@ public:
m_device = -1; // undefined
m_cardMode = PH_Mockingboard;
m_hasSC01 = true; // only for m_device==0
m_pPhonemeData00 = NULL;
ResetState(true);
}
~SSI263()
{
delete [] m_pPhonemeData00;
}
void ResetState(const bool powerCycle)
{
@@ -32,24 +29,20 @@ public:
m_cyclesThisAudioFrame = 0;
//
m_lastUpdateCycle = 0;
m_updateWasFullSpeed = false;
m_pPhonemeData = NULL;
m_phonemeLengthRemaining = 0;
m_phonemeAccurateLengthRemaining = 0;
m_phonemePlaybackAndDebugger = false;
m_phonemeCompleteByFullSpeed = false;
m_phonemeLeadoutLength = 0;
if (powerCycle || !m_isVotraxPhoneme)
{
m_phonemeLengthRemaining = 0;
m_phonemeLeadoutLength = 0;
m_currSampleMod4 = 0;
}
//
m_numSamplesError = 0;
m_byteOffset = (uint32_t)-1;
m_currSampleSum = 0;
m_currNumSamples = 0;
m_currSampleMod4 = 0;
if (powerCycle || !m_isVotraxPhoneme)
{
m_numSamplesError = 0;
m_byteOffset = (uint32_t)-1;
}
//
@@ -60,7 +53,7 @@ public:
m_inflection = 0;
m_rateInflection = 0;
m_ctrlArtAmp = (powerCycle || m_type == SSI263AP) ? CONTROL_MASK : 0; // Chip power-on, so CTL=1 (power-down / standby)
m_filterFreq = powerCycle ? FILTER_FREQ_SILENCE : 0; // Empirically observed at chip power-on (GH#1302)
m_filterFreq = powerCycle ? FILTER_FREQ_POWER_ON : 0; // Empirically observed at chip power-on (GH#1302)
m_currentMode.mode = 0;
m_currentMode.function = 0; // Set at runtime when CTL=0
@@ -71,6 +64,10 @@ public:
m_dbgFirst = true;
m_dbgStartTime = 0;
ResetSynthesis(powerCycle);
if (powerCycle)
ResetVotraxSynthesis();
}
void SetDevice(UINT device) { m_device = device; }
@@ -108,11 +105,24 @@ private:
// Also valid regardless of m_isVotraxPhoneme state
return m_currentActivePhoneme >= 0;
}
void ResetSynthesis(bool resetClock = true);
void ResetVotraxSynthesis();
void ConfigureVotrax();
void AdvanceSynthesis();
void UpdateSynthesis();
void AdvanceSpeechTiming();
void ResetResponseTiming();
void StartResponseTiming();
void AdvanceResponseTiming(uint32_t ticks);
void CommitResponse();
UINT GetResponsePeriod() const { return (16 - (m_rateInflection >> 4)) * 256; }
UINT GetDurationPeriod() const { return (4 - (m_durationPhoneme >> 6)) * GetResponsePeriod(); }
void SaveSynthesis(class YamlSaveHelper& yamlSaveHelper);
void LoadSynthesis(class YamlLoadHelper& yamlLoadHelper);
void Play(unsigned int nPhoneme);
void Stop();
void UpdateIRQ();
void RepeatPhoneme();
void UpdateAccurateLength();
void SetDeviceModeAndInts();
UINT64 GetLastCumulativeCycles();
@@ -132,6 +142,27 @@ private:
short m_mixBufferSSI263[m_kDSBufferByteSize / sizeof(short)];
VOICE SSI263SingleVoice;
SSI263Synth m_synth;
SSI263Synth m_votraxSynth;
bool m_votraxSynthStarted;
UINT64 m_synthLastCycle;
uint32_t m_synthCpuClock;
uint32_t m_synthCycleRemainder;
uint32_t m_synthSamplePhase;
uint32_t m_speechSamplePhase;
short m_synthBuffer[MAX_SAMPLES];
UINT m_synthSamples;
// Native A/R counters use effective SSI XCK, independent of audio samples.
bool m_responseActive;
bool m_responsePending;
bool m_responseReloaded;
bool m_durationReloaded;
UINT m_responseTicksRemaining;
UINT m_durationTicksRemaining;
BYTE m_responsePhase;
BYTE m_durationPhase;
//
// Duration/Phonome
@@ -154,15 +185,14 @@ private:
static const BYTE AMPLITUDE_MASK = 0x0F;
static const BYTE CONTROL_MASK = 0x80;
// Filter frequency range
static const BYTE FILTER_FREQ_SILENCE = 0xFF;
// Filter register after power-on
static const BYTE FILTER_FREQ_POWER_ON = 0xFF;
SSI263Type m_type;
UINT m_slot;
BYTE m_device; // SSI263 device# which is generating phoneme-complete IRQ (and only required whilst Mockingboard isn't a class)
PHASOR_MODE m_cardMode;
bool m_hasSC01;
short* m_pPhonemeData00;
// ctor/power-cycle: Set to -1
// Play(): Set to [$00-$3F] on a write to DURPHON register.
@@ -180,22 +210,13 @@ private:
//
UINT64 m_lastUpdateCycle;
bool m_updateWasFullSpeed;
const short* m_pPhonemeData;
UINT m_phonemeLengthRemaining; // length in samples, decremented as space becomes available in the ring-buffer
UINT m_phonemeAccurateLengthRemaining; // length in samples, decremented by cycles executed
bool m_phonemePlaybackAndDebugger;
bool m_phonemeCompleteByFullSpeed;
UINT m_phonemeLeadoutLength; // length in samples, decremented after \m_phonemeLengthRemaining\ goes to zero. Delay until phoneme repeats
UINT m_phonemeLengthRemaining; // legacy IRQ ticks remaining
UINT m_phonemeLeadoutLength; // legacy delay before the next IRQ period
//
int m_numSamplesError;
uint32_t m_byteOffset;
int m_currSampleSum;
int m_currNumSamples;
UINT m_currSampleMod4;
// Regs:
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+220
View File
@@ -0,0 +1,220 @@
#pragma once
/*
AppleWin : An Apple //e emulator for Windows
Copyright (C) 2026, Henri Asseily (henri@asseily.com)
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 2 of the License, or (at your
option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details. You should have received a copy of the GNU General
Public License along with this program; if not, see <https://www.gnu.org/licenses/>.
Portable SSI263 sound state and interface.
Adapted from the Appletini ONE project at https://github.com/hasseily/appletini-one
*/
#include <array>
#include <cstdint>
#include <vector>
// SSI-263 speech from its parameter ROM and an ideal SC-02 prototype circuit.
// Pitch glide, cold state and source levels remain model approximations.
// The card owns bus timing and interrupts; this class only produces audio.
class SSI263Synth
{
public:
static const uint32_t kSampleRate = 48000;
explicit SSI263Synth(uint32_t clockHz = 1015625);
void Reset(uint32_t clockHz);
void SetClock(uint32_t clockHz);
// Restore a saved mode, or relatch it on SSI263P reset, without a CTL edge.
void SetFunction(uint8_t function);
void Write(uint8_t reg, uint8_t value);
void Advance(uint32_t ticks);
// Call once per 48 kHz output sample, after advancing to its clock time.
int16_t GetSample();
uint32_t GetClockHz() const { return m_clockHz; }
// Versioned words, independent of struct layout. A failed load changes nothing.
std::vector<uint32_t> SaveState() const;
bool LoadState(const std::vector<uint32_t>& words);
private:
struct Ramp
{
int m_value = 0;
int m_step = 0;
int m_fraction = 8;
int m_target = 0;
bool m_upward = true;
void Retarget(int value);
void Step();
};
struct Control
{
Control();
void Write(int reg, int value);
void Tick(int amplitudeZero);
void RestartDuration();
void WriteParameter();
void LatchParameter(bool rising, int amplitudeZero);
int Target(int selector) const;
bool PermitTransition(int selector) const;
int ArticulationPeriod() const;
int DurationPeriod() const;
int AmplitudePeriod() const;
std::array<int, 5> m_regs = {{0xC0, 0, 0, 0x80, 0xFF}};
std::array<Ramp, 8> m_ramps;
// F1, F2, F2Q, shared F3/F4, filter gain, voice, noise, unused.
std::array<int, 8> m_codes = {{0}};
int m_selector = 0;
int m_scanPhase = 0;
int m_durationPhase = 0;
int m_durationLeft = 0;
int m_articulationLeft = 0;
int m_amplitudeLeft = 0;
bool m_active = false;
bool m_phoneValid = false;
bool m_phoneSetupPending = false;
bool m_phoneSetupWindow = false;
bool m_controlSetupPending = false;
bool m_controlSetupWindow = false;
bool m_articulationPending = false;
bool m_articulationWindow = false;
bool m_amplitudePending = false;
bool m_amplitudeWindow = false;
bool m_durationPending = false;
bool m_durationWindow = false;
// -1 denotes a latch whose cold state is unknown.
int m_pw0 = -1;
int m_pw1 = -1;
int m_pw2 = -1;
int m_pw3 = -1;
int m_pw5 = -1;
int m_route = -1;
int m_fric1 = -1;
int m_fric2 = -1;
int m_filterLeft = 1;
bool m_filterPhase = false;
bool m_filterEdge = false;
};
struct Codes
{
int m_f1 = 0;
int m_f2 = 0;
int m_f2q = 0;
int m_f3 = 0;
int m_f4 = 0;
int m_filterAmp = 0;
int m_voiceAmp = 0;
int m_fricAmp = 0;
bool Equals(const Codes& other) const;
};
struct FilterEvent
{
bool m_phase = false; // false = Phi0; true = Phi1.
bool m_phaseEdge = false;
Codes m_codes;
int m_voiceDrive = 0;
int m_fricDrive = 0;
bool m_fric1 = false;
bool m_fric2 = false;
bool m_outputOpen = true;
};
struct Source
{
Source();
void Tick(const Control& control, int inflection);
void Settle(const Control& control);
bool ResetVoice(const Control& control) const;
int AmplitudeZero() const { return (m_amplitude & 14) == 0 ? 1 : 0; }
void ShiftNoise();
FilterEvent m_output;
int m_noise1 = 1;
int m_noise2 = 0;
int m_noise3 = 0;
int m_noise4 = 0;
int m_noiseCount = 15;
int m_voiceLeft = 16384;
int m_voiceCount = 15;
int m_amplitude = 0;
bool m_voiceToggle = false;
bool m_envelopeClock = false;
bool m_noiseClock = false;
bool m_pitchSync = false;
bool m_loadPending = false;
};
struct Formant
{
int m_output = 0;
int m_history = 0;
int m_fixedPlate = 0;
std::array<int, 4> m_plates = {{0}};
};
struct Tract
{
// Voltages use signed 24-bit Q16; capacitor values are in pF.
void Process(const FilterEvent& event);
int16_t GetSample() const;
static int Saturate(int64_t value);
static int Divide(int64_t numerator, int denominator);
static int CapSum(int mask, const std::array<int, 4>& caps);
static int64_t Charge(int mask, int target, const std::array<int, 4>& plates,
const std::array<int, 4>& caps);
static int64_t Weighted(int mask, const std::array<int, 4>& plates,
const std::array<int, 4>& caps);
static void SetPlates(std::array<int, 4>& plates, int mask, int target);
void MoveFormant(int index, int mask, int fixedCap, const std::array<int, 4>& caps,
int denominator, int64_t extraCharge = 0);
int m_voice = 0;
int m_fric1 = 0;
int m_fric2Source = 0;
int m_fric2Shape = 0;
std::array<int, 4> m_voicePlates = {{0}};
std::array<int, 4> m_fric1Plates = {{0}};
std::array<int, 4> m_f2qPlates = {{0}};
std::array<int, 4> m_filterPlates = {{0}};
std::array<Formant, 5> m_formants;
int m_c143Plate = 0;
int m_c151Plate = 0;
int m_c150Delta = 0;
int m_c151Delta = 0;
int m_output = 0;
int m_reconstruction = 0;
FilterEvent m_applied;
bool m_hasExcitation = false;
};
int LiveInflection() const;
void StartPitch();
void AdvancePitch();
template<class State> void VisitState(State& state);
uint32_t m_clockHz;
Control m_control;
Source m_source;
Tract m_tract;
int m_function = 0;
int m_inflection = 0;
int m_pitchCadence = 0;
bool m_pitchSeeded = false;
};
+43
View File
@@ -0,0 +1,43 @@
# SSI263 synthesis tests
Build from the repository root with a C++14 compiler:
```sh
g++ -std=c++14 -O3 -Wall -Wextra -Werror -DSSI263_SYNTH_TEST source/SSI263Synth.cpp test/SSI263Synth/TestSSI263Synth.cpp -o TestSSI263Synth
./TestSSI263Synth
```
Or use a Visual Studio developer command prompt:
```bat
cl /nologo /std:c++14 /O2 /EHsc /W4 /WX /DSSI263_SYNTH_TEST source\SSI263Synth.cpp test\SSI263Synth\TestSSI263Synth.cpp /Fe:TestSSI263Synth.exe
TestSSI263Synth.exe
```
The tests cover cold reset, register aliases, independent chips, split clock
advances, exact snapshot continuation, invalid snapshots, and all phonemes
with extreme register settings. Function restore preserves running synthesis
state and selects immediate or transitioned pitch. A cold snapshot also checks
the first speech samples after one second of silence. The tests use no Windows
or audio device APIs.
`RenderTrace.cpp` can compare PCM with another implementation. Build it with
the same command, replacing `TestSSI263Synth.cpp` and the output name with
`RenderTrace.cpp` and `RenderTrace`. Run `RenderTrace events.txt output.pcm`.
The output is signed 16-bit little-endian stereo PCM at 48 kHz, with socket 0
on the left and socket 1 on the right.
The trace is plain text. Its first line is:
```text
SSIHOST1 clock_hz start_tick end_tick event_count
```
Each following line contains `tick socket register value`, in time order.
All numbers are decimal. Clock ticks use the effective SSI clock, after any
board clock divider. Negative ticks allow setup before the first saved sample.
Writes at a sample boundary take effect before that sample. Earlier samples
still run when `start_tick` selects a later range.
These tests check the digital model. They do not establish agreement with a
physical SSI263, nor test the AppleWin audio mixer or interrupt timing.
+137
View File
@@ -0,0 +1,137 @@
/*
AppleWin : An Apple //e emulator for Windows
Copyright (C) 2026, Henri Asseily (henri@asseily.com)
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 2 of the License, or (at your
option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details. You should have received a copy of the GNU General
Public License along with this program; if not, see <https://www.gnu.org/licenses/>.
Render timed SSI263 register writes to stereo PCM.
*/
#include "../../source/SSI263Synth.h"
#include <algorithm>
#include <fstream>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
struct RegisterEvent
{
int64_t tick;
uint32_t socket;
uint32_t reg;
uint32_t value;
};
static int64_t FloorDivide(int64_t numerator, int64_t denominator)
{
return numerator / denominator - (numerator % denominator < 0);
}
static int64_t CeilDivide(int64_t numerator, int64_t denominator)
{
return -FloorDivide(-numerator, denominator);
}
static void WriteSample(std::ostream& output, int16_t sample)
{
const uint16_t value = static_cast<uint16_t>(sample);
output.put(static_cast<char>(value & 255));
output.put(static_cast<char>(value >> 8));
}
static void RenderTrace(const char* inputPath, const char* outputPath)
{
std::ifstream input(inputPath);
std::string magic;
uint32_t clockHz;
int64_t startTick, endTick;
size_t eventCount;
input >> magic >> clockHz >> startTick >> endTick >> eventCount;
if (!input || magic != "SSIHOST1" || clockHz < 100000 || clockHz > 4000000 ||
startTick < 0 || endTick <= startTick || endTick > int64_t(clockHz) * 3600 || eventCount > 10000000)
throw std::runtime_error("invalid trace header");
std::vector<RegisterEvent> events(eventCount);
for (size_t index = 0; index < events.size(); index++)
{
RegisterEvent& event = events[index];
input >> event.tick >> event.socket >> event.reg >> event.value;
if (!input || event.tick < -int64_t(clockHz) || event.tick >= endTick ||
event.socket > 1 || event.reg > 7 || event.value > 255 || (index && event.tick < events[index - 1].tick))
throw std::runtime_error("invalid or unsorted register event");
}
std::string trailing;
if (input >> trailing)
throw std::runtime_error("unexpected trailing trace data");
SSI263Synth chips[2] = {SSI263Synth(clockHz), SSI263Synth(clockHz)};
const int64_t firstTick = events.empty() ? 0 : std::min<int64_t>(0, events.front().tick);
const int64_t firstFrame = FloorDivide(firstTick * SSI263Synth::kSampleRate, clockHz);
const int64_t startFrame = CeilDivide(startTick * SSI263Synth::kSampleRate, clockHz);
const int64_t endFrame = CeilDivide(endTick * SSI263Synth::kSampleRate, clockHz);
int64_t currentTick = CeilDivide(firstFrame * clockHz, SSI263Synth::kSampleRate);
size_t nextEvent = 0;
std::ofstream output(outputPath, std::ios::binary);
if (!output)
throw std::runtime_error("cannot open PCM output");
const auto Advance = [&](int64_t tick)
{
int64_t remaining = tick - currentTick;
while (remaining > 0)
{
const uint32_t ticks = static_cast<uint32_t>(std::min<int64_t>(remaining, 0xffffffff));
for (SSI263Synth& chip : chips)
chip.Advance(ticks);
remaining -= ticks;
}
currentTick = tick;
};
for (int64_t frame = firstFrame; frame < endFrame; frame++)
{
const int64_t tick = CeilDivide(frame * clockHz, SSI263Synth::kSampleRate);
while (nextEvent < events.size() && events[nextEvent].tick <= tick)
{
const RegisterEvent& event = events[nextEvent++];
Advance(event.tick);
chips[event.socket].Write(static_cast<uint8_t>(event.reg), static_cast<uint8_t>(event.value));
}
Advance(tick);
for (SSI263Synth& chip : chips)
{
const int16_t sample = chip.GetSample();
if (frame >= startFrame)
WriteSample(output, sample);
}
}
output.close();
if (!output)
throw std::runtime_error("PCM write failed");
std::cout << "{\"frames\":" << endFrame - startFrame << "}\n";
}
int main(int argc, char** argv)
{
try
{
if (argc != 3)
throw std::runtime_error("usage: RenderTrace EVENTS OUTPUT.pcm");
RenderTrace(argv[1], argv[2]);
return 0;
}
catch (const std::exception& error)
{
std::cerr << error.what() << '\n';
return 1;
}
}
+314
View File
@@ -0,0 +1,314 @@
/*
AppleWin : An Apple //e emulator for Windows
Copyright (C) 2026, Henri Asseily (henri@asseily.com)
This program is free software; you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation; either version 2 of the License, or (at your
option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details. You should have received a copy of the GNU General
Public License along with this program; if not, see <https://www.gnu.org/licenses/>.
SSI263 synthesis and state regression tests.
*/
#include "../../source/SSI263Synth.h"
#include <algorithm>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
static const uint32_t kClockHz = 1015625;
static void Check(bool condition, const char* message)
{
if (!condition)
throw std::runtime_error(message);
}
static void StartVoice(SSI263Synth& synth, uint8_t function = 2)
{
synth.Write(3, 0x80);
synth.Write(0, static_cast<uint8_t>(function << 6));
synth.Write(1, 0x6f);
synth.Write(2, 0x8e);
synth.Write(4, 128);
synth.Write(3, 0x5c);
synth.Write(0, 0x0e);
}
static std::vector<int16_t> Render(SSI263Synth& synth, uint32_t frames)
{
std::vector<int16_t> samples;
uint64_t previousTick = 0;
for (uint32_t frame = 0; frame < frames; frame++)
{
const uint64_t tick = (uint64_t(frame) * synth.GetClockHz() + SSI263Synth::kSampleRate - 1) / SSI263Synth::kSampleRate;
synth.Advance(static_cast<uint32_t>(tick - previousTick));
previousTick = tick;
samples.push_back(synth.GetSample());
}
return samples;
}
static void TestReset()
{
SSI263Synth synth(kClockHz);
const std::vector<uint32_t> coldState = synth.SaveState();
StartVoice(synth);
const std::vector<int16_t> expected = Render(synth, 4800);
Check(std::any_of(expected.begin(), expected.end(), [](int16_t value) { return value != 0; }), "voice stayed silent");
// FNV-1a over little-endian PCM from the accepted native model, 7 October 2026.
uint64_t hash = 14695981039346656037ULL;
for (int16_t sample : expected)
{
const uint16_t value = static_cast<uint16_t>(sample);
hash = (hash ^ (value & 255)) * 1099511628211ULL;
hash = (hash ^ (value >> 8)) * 1099511628211ULL;
}
Check(hash == 0xa2a114dcd3b1d24cULL, "voice PCM differs from the reference");
synth.Reset(kClockHz);
Check(synth.SaveState() == coldState, "reset did not restore cold state");
StartVoice(synth);
Check(Render(synth, 4800) == expected, "reset changed the PCM sequence");
synth.Reset(1022727);
Check(synth.GetClockHz() == 1022727, "reset did not select the new clock");
}
static void TestRegisterAliases()
{
for (uint8_t reg = 0; reg < 8; reg++)
{
SSI263Synth reference(kClockHz);
SSI263Synth alias(kClockHz);
StartVoice(reference);
StartVoice(alias);
const uint8_t decoded = reg < 4 ? reg : 4;
for (uint32_t value = 0; value < 256; value++)
{
reference.Write(decoded, static_cast<uint8_t>(value));
alias.Write(reg, static_cast<uint8_t>(value));
reference.Advance(37);
alias.Advance(37);
Check(reference.GetSample() == alias.GetSample(), "register alias changed PCM");
Check(reference.SaveState() == alias.SaveState(), "register alias changed state");
}
}
}
static void TestAdvanceBatching()
{
SSI263Synth batched(kClockHz);
SSI263Synth singleTick(kClockHz);
StartVoice(batched, 3);
StartVoice(singleTick, 3);
for (uint32_t frame = 0; frame < 4800; frame++)
{
if (frame % 137 == 0)
{
batched.Write(1, static_cast<uint8_t>(frame));
singleTick.Write(1, static_cast<uint8_t>(frame));
}
const uint32_t ticks = 1 + frame % 43;
batched.Advance(ticks);
for (uint32_t tick = 0; tick < ticks; tick++)
singleTick.Advance(1);
Check(batched.GetSample() == singleTick.GetSample(), "Advance batching changed PCM");
}
Check(batched.SaveState() == singleTick.SaveState(), "Advance batching changed state");
const std::vector<uint32_t> before = batched.SaveState();
batched.Advance(0);
Check(batched.SaveState() == before, "zero ticks changed state");
}
static void TestChipIndependence()
{
SSI263Synth reference(kClockHz);
SSI263Synth first(kClockHz);
SSI263Synth second(1022727);
StartVoice(reference);
StartVoice(first);
StartVoice(second, 3);
for (uint32_t frame = 0; frame < 4800; frame++)
{
reference.Advance(21);
first.Advance(21);
second.Write(static_cast<uint8_t>(frame & 7), static_cast<uint8_t>(frame * 17));
second.Advance(53);
second.GetSample();
Check(first.GetSample() == reference.GetSample(), "another chip changed PCM");
}
Check(first.SaveState() == reference.SaveState(), "another chip changed state");
}
static void TestSnapshot()
{
SSI263Synth original(kClockHz);
StartVoice(original, 3);
Render(original, 4799);
original.Write(1, 0xe5);
original.Write(0, 0x2c);
original.Advance(7);
const std::vector<uint32_t> snapshot = original.SaveState();
SSI263Synth restored(1022727);
Check(restored.LoadState(snapshot), "valid snapshot was rejected");
Check(restored.SaveState() == snapshot, "snapshot did not restore all state");
Check(Render(original, 9600) == Render(restored, 9600), "snapshot continuation changed PCM");
Check(original.SaveState() == restored.SaveState(), "snapshot continuation changed state");
const std::vector<uint32_t> before = restored.SaveState();
std::vector<uint32_t> invalid = snapshot;
invalid[0] = 0xffffffff;
Check(!restored.LoadState(invalid), "unknown snapshot version was accepted");
Check(restored.SaveState() == before, "bad snapshot version changed state");
invalid = snapshot;
invalid.pop_back();
Check(!restored.LoadState(invalid), "truncated snapshot was accepted");
Check(restored.SaveState() == before, "truncated snapshot changed state");
invalid = snapshot;
invalid.push_back(0);
Check(!restored.LoadState(invalid), "oversized snapshot was accepted");
Check(restored.SaveState() == before, "oversized snapshot changed state");
invalid.clear();
Check(!restored.LoadState(invalid), "empty snapshot was accepted");
Check(restored.SaveState() == before, "empty snapshot changed state");
invalid = snapshot;
Check(invalid.back() == 1, "speech did not record prior filter excitation");
invalid.back() = 0;
Check(!restored.LoadState(invalid), "charged filters accepted an unexcited snapshot flag");
Check(restored.SaveState() == before, "invalid filter excitation flag changed state");
for (size_t index = 0; index < snapshot.size(); index++)
{
invalid = snapshot;
invalid[index] = 0x80000000;
Check(!restored.LoadState(invalid), "out-of-range snapshot field was accepted");
Check(restored.SaveState() == before, "out-of-range snapshot field changed state");
}
restored.SetClock(1022727);
Check(restored.GetClockHz() == 1022727, "clock change was ignored");
restored.SetClock(kClockHz);
Check(restored.SaveState() == before, "clock change reset running synthesis state");
}
static void TestRegisterExtremes()
{
const uint8_t limits[] = {0, 1, 127, 128, 254, 255};
for (uint8_t limit : limits)
{
SSI263Synth synth(kClockHz);
StartVoice(synth);
synth.Write(1, limit);
synth.Write(2, limit);
synth.Write(4, limit);
for (uint8_t phone = 0; phone < 64; phone++)
{
synth.Write(0, phone);
synth.Write(3, static_cast<uint8_t>(limit & 0x7f));
const std::vector<uint32_t> before = synth.SaveState();
const std::vector<int16_t> expected = Render(synth, 240);
const std::vector<uint32_t> after = synth.SaveState();
Check(synth.LoadState(before), "extreme register state was rejected");
Check(Render(synth, 240) == expected, "extreme register continuation changed PCM");
Check(synth.SaveState() == after, "extreme register continuation changed state");
}
}
}
static void TestFunctionRestore()
{
SSI263Synth synth(kClockHz);
StartVoice(synth, 3);
Render(synth, 4800);
const std::vector<uint32_t> before = synth.SaveState();
for (uint8_t function = 0; function < 4; function++)
{
synth.SetFunction(function);
std::vector<uint32_t> expected = before;
// Version 1 stores the latched function after the version and clock.
expected[2] = function;
Check(synth.SaveState() == expected, "function restore changed running synthesis state");
}
const uint8_t invalidFunctions[] = {4, 255};
for (uint8_t function : invalidFunctions)
{
bool rejected = false;
try
{
synth.SetFunction(function);
}
catch (const std::invalid_argument&)
{
rejected = true;
}
Check(rejected, "invalid function was accepted");
Check(synth.SaveState() == before, "invalid function changed state");
}
SSI263Synth immediate(kClockHz);
SSI263Synth transitioned(kClockHz);
Check(immediate.LoadState(before) && transitioned.LoadState(before), "function test snapshot was rejected");
immediate.SetFunction(2);
transitioned.SetFunction(3);
bool differentPitch = false;
for (uint32_t frame = 0; frame < 4800; frame++)
{
// Keep moving the target so the next divider reload sees an active glide.
if (frame % 11 == 0)
{
const uint8_t inflection = (frame / 11) & 1 ? 255 : 0;
immediate.Write(1, inflection);
transitioned.Write(1, inflection);
}
immediate.Advance(21);
transitioned.Advance(21);
if (immediate.GetSample() != transitioned.GetSample())
differentPitch = true;
}
Check(differentPitch, "restored function did not select pitch glide");
}
static void TestColdSnapshot()
{
SSI263Synth cold(kClockHz);
const std::vector<int16_t> silence = Render(cold, SSI263Synth::kSampleRate);
Check(std::all_of(silence.begin(), silence.end(), [](int16_t value) { return value == 0; }), "cold chip produced sound");
Check(cold.SaveState().back() == 0, "cold chip recorded filter excitation");
SSI263Synth restored(kClockHz);
Check(restored.LoadState(cold.SaveState()), "cold snapshot was rejected");
StartVoice(cold);
StartVoice(restored);
const std::vector<int16_t> expected = Render(cold, 4800);
Check(std::any_of(expected.begin(), expected.end(), [](int16_t value) { return value != 0; }), "cold chip did not start speech");
Check(Render(restored, 4800) == expected, "cold snapshot changed the first speech samples");
Check(restored.SaveState() == cold.SaveState(), "cold snapshot changed the first speech state");
}
int main()
{
try
{
TestReset();
TestRegisterAliases();
TestAdvanceBatching();
TestChipIndependence();
TestSnapshot();
TestRegisterExtremes();
TestFunctionRestore();
TestColdSnapshot();
std::cout << "SSI263Synth: all tests passed\n";
return 0;
}
catch (const std::exception& error)
{
std::cerr << "SSI263Synth: " << error.what() << '\n';
return 1;
}
}