diff options
author | 2025-04-29 02:33:49 -0700 | |
---|---|---|
committer | 2025-04-29 11:33:49 +0200 | |
commit | 071b8e474362c4225e41a6e17dcc57dec36c8bd8 (patch) | |
tree | 61307652c1832b53000eacda18b7db96ff97e45c | |
parent | 7c19bf107514e5708184ecdb768d24a919ba7e2f (diff) |
moog/source.cpp: emulating envelope generators and LFO timing. (#13636)
-rw-r--r-- | src/devices/sound/va_eg.h | 10 | ||||
-rw-r--r-- | src/mame/layout/moog_source.lay | 2 | ||||
-rw-r--r-- | src/mame/moog/nl_source.cpp | 102 | ||||
-rw-r--r-- | src/mame/moog/source.cpp | 348 |
4 files changed, 425 insertions, 37 deletions
diff --git a/src/devices/sound/va_eg.h b/src/devices/sound/va_eg.h index 29675145810..66bf8a9c99e 100644 --- a/src/devices/sound/va_eg.h +++ b/src/devices/sound/va_eg.h @@ -17,6 +17,16 @@ // - Start decay: rc_eg.set_r(decay_r).set_target_v(sustain_v); // - Start release: rc_eg.set_r(release_r).set_target_v(0); // +// The set_*() methods expect a monotonically increasing machine time, and will +// assert()-fail if that's not the case. +// If a single instance is used by multiple CPUs, this requirement can be met by +// calling set_*() from within timer callbacks invoked with: +// machine().scheduler().synchronize(timer_expired_delegate(...)). +// See src/mame/moog/source.cpp for examples. +// +// Depending on the details of the multi-CPU system being emulated, additional +// synchronization mitigations may be warranted, such as add_quantum(), +// set_perfect_quantum(), etc. class va_rc_eg_device : public device_t, public device_sound_interface { public: diff --git a/src/mame/layout/moog_source.lay b/src/mame/layout/moog_source.lay index 26a33ffbc92..833aa106ea1 100644 --- a/src/mame/layout/moog_source.lay +++ b/src/mame/layout/moog_source.lay @@ -1146,7 +1146,7 @@ copyright-holders:m1macrophage <element ref="text_mod_rate"> <bounds x="90" y="303" width="26" height="7"/> </element> - <element ref="led"> + <element ref="led" name="mod_rate_led"> <bounds x="118" y="304" width="8" height="8"/> </element> <element ref="text_pitch"> diff --git a/src/mame/moog/nl_source.cpp b/src/mame/moog/nl_source.cpp new file mode 100644 index 00000000000..04c41d91ab5 --- /dev/null +++ b/src/mame/moog/nl_source.cpp @@ -0,0 +1,102 @@ +#include "netlist/devices/net_lib.h" + +NETLIST_START(moogsource) +{ + SOLVER(Solver, 1000) // Used for DC calculations, so a low frequency is fine. + + ANALOG_INPUT(VPLUS, 15) + ANALOG_INPUT(VMINUS, -15) + // Voltage at the CA3080's Iabc input. One diode drop above -15V. + ANALOG_INPUT(CA3080_VABC, -14.3) + + + // Voltage-to-exponential-current converter for the filter contour generator. + // Generates the control current for U44 (CA3080A). + + // Inputs. + ANALOG_INPUT(FLT_CNTR_RATE) + POT(R201, RES_K(100)) // 'Range' pot. + + RES(R199, RES_K(43.2)) // 1% tolerance. + RES(R200, RES_K(470)) + RES(R202, RES_M(1)) + RES(R203, RES_K(1)) // 1% tolerance. + QBJT_EB(Q35, "2N3904") + RES(R204, RES_K(100)) + QBJT_EB(Q36, "2N3906") + RES(R198, RES_K(10)) + + NET_C(FLT_CNTR_RATE, R199.1) + NET_C(R199.2, R200.1, R202.1, R203.1, Q35.B) + NET_C(R200.2, R201.2) + NET_C(Q35.E, R204.1, Q36.B) + NET_C(Q36.C, R198.1) + NET_C(R198.2, CA3080_VABC) + NET_C(VPLUS, Q35.C) + NET_C(GND, R201.1, R203.2, Q36.E) + NET_C(VMINUS, R201.3, R202.2, R204.2) + + ALIAS(FLT_CNTR_CV, R198.1) // Output. + + + // Voltage-to-exponential-current converter for the loudness contour generator. + // Generates the control current for U42 (CA3080A). Identical to the circuit + // above. + + // Inputs. + ANALOG_INPUT(LOUD_CNTR_RATE, 0) + POT(R179, RES_K(100)) // 'Range' pot. + + RES(R177, RES_K(43.2)) // 1% tolerance. + RES(R178, RES_K(470)) + RES(R180, RES_M(1)) + RES(R181, RES_K(1)) // 1% tolerance. + QBJT_EB(Q33, "2N3904") + RES(R185, RES_K(100)) + QBJT_EB(Q34, "2N3906") + RES(R186, RES_K(10)) + + NET_C(LOUD_CNTR_RATE, R177.1) + NET_C(R177.2, R178.1, R180.1, R181.1, Q33.B) + NET_C(R178.2, R179.2) + NET_C(Q33.E, R185.1, Q34.B) + NET_C(Q34.C, R186.1) + NET_C(R186.2, CA3080_VABC) + NET_C(VPLUS, Q33.C) + NET_C(GND, R179.1, R181.2, Q34.E) + NET_C(VMINUS, R179.3, R180.2, R185.2) + + ALIAS(LOUD_CNTR_CV, R186.1) // Output. + + + // Voltage-to-exponential-current converter for the LFO. Generates the + // control current for U49 (CA3080A). Almost identical to the circuit above, + // except for a resistor value and the 'range' potentiometer being connected + // to +15V instead of -15V. + + // Inputs. + ANALOG_INPUT(MOD_RATE, 0) + POT(R223, RES_K(100)) // 'Range' pot. + + RES(R221, RES_K(49.9)) // 1% tolerance. + RES(R222, RES_K(470)) + RES(R224, RES_M(1)) + RES(R225, RES_K(1)) // 1% tolerance. + QBJT_EB(Q38, "2N3904") + RES(R226, RES_K(100)) + QBJT_EB(Q39, "2N3906") + RES(R227, RES_K(10)) + + NET_C(MOD_RATE, R221.1) + NET_C(R221.2, R222.1, R224.1, R225.1, Q38.B) + NET_C(R222.2, R223.2) + NET_C(Q38.E, R226.1, Q39.B) + NET_C(Q39.C, R227.1) + NET_C(R227.2, CA3080_VABC) + NET_C(VPLUS, R223.3, Q38.C) + NET_C(GND, R223.1, R225.2, Q39.E) + NET_C(VMINUS, R224.2, R226.2) + + ALIAS(MOD_CV, R227.1) // Output. +} + diff --git a/src/mame/moog/source.cpp b/src/mame/moog/source.cpp index eadb32ca697..939c39c1dc3 100644 --- a/src/mame/moog/source.cpp +++ b/src/mame/moog/source.cpp @@ -29,21 +29,20 @@ comments refer to Board 3, unless otherwise noted. This driver attempts to accurately emulate the digital and digital-analog interface of the synthesizer, including all analog behavior that is relevant to -the firmware. There are still some TODOs left to fully achieve this goal. There -is no attempt to emulate the analogue audio circuit. The driver includes an +the firmware. The analog audio circuit is not emulated. The driver includes an interactive layout, and is intended as an educational tool. - -TODO: -- Emulation of MOD input to the CPU. -- Emulation of envelope generator timing. -- Cassette input/output. */ #include "emu.h" #include "cpu/z80/z80.h" +#include "machine/netlist.h" #include "machine/nvram.h" #include "machine/rescap.h" +#include "machine/timer.h" +#include "sound/va_eg.h" + +#include "netlist/nl_setup.h" #include "moog_source.lh" @@ -52,12 +51,17 @@ TODO: #define LOG_ENCODER (1U << 3) #define LOG_KEYBOARD (1U << 4) #define LOG_CV_KEYBOARD_APPROX (1U << 5) +#define LOG_LFO (1U << 6) +#define LOG_LFO_TIMER (1U << 7) +#define LOG_CONTOUR (1U << 8) #define VERBOSE (LOG_GENERAL | LOG_CV) //#define LOG_OUTPUT_FUNC osd_printf_info #include "logmacro.h" +NETLIST_EXTERNAL(moogsource) // In nl_source.cpp. + namespace { constexpr const char MAINCPU_TAG[] = "z80"; @@ -66,17 +70,25 @@ constexpr const char NVRAM_TAG[] = "nvram"; class source_state : public driver_device { public: + static constexpr feature_type unemulated_features() { return feature::TAPE; } + source_state(const machine_config &mconfig, device_type type, const char *tag) ATTR_COLD : driver_device(mconfig, type, tag) , m_maincpu(*this, MAINCPU_TAG) + , m_contour(*this, "contour_%d", 0) + , m_contour_rate(*this, "source_nl:cntr_rate_%d", 0) + , m_contour_range(*this, "contour_range_%d",0) + , m_lfo_timer(*this, "lfo_timer") + , m_lfo_rate(*this, "source_nl:lfo_rate") + , m_lfo_range(*this, "lfo_range") , m_octave_io(*this, "octave_buttons") , m_button_a_io(*this, "button_group_a_%d", 0U) , m_button_b_io(*this, "button_group_b_%d", 0U) , m_keyboard_io(*this, "keyboard_oct_%d", 1U) , m_encoder(*this, "incremental_controller") , m_trigger_io(*this, "trigger_in") - , m_contour_peaked_io(*this, "contour_peaked") , m_octave_led(*this, "octave_led_%d") + , m_lfo_rate_led(*this, "mod_rate_led") , m_program_display(*this, "program_digit_%d") , m_edit_display(*this, "edit_digit_%d") , m_edit_led(*this, "edit_led") @@ -87,7 +99,7 @@ public: , m_lfo_to_osc(*this, "lfo_to_osc") , m_lfo_shape(*this, "lfo_shape") , m_trigger_out(*this, "trigger_out") - , m_cv(static_cast<int>(CV::SIZE), -1) + , m_cv(int(CV::SIZE), 0) {} void source(machine_config &config) ATTR_COLD; @@ -110,6 +122,7 @@ private: void cassette_w(u8 data); void cv_w(offs_t offset, u8 data); + bool contour_peaked(const va_rc_eg_device &eg) const; float get_keyboard_v() const; u8 keyboard_r(); u8 buttons_r(const required_ioport_array<6> &button_io, const char *name) const; @@ -117,19 +130,35 @@ private: u8 buttons_b_r(); u8 encoder_r(); + template<int Which> NETDEV_ANALOG_CALLBACK_MEMBER(contour_cv_changed); + template<int Which> TIMER_CALLBACK_MEMBER(update_contour); + + NETDEV_ANALOG_CALLBACK_MEMBER(lfo_cv_changed); + TIMER_CALLBACK_MEMBER(update_lfo_timer); + TIMER_DEVICE_CALLBACK_MEMBER(lfo_timer_tick); + void memory_map(address_map &map) ATTR_COLD; void io_map(address_map &map) ATTR_COLD; required_device<z80_device> m_maincpu; + + required_device_array<va_rc_eg_device, 2> m_contour; + required_device_array<netlist_mame_analog_input_device, 2> m_contour_rate; + required_ioport_array<2> m_contour_range; + + required_device<timer_device> m_lfo_timer; + required_device<netlist_mame_analog_input_device> m_lfo_rate; + required_ioport m_lfo_range; + required_ioport m_octave_io; required_ioport_array<6> m_button_a_io; required_ioport_array<6> m_button_b_io; required_ioport_array<4> m_keyboard_io; required_ioport m_encoder; required_ioport m_trigger_io; - required_ioport m_contour_peaked_io; output_finder<2> m_octave_led; + output_finder<> m_lfo_rate_led; output_finder<2> m_program_display; output_finder<2> m_edit_display; output_finder<> m_edit_led; @@ -144,9 +173,18 @@ private: bool m_octave_hi = true; // `true` due to internal pullups of 74LS367 and 7404. u8 m_button_row_latch = 0xff; bool m_encoder_incr = false; + bool m_lfo_state = false; // Square output of the LFO. -14V (false) to 14V (true). + float m_lfo_cc = 0; // Control current into the LFO OTA. + std::array<float, 2> m_contour_cc = { 0, 0 }; // Control currents into the EG OTAs. std::vector<float> m_cv; + enum contour_type + { + FILTER_CONTOUR = 0, + LOUDNESS_CONTOUR + }; + // All MUXes are CD4051B. // Component designations refer to board 2 (synthesizer board). // The enum names match the CV labels in the schematic, but some @@ -167,7 +205,7 @@ private: FILTER_CONTOUR_LEVEL, OCT_1, GLIDE, - LOUDNESS_COUNTOUR_LEVEL, + LOUDNESS_CONTOUR_LEVEL, OSC_2, NOISE, UNUSED, // Sampled in (C22, U10A), but not used. @@ -177,7 +215,7 @@ private: EMPHASIS, NOT_CONNECTED, // U5, Y1 (pin 14) is not connected. AMT, // Filter contout amount. - RATE, // Modulation (LFO) rate. + MOD_RATE, // Modulation (LFO) rate. KEYBOARD_APPROX, KEYBOARD_CV, FILTER_CONTOUR_RATE, @@ -186,7 +224,10 @@ private: SIZE }; + static constexpr const float VMINUS = -15; // In Volts. static constexpr const float MAX_CV = 10; // In Volts. + static constexpr const float CA3080_VABC = VMINUS + 0.7; // 1 diode drop above -15. + static constexpr const float CONTOUR_C = CAP_U(0.047); // C57 (filter), C56 (loudness). static constexpr const u8 PATTERNS_7447[16] = { 0x3f, 0x06, 0x5b, 0x4f, 0x66, 0x6d, 0x7c, 0x07, @@ -363,7 +404,7 @@ void source_state::cv_w(offs_t offset, u8 data) // Z80 A3,A4 select which MUX to enable via decoder 74LS155. // The fourth output of the decoder is not connected. There are 3 muxes. - if (offset >= static_cast<int>(CV::SIZE)) + if (offset >= offs_t(CV::SIZE)) return; const float cv = MAX_CV * data / 255.0F; @@ -371,13 +412,75 @@ void source_state::cv_w(offs_t offset, u8 data) return; m_cv[offset] = cv; - if (offset == static_cast<int>(CV::KEYBOARD_APPROX)) - LOGMASKED(LOG_CV_KEYBOARD_APPROX, - "CV %d: 0x%02x, %f\n", offset, data, cv); + switch (offset) + { + case offs_t(CV::MOD_RATE): + m_lfo_rate->write(cv); + break; + case offs_t(CV::LOUDNESS_CONTOUR_RATE): + m_contour_rate[LOUDNESS_CONTOUR]->write(cv); + break; + case offs_t(CV::LOUDNESS_CONTOUR_LEVEL): + machine().scheduler().synchronize(timer_expired_delegate(FUNC(source_state::update_contour<LOUDNESS_CONTOUR>), this)); + break; + case offs_t(CV::FILTER_CONTOUR_RATE): + m_contour_rate[FILTER_CONTOUR]->write(cv); + break; + case offs_t(CV::FILTER_CONTOUR_LEVEL): + machine().scheduler().synchronize(timer_expired_delegate(FUNC(source_state::update_contour<FILTER_CONTOUR>), this)); + break; + } + + if (offset == offs_t(CV::KEYBOARD_APPROX)) + LOGMASKED(LOG_CV_KEYBOARD_APPROX, "CV %d: 0x%02x, %f\n", offset, data, cv); else LOGMASKED(LOG_CV, "CV %d: 0x%02x, %f\n", offset, data, cv); } +bool source_state::contour_peaked(const va_rc_eg_device &eg) const +{ + // The peak detector circuits for the filter and loudness contour generators + // are identical. They are located on board 2, and based on an LM393 comparator + // (U41B and U41A respectively). The threshold is set to ~9.984V, with a + // +-0.005V hysteresis. The EG output is connected to the inverting input. + // The comparator output is 0V when the EG output is above the threshold, and + // 5V otherwise (open collector output pulled to 5V via 10K resistors, R193 + // and R190 respectively). + + static constexpr const float R192 = RES_M(4.7); // R188 for loudness EG. + static constexpr const float R191 = RES_K(10); // R189 for loudness EG. + static constexpr const float RISING_THRESHOLD = 5 * RES_VOLTAGE_DIVIDER(R191, R192) + 5; + static constexpr const float FALLING_THRESHOLD = 10 * RES_VOLTAGE_DIVIDER(R191, R192); + static_assert(RISING_THRESHOLD > FALLING_THRESHOLD); + + const float eg_v = eg.get_v(); + if (eg_v > RISING_THRESHOLD) + { + return true; + } + else if (eg_v < FALLING_THRESHOLD) + { + return false; + } + else // eg_v is within the hysteresis range. + { + // Proper emulation of hysteresis would require a streaming (or otherwise + // stateful) comparator. But a heuristic tailored to this use case works + // fine: if the EG voltage is falling, assume we hit the 'rising' threshold + // in the past, so the 'falling' threshold is the active one. + + // This heuristic will pick the wrong threshold if a key is released while + // the EG is within the hysteresis range. But that should be rare (the + // hysteresis range is ~0.01V), and inconsequential. The difference in + // thresholds is very small, and the firmware is the one that initiates + // the EG release and is probably ignoring this input until the next + // attack. + + const float future_eg_v = eg.get_v(machine().time() + attotime::from_msec(1)); + return future_eg_v < eg_v; + } +} + float source_state::get_keyboard_v() const { // *** Detect which key is pressed. @@ -445,19 +548,15 @@ u8 source_state::keyboard_r() // firmware does a binary search by checking the result of the comparison // and updating the "KYBD APPROX" CV accordingly. // TODO: Compute keyboard voltage in an input callback. - static constexpr const int KB_APPROX_INDEX = - static_cast<int>(CV::KEYBOARD_APPROX); - const u8 d0 = (get_keyboard_v() >= m_cv[KB_APPROX_INDEX]) ? 1 : 0; - - // D1, D2: Loudness and Filter contour peaks. - // D1 <- U32, FILT CNTR <- S22-11: 0 when envolope reaches almost 10V - // (with some hysteresis). - // D2 <- U32, LOUD CNTR <- S22-10: 0 when envelope reaches almost 10V - // (with some hysteresis). - // TODO: Treating as inputs for now, until contour timing is emulated. - const u8 contour_peaked = m_contour_peaked_io->read(); - const u8 d1 = BIT(contour_peaked, 0); - const u8 d2 = BIT(contour_peaked, 1); + const u8 d0 = (get_keyboard_v() >= m_cv[int(CV::KEYBOARD_APPROX)]) ? 1 : 0; + + // D1 - Filter contour peak reached (active low). + // D1 <- U32, FILT CNTR <- S22-11 <- Comparator (U41B, LM393). + const u8 d1 = contour_peaked(*m_contour[FILTER_CONTOUR]) ? 0 : 1; + + // D2 - Loudness contour peak reached (active low). + // D2 <- U32, LOUD CNTR <- S22-10 <- Comparator (U41A, LM393). + const u8 d2 = contour_peaked(*m_contour[LOUDNESS_CONTOUR]) ? 0 : 1; // D3: Octave. <- U32, OCT (P34-2 (octave 0 button) and P34-1 (octave +1 // button) via U2B and U2C). @@ -466,8 +565,12 @@ u8 source_state::keyboard_r() // D4 <- J1-4, CASSETTE IN (through "cassette return" circuit and U18D). const u8 d4 = 1; // TODO: Implement. - // D5 <- U32, MOD (->P34-5) 1 when s22-5 low, otherwise 0. - const u8 d5 = 0; // TODO: Implement. + // D5 <- U32 D5 <- MOD. + // The square wave output of the LFO (~ -14V - 14V, connection S33-5) is + // inverted and level-shifted by Q5, R85, R86. The emitter of Q4 (signal + // name "MOD") is connected to U32's D5, and to the cathode of the "MOD + // RATE" LED (connection P34-5). + const u8 d5 = m_lfo_state ? 0 : 1; // D6 <- J2-5, S-TRIG IN, through U18C, pulled up by R23 and protected by // R22. @@ -489,7 +592,7 @@ u8 source_state::buttons_r( for (int i = 0; i < 6; ++i) { if (!BIT(m_button_row_latch, i)) - pressed |= static_cast<u8>(~button_io[i]->read() & 0xff); + pressed |= u8(~button_io[i]->read() & 0xff); } // Bits 6 and 7 are not connected to the button input and pulled high. pressed |= 0xc0; @@ -526,6 +629,138 @@ u8 source_state::encoder_r() return m_encoder_incr ? 1 : 0; } +template<int Which> NETDEV_ANALOG_CALLBACK_MEMBER(source_state::contour_cv_changed) +{ + // The control current (Iabc) into each envelope generator ("contour") CA3080 + // is determined by a voltage-to-exponential-current converter (see relevant + // netlist). + + // This callback is invoked by the netlist simulation when the control current + // changes. This happens when the firmware sets a new (dis)charge rate, or if + // the "range" trimmer is adjusted. + + static_assert(Which == FILTER_CONTOUR || Which == LOUDNESS_CONTOUR); + static constexpr const char *CONTOUR_NAME = (Which == FILTER_CONTOUR) ? "Filter" : "Loudness"; + static constexpr const int RATE_CV_INDEX = + (Which == FILTER_CONTOUR) ? int(CV::FILTER_CONTOUR_RATE) : int(CV::LOUDNESS_CONTOUR_RATE); + + // The netlist outputs a voltage. Convert it to a current. + m_contour_cc[Which] = (data - CA3080_VABC) / RES_K(10); // R198 for filter, R186 for loudness. + machine().scheduler().synchronize(timer_expired_delegate(FUNC(source_state::update_contour<Which>), this)); + + LOGMASKED(LOG_CONTOUR, "%s contour CC: %f uA, rate CV: %f, range trimmer: %d\n", + CONTOUR_NAME, m_contour_cc[Which] * 1e6F, m_cv[RATE_CV_INDEX], m_contour_range[Which]->read()); +} + +// Must be called with machine().scheduler().synchronize(...), to ensure the EG +// updates use the global time. +template<int Which> TIMER_CALLBACK_MEMBER(source_state::update_contour) +{ + // Each of the voltage-controlled envelope generators (called "contours" on + // the schematic) are based on a CA3080 OTA configured as a current-controlled + // resistor. This configuration is explained in the first 10 minutes of + // https://www.youtube.com/watch?v=pTHHzFsa4Ss + + // The OTA (dis)charges a capacitor to the level set by the firmware. + // Charge rate is controlled by the Iabc current into the OTA, which is + // also contrlled by the firmware (see contour_cv_changed()). + + static_assert(Which == FILTER_CONTOUR || Which == LOUDNESS_CONTOUR); + static constexpr const char *CONTOUR_NAME = (Which == FILTER_CONTOUR) ? "Filter" : "Loudness"; + static constexpr const int LEVEL_CV_INDEX = + (Which == FILTER_CONTOUR) ? int(CV::FILTER_CONTOUR_LEVEL) : int(CV::LOUDNESS_CONTOUR_LEVEL); + + // All componets are on board 2. All resistors have 1% tolerance. + // Filter contour Loudness contour + static constexpr const float R196 = RES_K(18.2); // R183 + static constexpr const float R197 = RES_R(100); // R184 + static constexpr const float R195 = RES_K(20); // R187 + static constexpr const float R194 = RES_R(100); // R182 + + // Voltage dividers at the OTA's + and - inputs. + static constexpr const float OTA_DIVIDER_PLUS = RES_VOLTAGE_DIVIDER(R196, R197); + static constexpr const float OTA_DIVIDER_MINUS = RES_VOLTAGE_DIVIDER(R195, R194); + + if (m_contour_cc[Which] <= 0) + { + // The netlist solver might transiently send negative values. + LOG("%s EG received a non-positive control current. Skipping update.\n", CONTOUR_NAME); + return; + } + + // Ideal OTA transconductance at room temparature. + const float g = 19.2F * m_contour_cc[Which]; + // Note the sligh difference in the calculations below, compared to the video + // linked above, due to the resistive dividers at the two OTA inputs not + // being identical. + const float effective_r = 1.0F / (g * OTA_DIVIDER_MINUS); + m_contour[Which]->set_r(effective_r); + + const float level_cv = m_cv[LEVEL_CV_INDEX]; // 0V - 10V. + const float target_v = level_cv * OTA_DIVIDER_PLUS / OTA_DIVIDER_MINUS; // 0V - ~10.98V. + m_contour[Which]->set_target_v(target_v); + + LOGMASKED(LOG_CONTOUR, "%s EG update - Level CV: %f, target_v: %f, R: %f, tau: %f\n", + CONTOUR_NAME, level_cv, target_v, effective_r, effective_r * CONTOUR_C); +} + +NETDEV_ANALOG_CALLBACK_MEMBER(source_state::lfo_cv_changed) +{ + // The calculation of the LFO control current is very similar to that of the + // EGs. See contour_cv_changed(). + m_lfo_cc = (data - CA3080_VABC) / RES_K(10); // R227 + machine().scheduler().synchronize(timer_expired_delegate(FUNC(source_state::update_lfo_timer), this)); + LOGMASKED(LOG_LFO, "LFO CC: %f uA, rate CV: %f, range trimmer: %d\n", + m_lfo_cc * 1e6F, m_cv[int(CV::MOD_RATE)], m_lfo_range->read()); +} + +TIMER_CALLBACK_MEMBER(source_state::update_lfo_timer) +{ + // The LFO ("MOD OSC" in the schematic) is a triangle core oscillator based + // on a CA3080 OTA (U49). The OTA's Iabc is determined by a voltage-to-exponential-current + // converter, whose voltage is set by the firmware. + // The OTA is configured to (dis)charges the capacitor (C58) with a constant + // current, resulting in a triangle wave (-/+ ~1.5V). The oscillator also + // produces a square wave (-/+ ~14V) as part of its operation. + + // All components on board 2. + static constexpr const float R219 = RES_K(100); + static constexpr const float R220 = RES_K(12); + static constexpr const float C58 = CAP_U(0.33); + + // Approximate max magnitude of opamp output, according to schematic (supply voltage is 15V). + static constexpr const float V_PEAK_SQUARE = 14; + static constexpr const float V_PEAK_TRIANGLE = V_PEAK_SQUARE * RES_VOLTAGE_DIVIDER(R219, R220); // ~1.5V + + // The differential input at the OTA will be +/- V_PEAK_TRIANGLE. This is well + // beyond the "linear" range (-/+ ~10-20mV), so the output current will be + // saturated to (almost) +/- Iabc (m_lfo_cc). + const float i_out = m_lfo_cc; + + // Time it takes to charge the capacitor from -V_PEAK_TRIANGLE to +V_PEAK_TRIANGLE + // with a constant current. This is the half-period of the LFO, which is what + // we need for our timer. + const float t_half = 2 * V_PEAK_TRIANGLE * C58 / i_out; + + // Continue from the current position in the cycle. + const double t_remaining = t_half * m_lfo_timer->remaining().as_double() / m_lfo_timer->period().as_double(); + + if (i_out > 0) + m_lfo_timer->adjust(attotime::from_double(t_remaining), 0, attotime::from_double(t_half)); + else + m_lfo_timer->reset(); + + LOGMASKED(LOG_LFO, "LFO frequency updated - Icharge: %f uA, t_remaining: %f, t_half: %f, f: %f\n", + i_out * 1e6F, t_remaining, t_half, 1.0F / (2.0F * t_half)); +} + +TIMER_DEVICE_CALLBACK_MEMBER(source_state::lfo_timer_tick) +{ + m_lfo_state = !m_lfo_state; + m_lfo_rate_led = !m_lfo_state; // LED (LED 3, board 5) is active low. + LOGMASKED(LOG_LFO_TIMER, "LFO Timer ticked: %d\n", m_lfo_state); +} + void source_state::memory_map(address_map &map) { // Address decoding done through U26, 74LS138, E1=E2=0, E3=1, @@ -585,6 +820,7 @@ void source_state::io_map(address_map &map) void source_state::machine_start() { m_octave_led.resolve(); + m_lfo_rate_led.resolve(); m_program_display.resolve(); m_edit_display.resolve(); m_edit_led.resolve(); @@ -599,12 +835,25 @@ void source_state::machine_start() save_item(NAME(m_octave_hi)); save_item(NAME(m_button_row_latch)); save_item(NAME(m_encoder_incr)); + save_item(NAME(m_lfo_state)); + save_item(NAME(m_lfo_cc)); + save_item(NAME(m_contour_cc)); save_item(NAME(m_cv)); } void source_state::machine_reset() { update_octave_leds(); + update_contour<FILTER_CONTOUR>(0); + update_contour<LOUDNESS_CONTOUR>(0); + update_lfo_timer(0); + + // If an input port has its default value at startup, its write callback will + // not be invoked. Ensure the netlist inputs are initialized even in that + // scenario. + subdevice<netlist_mame_analog_input_device>("source_nl:cntr_range_0")->write(m_contour_range[0]->read()); + subdevice<netlist_mame_analog_input_device>("source_nl:cntr_range_1")->write(m_contour_range[1]->read()); + subdevice<netlist_mame_analog_input_device>("source_nl:lfo_range")->write(m_lfo_range->read()); } void source_state::source(machine_config &config) @@ -617,7 +866,29 @@ void source_state::source(machine_config &config) NVRAM(config, NVRAM_TAG, nvram_device::DEFAULT_ALL_0); // 2x6514: U27, U28. + VA_RC_EG(config, m_contour[FILTER_CONTOUR]).set_c(CONTOUR_C); // C57 (Board 2). + VA_RC_EG(config, m_contour[LOUDNESS_CONTOUR]).set_c(CONTOUR_C); // C56 (Board 2). + TIMER(config, m_lfo_timer).configure_generic(FUNC(source_state::lfo_timer_tick)); + config.set_default_layout(layout_moog_source); + + + NETLIST_CPU(config, "source_nl", netlist::config::DEFAULT_CLOCK()).set_source(NETLIST_NAME(moogsource)); + + NETLIST_ANALOG_INPUT(config, "source_nl:cntr_range_0", "R201.DIAL"); + NETLIST_ANALOG_INPUT(config, m_contour_rate[FILTER_CONTOUR], "FLT_CNTR_RATE.IN"); + NETLIST_ANALOG_OUTPUT(config, "source_nl:cntr_cv_0") + .set_params("FLT_CNTR_CV", FUNC(source_state::contour_cv_changed<FILTER_CONTOUR>)); + + NETLIST_ANALOG_INPUT(config, "source_nl:cntr_range_1", "R179.DIAL"); + NETLIST_ANALOG_INPUT(config, m_contour_rate[LOUDNESS_CONTOUR], "LOUD_CNTR_RATE.IN"); + NETLIST_ANALOG_OUTPUT(config, "source_nl:cntr_cv_1") + .set_params("LOUD_CNTR_CV", FUNC(source_state::contour_cv_changed<LOUDNESS_CONTOUR>)); + + NETLIST_ANALOG_INPUT(config, "source_nl:lfo_range", "R223.DIAL"); + NETLIST_ANALOG_INPUT(config, m_lfo_rate, "MOD_RATE.IN"); + NETLIST_ANALOG_OUTPUT(config, "source_nl:lfo_cv") + .set_params("MOD_CV", FUNC(source_state::lfo_cv_changed)); } DECLARE_INPUT_CHANGED_MEMBER(source_state::octave_button_pressed) @@ -818,10 +1089,15 @@ INPUT_PORTS_START(source) PORT_START("trigger_in") // External trigger input (see keyboard_r()). PORT_BIT(0x01, IP_ACTIVE_LOW, IPT_OTHER) PORT_NAME("S TRIG IN") PORT_CODE(KEYCODE_T) - // TODO: User can control when contours peak, until those are emulated. - PORT_START("contour_peaked") - PORT_BIT(0x01, IP_ACTIVE_LOW, IPT_OTHER) PORT_NAME("Filter Contour Peaked") PORT_CODE(KEYCODE_Z) - PORT_BIT(0x02, IP_ACTIVE_LOW, IPT_OTHER) PORT_NAME("Loudness Contour Peaked") PORT_CODE(KEYCODE_X) + PORT_START("contour_range_0") // R201 (Board 2), 100K trimpot. + PORT_ADJUSTER(50, "FILTER_CONTOUR_RANGE") NETLIST_ANALOG_PORT_CHANGED("source_nl", "cntr_range_0") + + PORT_START("contour_range_1") // R179 (Board 2), 100K trimpot. + PORT_ADJUSTER(50, "LOUDNESS_CONTOUR_RANGE") NETLIST_ANALOG_PORT_CHANGED("source_nl", "cntr_range_1") + + PORT_START("lfo_range") // R223 (Board 2), 100K trimpot. + // A default of 0 takes us the closest to the advertised highest LFO frequency of 30 Hz. + PORT_ADJUSTER(0, "LFO RANGE") NETLIST_ANALOG_PORT_CHANGED("source_nl", "lfo_range") INPUT_PORTS_END // It seems like the Source was launched with firmware Revision 2.2. |