/*
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 .
SSI263 speech synthesis from its parameter ROM and the SC-02 circuit model.
Adapted from the Appletini ONE project at https://github.com/hasseily/appletini-one
*/
#ifndef SSI263_SYNTH_TEST
#include "StdAfx.h"
#endif
#include "SSI263Synth.h"
#include
#include
#include
// Native SSI-263 source and ideal SC-02 charge-transfer filters.
// See docs/SSI263-synthesis.md for circuit sources and model limits.
const uint32_t SSI263Synth::kSampleRate;
namespace
{
int And(int a, int b)
{
return a == 0 || b == 0 ? 0 : a < 0 || b < 0 ? -1 : 1;
}
int Or(int a, int b)
{
return a == 1 || b == 1 ? 1 : a < 0 || b < 0 ? -1 : 0;
}
int LoadLatch(int oldValue, int enable, int value)
{
return enable == 1 ? value : enable == 0 || oldValue == value ? oldValue : -1;
}
// SSI-263A active ROM, CRC32 of the original 2 KiB image: CC0A72EE.
// SHA-256 of these 512 bytes: 101d129a5f104e6190f2eca518bbf9ef65bf4ff92684d29eba56d9641aa02b0a.
//
// Layout: 64 native phonemes ($00..$3F), eight bytes per phoneme.
// Address = (phoneme << 3) | selector. The unused $200..$7FF bytes of
// the original image are zero. These are circuit parameters, not audio samples.
// Each byte holds a 4-bit target in bits 7..4 and control flags in bits 3..0.
// Targets are capacitor-bank codes, not frequencies in Hz or linear gains.
//
// Selector Target in bits 7..4 Control bits used
// 0 F1 bit 0: voice transition start
// 1 F2 bit 0: fricative transition start
// 2 F2 resonance (Q) bits 1..3: source, transition, route
// 3 Shared F3/F4 none
// 4 Filter amplitude none; ROM byte is zero, use host AMP
// 5 Voice amplitude none
// 6 Fricative (noise) amplitude none
// 7 No parameter write none; ROM byte is zero
// F5 has fixed components and no ROM target.
//
// The control pins are TPARM0..3 in the SC-02 schematic. Their meanings
// depend on the selector; they are not extra target bits or phoneme classes.
// At selectors 0/1, bit 0 chooses duration phase 2 (set) or 6 (clear),
// out of 16 phases. The matching scan sets PW0/PW1, which then permit
// voice/fricative amplitude steps. A phoneme write clears both latches.
//
// At selector 2:
// - Bit 1 controls PW3. While PW1 permits a load, PW3 becomes
// (CTL || !bit1); otherwise it holds. PW3 affects source/envelope timing.
// - Bit 2 loads PW2 and its inverse PW5. PW5 can block F1, F2 and F3/F4
// transition steps; PW2 also helps permit a fricative-route change.
// - Bit 3 requests the fricative route. Its latch loads only when PW1,
// old PW2/current bit 2, and the amplitude-zero conditions permit it.
// Separate phase latches then select FRIC1 at the F2 output (bit 3 set)
// or FRIC2 after F5 (bit 3 clear). They are not live complements.
// Selector-2 bit 0 and all low bits at selectors 3..7 are zero in this ROM.
// For example, HF ($2C) and HFC ($2D) have the same targets; only selector-2
// bit 1 differs, so their sound differs through source/envelope control.
const uint8_t phonemeRom[512] =
{
0x70, 0x90, 0x0A, 0xC0, 0x00, 0x00, 0x00, 0x00, // $00
0x20, 0xE1, 0x0E, 0xE0, 0x00, 0xC0, 0x00, 0x00, // $01
0x50, 0xE1, 0x0E, 0xD0, 0x00, 0xA0, 0x00, 0x00, // $02
0x10, 0xD1, 0x0E, 0xE0, 0x00, 0xB0, 0x00, 0x00, // $03
0x20, 0xC1, 0x0E, 0xB0, 0x00, 0x60, 0x00, 0x00, // $04
0x30, 0xE1, 0x0E, 0xE0, 0x00, 0xC0, 0x00, 0x00, // $05
0x10, 0xF1, 0x0E, 0xF0, 0x00, 0x90, 0x00, 0x00, // $06
0x50, 0xA1, 0x0E, 0xC0, 0x00, 0x80, 0x00, 0x00, // $07
0x60, 0xB1, 0x0E, 0xB0, 0x00, 0x80, 0x00, 0x00, // $08
0x60, 0x91, 0x0E, 0xA0, 0x00, 0x80, 0x00, 0x00, // $09
0x90, 0x81, 0x0E, 0xB0, 0x00, 0x80, 0x00, 0x00, // $0A
0xA0, 0x91, 0x0E, 0xB0, 0x00, 0x80, 0x00, 0x00, // $0B
0xD0, 0x91, 0x0E, 0xB0, 0x00, 0x60, 0x00, 0x00, // $0C
0xF0, 0x71, 0x0E, 0xB0, 0x00, 0x60, 0x00, 0x00, // $0D
0xF0, 0x31, 0x0E, 0xB0, 0x00, 0x60, 0x00, 0x00, // $0E
0xF0, 0x41, 0x0E, 0xB0, 0x00, 0x70, 0x00, 0x00, // $0F
0xD0, 0x21, 0x0E, 0xA0, 0x00, 0x60, 0x00, 0x00, // $10
0x70, 0x11, 0x0E, 0xB0, 0x00, 0x80, 0x00, 0x00, // $11
0x50, 0x11, 0x0E, 0xB0, 0x00, 0x90, 0x00, 0x00, // $12
0x80, 0x21, 0x0E, 0xA0, 0x00, 0x80, 0x00, 0x00, // $13
0x30, 0x61, 0x0E, 0xA0, 0x00, 0xA0, 0x00, 0x00, // $14
0x40, 0x41, 0x0E, 0xA0, 0x00, 0x90, 0x00, 0x00, // $15
0x30, 0x11, 0x0E, 0x80, 0x00, 0xA0, 0x00, 0x00, // $16
0x10, 0x01, 0x0E, 0x70, 0x00, 0xA0, 0x00, 0x00, // $17
0x80, 0x41, 0x0E, 0xB0, 0x00, 0xA0, 0x00, 0x00, // $18
0xA0, 0x31, 0x0E, 0xB0, 0x00, 0x80, 0x00, 0x00, // $19
0xC0, 0x31, 0x0E, 0xB0, 0x00, 0x60, 0x00, 0x00, // $1A
0xC0, 0x51, 0x0E, 0xB0, 0x00, 0x70, 0x00, 0x00, // $1B
0x60, 0x41, 0x0E, 0x30, 0x00, 0x80, 0x00, 0x00, // $1C
0x30, 0x11, 0x0E, 0x10, 0x00, 0x80, 0x00, 0x00, // $1D
0x20, 0x31, 0x0E, 0x40, 0x00, 0x80, 0x00, 0x00, // $1E
0x70, 0x61, 0x0E, 0x90, 0x00, 0x60, 0x00, 0x00, // $1F
0x30, 0x31, 0x0E, 0xE0, 0x00, 0x70, 0x00, 0x00, // $20
0x10, 0x51, 0x0E, 0xF0, 0x00, 0xF0, 0x00, 0x00, // $21
0x50, 0x11, 0x0E, 0xE0, 0x00, 0x90, 0x00, 0x00, // $22
0x30, 0x01, 0x0E, 0x90, 0x00, 0x80, 0x00, 0x00, // $23
0x10, 0x31, 0x0C, 0xC0, 0x00, 0x80, 0x00, 0x00, // $24
0x10, 0x91, 0x0C, 0xE0, 0x00, 0x80, 0x00, 0x00, // $25
0x30, 0xA1, 0x0E, 0x80, 0x00, 0xA0, 0x00, 0x00, // $26
0x41, 0x20, 0x0C, 0x80, 0x00, 0x00, 0xF0, 0x00, // $27
0x41, 0x90, 0x04, 0xE0, 0x00, 0x00, 0xF0, 0x00, // $28
0x31, 0xA0, 0x0C, 0x80, 0x00, 0x00, 0x40, 0x00, // $29
0x70, 0x91, 0x0A, 0xC0, 0x00, 0x60, 0x00, 0x00, // $2A
0x70, 0x91, 0x08, 0xC0, 0x00, 0xF0, 0x00, 0x00, // $2B
0x71, 0x90, 0x0A, 0xC0, 0x00, 0x00, 0x80, 0x00, // $2C
0x71, 0x90, 0x08, 0xC0, 0x00, 0x00, 0x80, 0x00, // $2D
0x70, 0x91, 0x3A, 0xC0, 0x00, 0x40, 0x00, 0x00, // $2E
0x30, 0x20, 0x06, 0xD0, 0x00, 0x20, 0xF0, 0x00, // $2F
0x01, 0x70, 0x06, 0xC0, 0x00, 0x00, 0xF0, 0x00, // $30
0x20, 0xB0, 0x0E, 0xE0, 0x00, 0x20, 0xF0, 0x00, // $31
0x21, 0xB0, 0x0E, 0xE0, 0x00, 0x00, 0x90, 0x00, // $32
0x20, 0x30, 0x0E, 0x90, 0x00, 0x60, 0x80, 0x00, // $33
0x21, 0x30, 0x0E, 0x90, 0x00, 0x00, 0x80, 0x00, // $34
0x30, 0x70, 0x06, 0xE0, 0x00, 0x20, 0x40, 0x00, // $35
0x51, 0x80, 0x06, 0xA0, 0x00, 0x00, 0x60, 0x00, // $36
0x00, 0x31, 0x3E, 0x90, 0x00, 0xF0, 0x00, 0x00, // $37
0x00, 0x81, 0x3E, 0xD0, 0x00, 0xF0, 0x00, 0x00, // $38
0x20, 0xC1, 0x3E, 0xE0, 0x00, 0x80, 0x00, 0x00, // $39
0x70, 0x91, 0x0E, 0xA0, 0x00, 0x80, 0x00, 0x00, // $3A
0x20, 0x81, 0x0E, 0x90, 0x00, 0x60, 0x00, 0x00, // $3B
0x10, 0x71, 0x0E, 0x90, 0x00, 0xA0, 0x00, 0x00, // $3C
0x00, 0x91, 0x0E, 0xA0, 0x00, 0xA0, 0x00, 0x00, // $3D
0x60, 0x71, 0x0E, 0xA0, 0x00, 0x70, 0x00, 0x00, // $3E
0x10, 0x11, 0x0E, 0xE0, 0x00, 0xF0, 0x00, 0x00, // $3F
};
}
SSI263Synth::SSI263Synth(uint32_t clockHz)
: m_clockHz(clockHz)
{
SetClock(clockHz);
}
void SSI263Synth::SetClock(uint32_t clockHz)
{
if (clockHz == 0 || clockHz > 4000000)
throw std::invalid_argument("SSI263 clock is outside the supported range");
m_clockHz = clockHz;
}
void SSI263Synth::Reset(uint32_t clockHz)
{
*this = SSI263Synth(clockHz);
}
void SSI263Synth::SetFunction(uint8_t function)
{
if (function > 3)
throw std::invalid_argument("SSI263 function must be 0..3");
m_function = function;
}
int SSI263Synth::LiveInflection() const
{
return ((m_control.m_regs[2] & 8) << 8) | (m_control.m_regs[1] << 3) | (m_control.m_regs[2] & 7);
}
void SSI263Synth::StartPitch()
{
int next = LiveInflection();
if (m_function == 3)
{
if (m_pitchSeeded)
next = (next & ~0x7C0) | (m_inflection & 0x7C0);
m_pitchSeeded = true;
}
m_inflection = next;
}
void SSI263Synth::Write(uint8_t reg, uint8_t value)
{
reg &= 7;
if (reg >= 4)
reg = 4;
const bool oldControl = (m_control.m_regs[3] & 0x80) != 0;
m_control.Write(reg, value);
if (reg == 0 && !oldControl)
StartPitch();
else if (reg == 3 && oldControl && !(value & 0x80))
{
const int mode = m_control.m_regs[0] >> 6;
if (mode != 0)
m_function = mode;
StartPitch();
}
}
void SSI263Synth::Advance(uint32_t ticks)
{
const int inflection = m_function == 3 ? m_inflection : LiveInflection();
while (ticks--)
{
// The controller sees the envelope before this edge; source and tract follow.
m_control.Tick(m_source.AmplitudeZero());
m_source.Tick(m_control, inflection);
m_tract.Process(m_source.m_output);
}
}
void SSI263Synth::AdvancePitch()
{
// Retain the listening model's 20 kHz glide. Its exact SSI timing is unknown.
m_pitchCadence += 5;
if (m_pitchCadence < 12)
return;
m_pitchCadence -= 12;
int next = LiveInflection();
if (m_function == 3)
{
static const int steps[8] = {1, 2, 3, 4, 6, 8, 12, 16};
const int active = (m_inflection >> 6) & 31;
const int target = (next >> 6) & 31;
const int step = steps[(next >> 3) & 7];
const int delta = target - active;
const int moved = active + (delta < -step ? -step : delta > step ? step : delta);
next = (next & ~0x7C0) | (moved << 6);
}
m_inflection = next;
}
int16_t SSI263Synth::GetSample()
{
const int16_t sample = m_tract.GetSample();
AdvancePitch();
return sample;
}
void SSI263Synth::Ramp::Retarget(int value)
{
m_target = value;
m_step = (m_target - m_value + 16) & 15;
m_fraction = 8;
m_upward = m_target >= m_value;
}
void SSI263Synth::Ramp::Step()
{
if (m_value == m_target)
return;
const int sum = m_step + m_fraction;
m_fraction = sum & 15;
const bool carry = sum >= 16;
if (m_upward && carry)
++m_value;
if (!m_upward && !carry)
--m_value;
}
SSI263Synth::Control::Control()
{
RestartDuration();
}
int SSI263Synth::Control::ArticulationPeriod() const
{
// Fixed reference RATE=8 keeps articulation independent of the speech RATE.
return 256 * 8 * (8 - ((m_regs[3] >> 4) & 7));
}
int SSI263Synth::Control::DurationPeriod() const
{
return 256 * (16 - (m_regs[2] >> 4)) * (4 - (m_regs[0] >> 6));
}
int SSI263Synth::Control::AmplitudePeriod() const
{
return 128 * (16 - (m_regs[2] >> 4)) * ((m_regs[0] & 0x80) ? 1 : 2);
}
void SSI263Synth::Control::RestartDuration()
{
m_durationPhase = 0;
m_durationLeft = DurationPeriod();
m_articulationLeft = ArticulationPeriod();
m_amplitudeLeft = AmplitudePeriod();
m_durationPending = m_durationWindow = false;
m_articulationPending = m_articulationWindow = false;
m_amplitudePending = m_amplitudeWindow = false;
}
void SSI263Synth::Control::Write(int reg, int value)
{
const int old = m_regs[reg];
m_regs[reg] = value;
if (reg == 0)
{
m_phoneValid = true;
m_pw0 = m_pw1 = 0;
m_phoneSetupPending = true;
// Restart one full duration interval; retain scanner and parameter state.
m_durationPhase = 0;
m_durationLeft = DurationPeriod();
m_durationPending = m_durationWindow = false;
}
else if (reg == 3)
{
m_controlSetupPending = true;
m_active = (value & 0x80) == 0;
if ((old & 0x80) && m_active)
RestartDuration();
}
}
int SSI263Synth::Control::Target(int selector) const
{
if (selector == 4)
return m_regs[3] & 15;
if ((selector == 5 || selector == 6) && (m_regs[3] & 15) == 0)
return 0;
return (phonemeRom[8 * (m_regs[0] & 63) + selector] >> 4) & 15;
}
bool SSI263Synth::Control::PermitTransition(int selector) const
{
switch (selector)
{
case 0:
case 1:
case 3:
return m_articulationWindow && And(m_pw5,
((m_regs[0] & 32) || m_codes[5] || m_codes[6]) ? 1 : 0) == 0;
case 2:
return m_articulationWindow;
case 4:
// AMP=0 clears source targets but retains filter gain. U166B timing is approximate.
return m_durationWindow && (m_regs[3] & 15) != 0;
case 5:
return m_amplitudeWindow && m_pw0 == 1;
case 6:
return m_amplitudeWindow && m_pw1 == 1;
default:
return false;
}
}
void SSI263Synth::Control::WriteParameter()
{
if (!m_phoneValid || m_selector == 7)
return;
const bool setup = (m_phoneSetupWindow && m_selector != 4) ||
(m_controlSetupWindow && m_selector >= 4 && m_selector <= 6 &&
(m_selector != 4 || (m_regs[3] & 15) != 0));
if (setup)
m_ramps[m_selector].Retarget(Target(m_selector));
else if (!m_phoneSetupPending && m_active && PermitTransition(m_selector))
m_ramps[m_selector].Step();
}
void SSI263Synth::Control::LatchParameter(bool rising, int amplitudeZero)
{
if (!m_phoneValid)
return;
if (m_selector < 7)
m_codes[m_selector] = m_ramps[m_selector].m_value;
const int flags = phonemeRom[8 * (m_regs[0] & 63) + m_selector] & 15;
if (m_selector < 2 && m_durationPhase == ((flags & 1) ? 2 : 6))
(m_selector == 0 ? m_pw0 : m_pw1) = 1;
else if (m_selector == 2)
{
// PW3 is transparent in phases 10 and 11; route/PW2/PW5 use only the edge.
m_pw3 = LoadLatch(m_pw3, m_pw1, ((m_regs[3] & 0x80) || !(flags & 2)) ? 1 : 0);
if (!rising)
return;
const int parameter2 = (flags >> 2) & 1;
const int settled = Or(And(And(m_pw0, m_pw1), amplitudeZero), m_codes[6] == 0 ? 1 : 0);
const int enable = And(And(m_pw1, Or(m_pw2, parameter2)), settled);
m_route = LoadLatch(m_route, enable, (flags >> 3) & 1);
m_pw2 = parameter2;
m_pw5 = 1 - parameter2;
}
}
void SSI263Synth::Control::Tick(int amplitudeZero)
{
m_filterEdge = false;
const int previousRoute = m_route;
if (m_active && m_phoneValid)
{
if (--m_durationLeft == 0)
{
m_durationLeft = DurationPeriod();
m_durationPhase = (m_durationPhase + 1) & 15;
m_durationPending = true;
}
if (--m_articulationLeft == 0)
{
m_articulationLeft = ArticulationPeriod();
m_articulationPending = true;
}
if (--m_amplitudeLeft == 0)
{
m_amplitudeLeft = AmplitudePeriod();
m_amplitudePending = true;
}
}
// The settled scan phase selects WRITE=2, LATCH=10, and next selector=0.
m_scanPhase = (m_scanPhase + 1) & 15;
if (m_scanPhase == 0)
{
const int oldSelector = m_selector;
m_selector = (m_selector + 1) & 7;
if (oldSelector == 3)
{
m_phoneSetupWindow = m_phoneSetupPending;
m_controlSetupWindow = m_controlSetupPending;
m_articulationWindow = m_articulationPending;
m_amplitudeWindow = m_amplitudePending;
m_durationWindow = m_durationPending;
m_phoneSetupPending = m_controlSetupPending = false;
m_articulationPending = m_amplitudePending = m_durationPending = false;
}
}
if (m_scanPhase == 2)
WriteParameter();
if (m_scanPhase == 10 || m_scanPhase == 11)
LatchParameter(m_scanPhase == 10, amplitudeZero);
if (--m_filterLeft == 0)
{
m_filterLeft = 256 - m_regs[4];
m_filterPhase = !m_filterPhase;
m_filterEdge = true;
// A coincident route write and Phi0 edge must latch the old route.
if (!m_filterPhase)
m_fric2 = previousRoute < 0 ? -1 : 1 - previousRoute;
}
if (m_filterPhase)
m_fric1 = m_route;
}
SSI263Synth::Source::Source()
{
// Deterministic cold route: FRIC1 avoids the spurious first-H hiss.
// This seed and the voice/noise trims are not measured chip reset values.
m_output.m_fric1 = true;
}
void SSI263Synth::Source::ShiftNoise()
{
const int force = (m_noiseCount & 12) == 0 ? 1 : 0;
const int feedback = force ^ ((m_noise1 >> 3) & 1) ^ ((m_noise2 >> 4) & 1) ^
((m_noise4 >> 3) & 1) ^ ((m_noise4 >> 4) & 1);
// All four CD4006 shift sections read their inputs before this edge.
const int next1 = ((m_noise1 << 1) | ((m_noise3 >> 3) & 1)) & 15;
const int next2 = ((m_noise2 << 1) | ((m_noise4 >> 4) & 1)) & 31;
const int next3 = ((m_noise3 << 1) | ((m_noise2 >> 4) & 1)) & 15;
const int next4 = ((m_noise4 << 1) | feedback) & 31;
m_noise1 = next1;
m_noise2 = next2;
m_noise3 = next3;
m_noise4 = next4;
}
bool SSI263Synth::Source::ResetVoice(const Control& control) const
{
const bool closed = control.m_pw3 == 1 && !m_voiceToggle;
const bool up = !closed && (m_output.m_codes.m_voiceAmp != 0 || m_output.m_codes.m_fricAmp != 0);
return closed || (up ? m_amplitude != 15 : m_amplitude != 0);
}
void SSI263Synth::Source::Settle(const Control& control)
{
// A gate can open while its selector is already high. Resolve those edges
// between clock events; counting selector changes alone misses them.
for (int pass = 0; pass < 16; ++pass)
{
bool changed = false;
if (ResetVoice(control) && m_voiceToggle)
{
m_voiceToggle = false;
changed = true;
}
const bool closed = control.m_pw3 == 1 && !m_voiceToggle;
const bool up = !closed && (m_output.m_codes.m_voiceAmp != 0 || m_output.m_codes.m_fricAmp != 0);
const bool notTerminal = up ? m_amplitude != 15 : m_amplitude != 0;
const bool clock = (control.m_selector & 4) &&
!((!notTerminal && up) || (!up && AmplitudeZero()));
if (clock && !m_envelopeClock)
{
m_amplitude = (m_amplitude + (up ? 1 : 15)) & 15;
changed = true;
}
if (clock != m_envelopeClock)
changed = true;
m_envelopeClock = clock;
const bool noiseClock = !(control.m_pw3 == 1 && !m_voiceToggle) &&
!(control.m_selector & 2) && m_output.m_codes.m_fricAmp != 0;
if (noiseClock && !m_noiseClock)
m_noiseCount = m_noiseCount == 15 ? 1 : m_noiseCount + 1;
else if (!noiseClock && m_noiseClock)
ShiftNoise();
m_noiseClock = noiseClock;
if (!changed)
return;
}
assert(false && "SSI263 source gates failed to settle");
}
void SSI263Synth::Source::Tick(const Control& control, int inflection)
{
const bool phase = control.m_filterPhase;
const bool edge = control.m_filterEdge;
const bool poweredDown = (control.m_regs[3] & 0x80) != 0;
m_output.m_phase = phase;
m_output.m_phaseEdge = edge;
m_output.m_outputOpen = edge && !phase;
Codes& codes = m_output.m_codes;
// The phase latches remain open between edges.
if (!phase)
{
codes.m_f1 = control.m_codes[0];
codes.m_f3 = control.m_codes[3];
codes.m_voiceAmp = control.m_codes[5];
}
else
{
codes.m_f2 = control.m_codes[1];
codes.m_f2q = control.m_codes[2];
codes.m_f4 = control.m_codes[3];
codes.m_fricAmp = control.m_codes[6];
}
if (control.m_fric1 >= 0)
m_output.m_fric1 = control.m_fric1 != 0;
if (control.m_fric2 >= 0)
m_output.m_fric2 = control.m_fric2 != 0;
Settle(control);
// Pitch writes take effect at divider reload; phone writes do not reset it.
if (--m_voiceLeft == 0)
{
m_voiceLeft = 4 * (4096 - inflection);
if (!ResetVoice(control))
m_voiceToggle = !m_voiceToggle;
}
Settle(control);
if (poweredDown)
m_pitchSync = m_loadPending = false;
else if (edge && !phase)
{
const bool oldSync = m_pitchSync;
m_pitchSync = m_voiceToggle;
if (m_voiceToggle && !oldSync)
m_loadPending = true;
}
if (edge)
{
if (phase)
{
if (!poweredDown && m_loadPending)
{
m_voiceCount = 11; // Four Phi1 intervals before the counter reaches 15.
m_loadPending = false;
}
else if (m_voiceCount != 15)
++m_voiceCount;
}
else
{
const bool closed = control.m_pw3 == 1 && !m_voiceToggle;
codes.m_filterAmp = control.m_codes[4] & ((m_amplitude & 14) | (closed ? 0 : 1));
}
}
const bool closed = control.m_pw3 == 1 && !m_voiceToggle;
const bool noise = !(((m_noise3 >> 3) & 1) || closed) && (!m_voiceToggle || codes.m_voiceAmp == 0);
// CTL hard-mutes excitation but retains filter charge. This is a model policy.
m_output.m_voiceDrive = !poweredDown && m_voiceCount != 15 ? -16384 : 0;
m_output.m_fricDrive = poweredDown ? 0 : noise ? 301 : -301;
}
namespace
{
const std::array voiceCaps{220, 430, 870, 1800};
const std::array fric1Caps{270, 512, 1068, 2160};
const std::array fric2Caps{270, 530, 1082, 2160};
const std::array f1Caps{160, 330, 660, 1300};
const std::array f2Caps{280, 560, 1120, 2300};
const std::array f3Caps{210, 420, 820, 1640};
const std::array f4Caps{200, 400, 820, 1620};
const std::array ampCaps{76, 150, 300, 600};
const std::array zeroCaps{0};
}
bool SSI263Synth::Codes::Equals(const Codes& other) const
{
return m_f1 == other.m_f1 && m_f2 == other.m_f2 && m_f2q == other.m_f2q &&
m_f3 == other.m_f3 && m_f4 == other.m_f4 && m_filterAmp == other.m_filterAmp &&
m_voiceAmp == other.m_voiceAmp && m_fricAmp == other.m_fricAmp;
}
int SSI263Synth::Tract::Saturate(int64_t value)
{
return static_cast((value < -8388608 ? -8388608 : value > 8388607 ? 8388607 : value));
}
int SSI263Synth::Tract::Divide(int64_t numerator, int denominator)
{
// Sum pF * Q16 plate charge before rounding once, with ties away from zero.
const int64_t rounded = numerator < 0 ? numerator - denominator / 2 : numerator + denominator / 2;
return Saturate(rounded / denominator);
}
int SSI263Synth::Tract::CapSum(int mask, const std::array& caps)
{
int result = 0;
for (int bit = 0; bit < 4; ++bit)
{
if (mask & (1 << bit))
result += caps[bit];
}
return result;
}
int64_t SSI263Synth::Tract::Charge(int mask, int target, const std::array& plates,
const std::array& caps)
{
int64_t result = 0;
for (int bit = 0; bit < 4; ++bit)
{
if (mask & (1 << bit))
result += int64_t(caps[bit]) * (target - plates[bit]);
}
return result;
}
int64_t SSI263Synth::Tract::Weighted(int mask, const std::array& plates,
const std::array& caps)
{
int64_t result = 0;
for (int bit = 0; bit < 4; ++bit)
{
if (mask & (1 << bit))
result += int64_t(caps[bit]) * plates[bit];
}
return result;
}
void SSI263Synth::Tract::SetPlates(std::array& plates, int mask, int target)
{
for (int bit = 0; bit < 4; ++bit)
{
if (mask & (1 << bit))
plates[bit] = target;
}
}
void SSI263Synth::Tract::MoveFormant(int index, int mask, int fixedCap,
const std::array& caps, int denominator, int64_t extraCharge)
{
Formant& formant = m_formants[index];
const int delta = Divide(int64_t(fixedCap) * (formant.m_history - formant.m_fixedPlate) +
Charge(mask, formant.m_history, formant.m_plates, caps) + extraCharge, denominator);
formant.m_output = Saturate(int64_t(formant.m_output) - delta);
formant.m_fixedPlate = formant.m_history;
SetPlates(formant.m_plates, mask, formant.m_history);
}
void SSI263Synth::Tract::Process(const FilterEvent& event)
{
// With zero initial charge and no input drive, every voltage stays zero.
// Keep event history and all chip clocks running until the first excitation.
if (!m_hasExcitation)
{
if (event.m_voiceDrive == 0 && event.m_fricDrive == 0)
{
m_applied = event;
return;
}
m_hasExcitation = true;
}
const FilterEvent& e = event;
const bool changed = e.m_phaseEdge || e.m_phase != m_applied.m_phase ||
!e.m_codes.Equals(m_applied.m_codes) || e.m_voiceDrive != m_applied.m_voiceDrive ||
e.m_fricDrive != m_applied.m_fricDrive || e.m_fric1 != m_applied.m_fric1 || e.m_fric2 != m_applied.m_fric2;
if (!changed)
{
if (e.m_outputOpen)
m_reconstruction = m_output;
m_applied.m_outputOpen = e.m_outputOpen;
return;
}
const bool phi0 = e.m_phaseEdge && !e.m_phase;
const bool phi1 = e.m_phaseEdge && e.m_phase;
const int oldF2q = m_applied.m_codes.m_f2q;
const int oldFric = m_applied.m_fricDrive;
m_applied = e;
Formant& f1 = m_formants[0];
Formant& f2 = m_formants[1];
Formant& f3 = m_formants[2];
Formant& f4 = m_formants[3];
Formant& f5 = m_formants[4];
// Continuously closed precharge/reset paths use the final mask. Open
// capacitor plates retain their own voltage across code changes.
if (!e.m_phase)
{
m_voice = 0;
SetPlates(m_voicePlates, e.m_codes.m_voiceAmp, 0);
f1.m_fixedPlate = f3.m_fixedPlate = f5.m_fixedPlate = 0;
SetPlates(f1.m_plates, e.m_codes.m_f1, 0);
SetPlates(f3.m_plates, e.m_codes.m_f3, 0);
SetPlates(m_filterPlates, e.m_codes.m_filterAmp, f5.m_output);
}
else
{
m_fric1 = 0;
SetPlates(m_fric1Plates, e.m_codes.m_fricAmp, 0);
f2.m_fixedPlate = f4.m_fixedPlate = 0;
SetPlates(m_f2qPlates, e.m_codes.m_f2q, 0);
SetPlates(f2.m_plates, e.m_codes.m_f2, 0);
SetPlates(f4.m_plates, e.m_codes.m_f4, 0);
if (e.m_fric1)
m_c143Plate = 0;
}
// U152/U154, including source edges while either phase stays open.
if (phi0)
{
const int delta = Divide(int64_t(3600) * m_fric2Shape, 3900);
m_fric2Source = Saturate(int64_t(m_fric2Source) + delta);
m_fric2Shape = Saturate(int64_t(m_fric2Shape) + Divide(int64_t(-5700) * delta, 3900));
}
else if (phi1)
{
m_fric2Shape = Saturate(int64_t(m_fric2Shape) + Divide(int64_t(-3600) * m_fric2Source, 3900));
}
const int edgeDelta = Divide(-int64_t(CapSum(e.m_codes.m_fricAmp, fric2Caps)) * (e.m_fricDrive - oldFric), 3900);
m_fric2Source = Saturate(int64_t(m_fric2Source) + edgeDelta);
m_fric2Shape = Saturate(int64_t(m_fric2Shape) +
Divide(int64_t(e.m_phase ? -9300 : -5700) * edgeDelta, 3900));
m_c150Delta = e.m_phase ? edgeDelta : 0;
m_c151Delta = e.m_phase && e.m_fric2 ? m_fric2Source - m_c151Plate : 0;
if (e.m_fric2)
m_c151Plate = m_fric2Source;
const int oldVoice = m_voice;
if (e.m_phase)
{
m_voice = Saturate(int64_t(m_voice) -
Divide(Charge(e.m_codes.m_voiceAmp, e.m_voiceDrive, m_voicePlates, voiceCaps), 3300));
SetPlates(m_voicePlates, e.m_codes.m_voiceAmp, e.m_voiceDrive);
const int value = phi1 ? Divide(int64_t(11500) * f1.m_history +
int64_t(2700) * (f1.m_output - m_voice) - int64_t(2700) * m_voice, 11700) :
Divide(int64_t(-5400) * (m_voice - oldVoice), 11700);
f1.m_history = phi1 ? value : Saturate(int64_t(f1.m_history) + value);
}
else
{
m_fric1 = Saturate(int64_t(m_fric1) -
Divide(Charge(e.m_codes.m_fricAmp, e.m_fricDrive, m_fric1Plates, fric1Caps), 3900));
SetPlates(m_fric1Plates, e.m_codes.m_fricAmp, e.m_fricDrive);
const int newMask = (~oldF2q) & e.m_codes.m_f2q;
if (phi0)
{
f2.m_history = Divide(int64_t(6800) * f2.m_history + int64_t(4700) * (f2.m_output - f1.m_output) +
Weighted(e.m_codes.m_f2q, m_f2qPlates, voiceCaps), 7000 + CapSum(e.m_codes.m_f2q, voiceCaps));
}
else if (newMask)
{
const int keep = oldF2q & e.m_codes.m_f2q;
const int denominator = 7000 + CapSum(keep, voiceCaps);
f2.m_history = Divide(int64_t(denominator) * f2.m_history +
Weighted(newMask, m_f2qPlates, voiceCaps), denominator + CapSum(newMask, voiceCaps));
}
if (phi0 || newMask)
SetPlates(m_f2qPlates, e.m_codes.m_f2q, f2.m_history);
}
const int oldF1 = f1.m_output;
if (e.m_phase)
{
MoveFormant(0, e.m_codes.m_f1, 250, f1Caps, 11500);
const int value = phi1 ? Divide(int64_t(4700) * f3.m_history +
int64_t(3900) * (f3.m_output - f2.m_output) + int64_t(2000) * (oldF1 - f1.m_output), 4900) :
Divide(int64_t(-2000) * (f1.m_output - oldF1), 4900);
f3.m_history = phi1 ? value : Saturate(int64_t(f3.m_history) + value);
MoveFormant(2, e.m_codes.m_f3, 820, f3Caps, 4700);
const int64_t sideCharge = int64_t(-1150) * m_c150Delta - int64_t(3700) * m_c151Delta;
const int lastHistory = phi1 ? Divide(int64_t(3450) * f5.m_history +
int64_t(4700) * (f5.m_output - f4.m_output) + sideCharge, 3730) : Divide(sideCharge, 3730);
f5.m_history = phi1 ? lastHistory : Saturate(int64_t(f5.m_history) + lastHistory);
MoveFormant(4, 0, 4700, zeroCaps, 3450);
const int64_t outputCharge = Charge(e.m_codes.m_filterAmp, f5.m_output, m_filterPlates, ampCaps);
if (phi1)
m_output = Divide(int64_t(2700) * m_output - outputCharge, 2750);
else
m_output = Saturate(int64_t(m_output) - Divide(outputCharge, 2750));
SetPlates(m_filterPlates, e.m_codes.m_filterAmp, f5.m_output);
}
else
{
const int c143Delta = e.m_fric1 ? m_c143Plate - m_fric1 : 0;
MoveFormant(1, e.m_codes.m_f2, 500, f2Caps, 6800, int64_t(-1000) * c143Delta);
if (e.m_fric1)
m_c143Plate = m_fric1;
if (phi0)
f4.m_history = Divide(int64_t(4300) * f4.m_history +
int64_t(4700) * (f4.m_output - f3.m_output), 4500);
MoveFormant(3, e.m_codes.m_f4, 1670, f4Caps, 4300);
}
if (e.m_outputOpen)
m_reconstruction = m_output;
}
int16_t SSI263Synth::Tract::GetSample() const
{
// Floor division preserves the signed Q16-to-PCM rounding, including -1 -> -1.
const int output = m_reconstruction >= 0 ? m_reconstruction / 2 : -((-m_reconstruction + 1) / 2);
return static_cast(output < -32768 ? -32768 : output > 32767 ? 32767 : output);
}
namespace
{
struct StateWriter
{
std::vector m_words;
void Value(int& value, int, int) { m_words.push_back(static_cast(value)); }
void Value(bool& value) { m_words.push_back(value ? 1 : 0); }
};
struct StateReader
{
explicit StateReader(const std::vector& words) : m_words(words) {}
const std::vector& m_words;
std::size_t m_next = 0;
bool m_valid = true;
void Value(int& value, int minimum, int maximum)
{
if (m_next == m_words.size())
{
m_valid = false;
return;
}
const uint32_t word = m_words[m_next++];
const int64_t decoded = word <= INT32_MAX ? int64_t(word) : int64_t(word) - 0x100000000LL;
if (decoded < minimum || decoded > maximum)
m_valid = false;
else
value = static_cast(decoded);
}
void Value(bool& value)
{
int bit = 0;
Value(bit, 0, 1);
value = bit != 0;
}
};
}
template void SSI263Synth::VisitState(State& state)
{
// Keep this order fixed within a version. Never save raw structs or padding.
int version = 1;
state.Value(version, 1, 1);
int clockHz = static_cast(m_clockHz);
state.Value(clockHz, 1, 4000000);
m_clockHz = static_cast(clockHz);
state.Value(m_function, 0, 3);
state.Value(m_inflection, 0, 4095);
state.Value(m_pitchCadence, 0, 11);
state.Value(m_pitchSeeded);
for (int& reg : m_control.m_regs)
state.Value(reg, 0, 255);
for (Ramp& ramp : m_control.m_ramps)
{
state.Value(ramp.m_value, 0, 15);
state.Value(ramp.m_step, 0, 15);
state.Value(ramp.m_fraction, 0, 15);
state.Value(ramp.m_target, 0, 15);
state.Value(ramp.m_upward);
}
for (int& code : m_control.m_codes)
state.Value(code, 0, 15);
state.Value(m_control.m_selector, 0, 7);
state.Value(m_control.m_scanPhase, 0, 15);
state.Value(m_control.m_durationPhase, 0, 15);
state.Value(m_control.m_durationLeft, 1, 16384);
state.Value(m_control.m_articulationLeft, 1, 16384);
state.Value(m_control.m_amplitudeLeft, 1, 4096);
state.Value(m_control.m_active);
state.Value(m_control.m_phoneValid);
state.Value(m_control.m_phoneSetupPending);
state.Value(m_control.m_phoneSetupWindow);
state.Value(m_control.m_controlSetupPending);
state.Value(m_control.m_controlSetupWindow);
state.Value(m_control.m_articulationPending);
state.Value(m_control.m_articulationWindow);
state.Value(m_control.m_amplitudePending);
state.Value(m_control.m_amplitudeWindow);
state.Value(m_control.m_durationPending);
state.Value(m_control.m_durationWindow);
for (int* value : {&m_control.m_pw0, &m_control.m_pw1, &m_control.m_pw2, &m_control.m_pw3,
&m_control.m_pw5, &m_control.m_route, &m_control.m_fric1, &m_control.m_fric2})
state.Value(*value, -1, 1);
state.Value(m_control.m_filterLeft, 1, 256);
state.Value(m_control.m_filterPhase);
state.Value(m_control.m_filterEdge);
state.Value(m_source.m_noise1, 0, 15);
state.Value(m_source.m_noise2, 0, 31);
state.Value(m_source.m_noise3, 0, 15);
state.Value(m_source.m_noise4, 0, 31);
state.Value(m_source.m_noiseCount, 1, 15);
state.Value(m_source.m_voiceLeft, 1, 16384);
state.Value(m_source.m_voiceCount, 0, 15);
state.Value(m_source.m_amplitude, 0, 15);
state.Value(m_source.m_voiceToggle);
state.Value(m_source.m_envelopeClock);
state.Value(m_source.m_noiseClock);
state.Value(m_source.m_pitchSync);
state.Value(m_source.m_loadPending);
for (FilterEvent* event : {&m_source.m_output, &m_tract.m_applied})
{
state.Value(event->m_phase);
state.Value(event->m_phaseEdge);
Codes& codes = event->m_codes;
for (int* code : {&codes.m_f1, &codes.m_f2, &codes.m_f2q, &codes.m_f3, &codes.m_f4,
&codes.m_filterAmp, &codes.m_voiceAmp, &codes.m_fricAmp})
state.Value(*code, 0, 15);
state.Value(event->m_voiceDrive, -8388608, 8388607);
state.Value(event->m_fricDrive, -131072, 131071);
state.Value(event->m_fric1);
state.Value(event->m_fric2);
state.Value(event->m_outputOpen);
}
for (int* value : {&m_tract.m_voice, &m_tract.m_fric1, &m_tract.m_fric2Source, &m_tract.m_fric2Shape,
&m_tract.m_c143Plate, &m_tract.m_c151Plate, &m_tract.m_c150Delta,
&m_tract.m_output, &m_tract.m_reconstruction})
state.Value(*value, -8388608, 8388607);
state.Value(m_tract.m_c151Delta, -16777215, 16777215);
for (std::array* plates : {&m_tract.m_voicePlates, &m_tract.m_fric1Plates,
&m_tract.m_f2qPlates, &m_tract.m_filterPlates})
for (int& plate : *plates)
state.Value(plate, -8388608, 8388607);
for (Formant& formant : m_tract.m_formants)
{
state.Value(formant.m_output, -8388608, 8388607);
state.Value(formant.m_history, -8388608, 8388607);
state.Value(formant.m_fixedPlate, -8388608, 8388607);
for (int& plate : formant.m_plates)
state.Value(plate, -8388608, 8388607);
}
state.Value(m_tract.m_hasExcitation);
}
std::vector SSI263Synth::SaveState() const
{
SSI263Synth copy = *this;
StateWriter writer;
copy.VisitState(writer);
return writer.m_words;
}
bool SSI263Synth::LoadState(const std::vector& words)
{
SSI263Synth copy = *this;
StateReader reader(words);
copy.VisitState(reader);
if (!reader.m_valid || reader.m_next != words.size())
return false;
for (const Ramp& ramp : copy.m_control.m_ramps)
{
if ((ramp.m_upward && ramp.m_target < ramp.m_value) ||
(!ramp.m_upward && ramp.m_target > ramp.m_value))
return false;
}
if (!copy.m_tract.m_hasExcitation)
{
// A loaded cold-state flag must satisfy the fast path's zero-charge invariant.
const Tract& tract = copy.m_tract;
for (int value : {tract.m_voice, tract.m_fric1, tract.m_fric2Source, tract.m_fric2Shape,
tract.m_c143Plate, tract.m_c151Plate, tract.m_c150Delta, tract.m_c151Delta,
tract.m_output, tract.m_reconstruction, tract.m_applied.m_voiceDrive, tract.m_applied.m_fricDrive})
if (value != 0)
return false;
for (const std::array* plates : {&tract.m_voicePlates, &tract.m_fric1Plates,
&tract.m_f2qPlates, &tract.m_filterPlates})
for (int plate : *plates)
if (plate != 0)
return false;
for (const Formant& formant : tract.m_formants)
{
if (formant.m_output != 0 || formant.m_history != 0 || formant.m_fixedPlate != 0)
return false;
for (int plate : formant.m_plates)
if (plate != 0)
return false;
}
}
*this = copy;
return true;
}