diff --git a/scripts/src/sound.lua b/scripts/src/sound.lua index 7f7d7506fe58d..5d5bd768f65e3 100644 --- a/scripts/src/sound.lua +++ b/scripts/src/sound.lua @@ -1190,6 +1190,18 @@ if (SOUNDS["VA_VCA"]~=null) then } end +-------------------------------------------------- +-- Virtual analog voltage-controlled filters (VCFs) +--@src/devices/sound/va_vcf.h,SOUNDS["VA_VCF"] = true +-------------------------------------------------- + +if (SOUNDS["VA_VCF"]~=null) then + files { + MAME_DIR .. "src/devices/sound/va_vcf.cpp", + MAME_DIR .. "src/devices/sound/va_vcf.h", + } +end + --------------------------------------------------- -- VLM5030 speech synthesizer --@src/devices/sound/vlm5030.h,SOUNDS["VLM5030"] = true diff --git a/src/devices/sound/va_vcf.cpp b/src/devices/sound/va_vcf.cpp new file mode 100644 index 0000000000000..d63176e0f4ae9 --- /dev/null +++ b/src/devices/sound/va_vcf.cpp @@ -0,0 +1,330 @@ +// license:BSD-3-Clause +// copyright-holders:m1macrophage,Olivier Galibert + +#include "emu.h" +#include "va_vcf.h" +#include "machine/rescap.h" + +#include + +va_lpf4_device::va_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) + : va_lpf4_device(mconfig, VA_LPF4, tag, owner, clock) +{ +} + +va_lpf4_device::va_lpf4_device(const machine_config &mconfig, device_type type, const char *tag, device_t *owner, uint32_t clock) + : device_t(mconfig, type, tag, owner, clock) + , device_sound_interface(mconfig, *this) + , m_stream(nullptr) + , m_fc(0) + , m_res(0) +{ + std::fill(m_a.begin(), m_a.end(), 0); + std::fill(m_b.begin(), m_b.end(), 0); + std::fill(m_x.begin(), m_x.end(), 0); + std::fill(m_y.begin(), m_y.end(), 0); +} + +void va_lpf4_device::set_fixed_freq_cv(float freq_cv) +{ + if (!m_stream) + fatalerror("%s: set_fixed_freq_cv() cannot be called before device_start()\n", tag()); + if (BIT(get_sound_requested_inputs_mask(), INPUT_FREQ)) + fatalerror("%s: Cannot set a fixed frequency CV when streaming it.\n", tag()); + + const float fc = cv_to_freq(freq_cv); + if (fc == m_fc) + return; + + m_stream->update(); + m_fc = fc; + recalc_filter(); +} + +void va_lpf4_device::set_fixed_res_cv(float res_cv) +{ + if (!m_stream) + fatalerror("%s: set_fixed_res_cv() cannot be called before device_start()\n", tag()); + if (BIT(get_sound_requested_inputs_mask(), INPUT_RES)) + fatalerror("%s: Cannot set a fixed resonance CV when streaming it.\n", tag()); + + const float res = cv_to_res(res_cv); + if (res == m_res) + return; + + m_stream->update(); + m_res = res; + recalc_filter(); +} + +float va_lpf4_device::cv_to_freq(float freq_cv) const +{ + return freq_cv; +} + +float va_lpf4_device::cv_to_res(float res_cv) const +{ + return res_cv; +} + +void va_lpf4_device::device_start() +{ + if (!BIT(get_sound_requested_inputs_mask(), INPUT_AUDIO)) + fatalerror("%s: requires input 0 to be connected.\n", tag()); + if (get_sound_requested_inputs_mask() & ~u64(7)) + fatalerror("%s: can only have inputs 0-2 connected.\n", tag()); + + save_item(NAME(m_fc)); + save_item(NAME(m_res)); + save_item(NAME(m_a)); + save_item(NAME(m_b)); + save_item(NAME(m_x)); + save_item(NAME(m_y)); + + m_stream = stream_alloc(get_sound_requested_inputs(), 1, SAMPLE_RATE_OUTPUT_ADAPTIVE); + recalc_filter(); +} + +/* + A 4-level lowpass filter with a loopback: + + + +-[+]-<-[*-1]--------------------------+ + | | | + ^ [*r] | + | | | + | v ^ + input ---+-[+]--[LPF]---[LPF]---[LPF]---[LPF]---+--- output + + All 4 LPFs are identical, with a transconductance G: + + output = 1/(1+s/G)^4 * ( (1+r)*input - r*output) + + or + + output = input * (1+r)/((1+s/G)^4+r) + + to which the usual z-transform can be applied (see votrax.c) +*/ +void va_lpf4_device::sound_stream_update(sound_stream &stream) +{ + const bool streaming_freq = BIT(get_sound_requested_inputs_mask(), INPUT_FREQ); + const bool streaming_res = BIT(get_sound_requested_inputs_mask(), INPUT_RES); + const bool streaming_cv = streaming_freq || streaming_res; + + const int n = stream.samples(); + for(int i = 0; i < n; ++i) + { + if (streaming_cv) + { + bool recalc = false; + if (streaming_freq) + { + const float fc = cv_to_freq(stream.get(INPUT_FREQ, i)); + if (fc != m_fc) + { + m_fc = fc; + recalc = true; + } + } + if (streaming_res) + { + const float res = cv_to_res(stream.get(INPUT_RES, i)); + if (res != m_res) + { + m_res = res; + recalc = true; + } + } + if (recalc) + recalc_filter(); + } + + const float x = stream.get(INPUT_AUDIO, i); + const float y = (x * m_a[0] + + m_x[0] * m_a[1] + m_x[1] * m_a[2] + m_x[2] * m_a[3] + m_x[3] * m_a[4] + - m_y[0] * m_b[1] - m_y[1] * m_b[2] - m_y[2] * m_b[3] - m_y[3] * m_b[4]) / m_b[0]; + memmove(&m_x[1], &m_x[0], 3 * sizeof(float)); + memmove(&m_y[1], &m_y[0], 3 * sizeof(float)); + m_x[0] = x; + m_y[0] = y; + stream.put(0, i, y); + + // When the input goes quiet, the filter can oscillate continuously at + // very low volume and, in some cases, eventually "explode". Detect low + // volume states and stop any oscillations. + bool quiet = true; + for (const auto my : m_y) + { + if (fabsf(my) > 1e-20) + { + quiet = false; + break; + } + } + if (quiet) + std::fill(m_y.begin(), m_y.end(), 0); + } +} + +void va_lpf4_device::recalc_filter() +{ + const float T = 1.0F / m_stream->sample_rate(); + const float w = 2 * float(M_PI) * m_fc; + + // Using the "bounded cutoff prewarping" strategy described in Zavalishin's + // "The art of VA filter design": if the cutoff is larger than some bound + // w_max (16KHz in the book), then use w_max instead of the cutoff as the + // prewarp point. The argument is that there is no point improving the filter + // response accuracy at inaudible frequencies, at the expense of accuracy at + // audible ones. This is more relevant to HPFs, but a useful side-effect for + // LPFs is that the cutoff frequency can be near or beyond Nyquist, which + // does not work well with standard cutoff prewarping. + // Here, we set the max at 16KHz (same as in the book). But for low sample + // rates, we use a fraction of Nyquist instead. + const float w_max = 2 * float(M_PI) * std::min(0.75F * m_stream->sample_rate() / 2, 16'000.0F); + float g = 0; + if (w <= w_max) + g = tanf(w * T / 2); + else + g = tanf(w_max * T / 2) / w_max * w; + + const float gzc = 1 / g; + const float gzc2 = gzc * gzc; + const float gzc3 = gzc2 * gzc; + const float gzc4 = gzc3 * gzc; + const float r1 = 1 + m_res; + + m_a[0] = r1; + m_a[1] = 4 * r1; + m_a[2] = 6 * r1; + m_a[3] = 4 * r1; + m_a[4] = r1; + + m_b[0] = r1 + 4 * gzc + 6 * gzc2 + 4 * gzc3 + gzc4; + m_b[1] = 4 * (r1 + 2 * gzc - 2 * gzc3 - gzc4); + m_b[2] = 6 * (r1 - 2 * gzc2 + gzc4); + m_b[3] = 4 * (r1 - 2 * gzc + 2 * gzc3 - gzc4); + m_b[4] = r1 - 4 * gzc + 6 * gzc2 - 4 * gzc3 + gzc4; +} + + +cem3320_lpf4_device::cem3320_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, float c_p, float r_f) + : va_lpf4_device(mconfig, CEM3320_LPF4, tag, owner, 0) + , m_r_eq(1) + , m_cv2freq(1) + , m_res_enabled(false) + , m_r_rc(1) + , m_res_a(1) +{ + // See cem3320_lpf4_device::cv_to_freq() for info on these equations. + constexpr float AI0 = 0.9F; // From the datasheet. + m_r_eq = RES_2_PARALLEL(r_f, RES_M(1)); + m_cv2freq = AI0 / (2 * float(M_PI) * m_r_eq * c_p); +} + +cem3320_lpf4_device::cem3320_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) + : cem3320_lpf4_device(mconfig, tag, owner, CAP_P(300), RES_K(100)) // Arbitrarily choosing the example values in the datasheet. +{ +} + +cem3320_lpf4_device &cem3320_lpf4_device::configure_resonance(float r_rc, float r_ri) +{ + return configure_resonance(r_rc, r_ri, -1, 1); +} + +cem3320_lpf4_device &cem3320_lpf4_device::configure_resonance(float r_rc, float r_ri, float r_ri_gnd, float external_gain) +{ + // See cv_to_res() for details on the equations here. + constexpr float Z_RI = RES_K(3.6); // Nominal input impedance of pin 8. + const float z_input = (r_ri_gnd > 0) ? RES_2_PARALLEL(Z_RI, r_ri_gnd) : Z_RI; + m_res_a = external_gain * z_input / r_ri; + + m_r_rc = r_rc; + m_res_enabled = true; + return *this; +} + +float cem3320_lpf4_device::cv_to_freq(float freq_cv) const +{ + // From the datasheet, the pole frequency is given by: + // f_p = AI0 / (2 * PI * R_EQ * C_P) * exp(-V_C / V_T), where: + // - V_C ~ Frequency control voltage at pin 12. + // - V_T ~ Thermal voltage. + // - AI0 ~ Gain when V_C = 0. Typically 0.9, can range from 0.7 to 1.3. + // - R_EQ ~ Parallel combination of R_F and 1MOhm. + // - R_F ~ External feedback resistor. Usually 100K. + // - C_P ~ External capacitor. + constexpr float VT = 0.0252F; // Thermal voltage at 20C. + // m_cv2freq caches: AI0 / (2 * PI * R_EQ * C_P). + return m_cv2freq * expf(-freq_cv / VT); +} + +float cem3320_lpf4_device::cv_to_res(float res_cv) const +{ + if (!m_res_enabled) + fatalerror("%s: Attempting to use resonance, but configure_resonance() was never called.\n", tag()); + + // Resonance is applied by having the output of the filter (pin 10) feed + // back into the resonance input (pin 8), which is routed to the filter's + // input via an OTA. The control current for the OTA is provided to pin 9. + + // Compute resonance control current. + const float i_rc = res_cv / m_r_rc; + + // Compute mapping from resonance control current to the OTA's + // transconductance. + + // The datasheet provides a graph (figure 6) of that mapping but no + // equations. It calls it a "modified linear scale". The equations below + // transition smoothly between lines (A[0], B[0]) and (A[1], B[1]), by + // blending with a 3rd line (A[2], B[2]). Line 0 is the tangent line near + // X = 0uA, line 1 is the tangent line near X = 300uA, and line 2 connects + // the Y points of line 1 and 2 at X = 0uA and 300uA respectively. + // The values below were determined by eyeballing the graph. The result + // matches the graph decently well, but note that the graph has a max X of + // 300 uA. Not sure what happens beyond that. The equation below treat that + // part as (almost) linear. + + constexpr float A[3] = { 500E-6F / 30E-6F, (1600E-6F - 1200E-6F) / 300E-6F, 1600E-6F / 300E-6F }; + constexpr float B[3] = { 0, 1200E-6F, 0 }; + constexpr float C = B[1] / (A[0] - A[1]); // X at which lines 0 and 1 intersect. + constexpr float K = 0.015E6F; // Smoothing factor. + constexpr float MAX_G_M = 2250E-6F; // From figure 6. + + const float y = (i_rc <= C) ? (A[0] * i_rc + B[0]) : (A[1] * i_rc + B[1]); + const float blend = 1.0F / (1.0F + expf(-K * fabsf(i_rc - C))); + const float g_m = std::min(MAX_G_M, blend * y + (1.0F - blend) * (A[2] * i_rc + B[2])); + + // Convert the transconductance to a gain. + + // This is done by rearranging the datasheet equation for determining R_RI + // (the signal resistor at pin 8), while also taking into account signal + // gain that might be applied externally, and any external resistors from + // pin 8 to ground. + + // With the above in mind, we have: + // GAIN = (EXTERNAL_GAIN * INPUT_Z / R_RI) * (G * R_EQ - 1), where: + // - EXTERNAL_GAIN ~ Gain applied to the filter output (pin 10), before + // routing it to the resonance input (pin 8). + // - INPUT_Z ~ Input impedance at pin 8. This is 3.6 KOhm nominal, unless a + // resistor to ground is connected externally. + // - R_RI ~ External resistor between the input signal and pin 8. + // - G ~ transconductance of the resonance OTA. + // - R_EQ ~ (R_F || 1MOhm). See datasheet. + // - R_F ~ external feedback resistor. + + // The (EXTERNAL_GAIN * INPUT_Z / R_RI) factor is computed in + // configure_resonance() and stored in m_res_a. + const float gain = m_res_a * (g_m * m_r_eq - 1.0F); + + // The equations in the datasheet can result in slightly negative gain + // values. Clamp those to 0. + // The CEM3320 supports some gain above 4, which results in an increased + // self-oscillation amplitude. But the implementation here does not support + // gain >= 4, so clamp it. + return std::clamp(gain, 0.0F, 3.99F); +} + +DEFINE_DEVICE_TYPE(VA_LPF4, va_lpf4_device, "va_lpf4", "4th order LPF") +DEFINE_DEVICE_TYPE(CEM3320_LPF4, cem3320_lpf4_device, "cem3320_lpf4", "CEM3320-based 4th order LPF") diff --git a/src/devices/sound/va_vcf.h b/src/devices/sound/va_vcf.h new file mode 100644 index 0000000000000..d5d9cf49db7b1 --- /dev/null +++ b/src/devices/sound/va_vcf.h @@ -0,0 +1,125 @@ +// license:BSD-3-Clause +// copyright-holders:m1macrophage +/* +Virtual analog filters: + +* VA_LPF4 / va_lpf4_device + + An ideal (linear) 4th order, resonant low-pass filter. + + Cutoff frequency and resonance can either be provided by calling class + methods, or via input streams. + + The frequency CV is in Hz, and the resonance CV is the feedback gain (0-4). + The meaning of CV can be different in subclasses: it will typically match + the type of inputs in the emulated hardware. + +* CEM3320_LPF4 / cem3320_lpf4_device: + + A CEM3320 configured as a 4th order low-pass filter, with optional resonance + control. + + The frequency CV is the voltage applied to pin 12. The resonance CV is the + voltage applied to resistor R_RC, connected to pin 9. +*/ + +#ifndef MAME_SOUND_VA_VCF_H +#define MAME_SOUND_VA_VCF_H + +#pragma once + +DECLARE_DEVICE_TYPE(VA_LPF4, va_lpf4_device) +DECLARE_DEVICE_TYPE(CEM3320_LPF4, cem3320_lpf4_device) + +class va_lpf4_device : public device_t, public device_sound_interface +{ +public: + enum input_streams + { + INPUT_AUDIO = 0, + INPUT_FREQ, + INPUT_RES + }; + + va_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock = 0) ATTR_COLD; + + // The meaning of "CV" depends on the class being instantiated. See the + // overview at the top of the file. + void set_fixed_freq_cv(float freq_cv); + void set_fixed_res_cv(float res_cv); + +protected: + va_lpf4_device(const machine_config &mconfig, device_type type, const char *tag, device_t *owner, uint32_t clock) ATTR_COLD; + + virtual float cv_to_freq(float freq_cv) const; + virtual float cv_to_res(float res_cv) const; + + void device_start() override ATTR_COLD; + void sound_stream_update(sound_stream &stream) override; + +private: + void recalc_filter(); + + sound_stream *m_stream; + + float m_fc; // Cutoff frequency in Hz. + float m_res; // Feedback gain. + std::array m_a; + std::array m_b; + std::array m_x; + std::array m_y; +}; + + +// A CEM3320 configured as a 4th order lowpass filter, with optional resonance. +// freq CV: Voltage applied to pin 12. +// res CV: Voltage applied to resistor R_RC connected to pin 9. + +// Known inaccuracies: +// - Filter implementation is linear. +// - On the actual device, once self-oscillation is achieved, increasing the +// resonance CV will increase the amplitude of the oscillation (up to a limit). +// This is not modeled here. The maximum resonance is capped for filter stability. +// - The resonance CV response was eyeballed from a graph on the datasheet, and +// is approximate. Furthermore, that graph only goes up to a control current +// of 300 uA. The implementation here extrapolates linearly beyond that. +class cem3320_lpf4_device : public va_lpf4_device +{ +public: + // c_p: pole capacitor. The value of the capacitors connected to pins 4, 5, 11, 16. + // r_f: feedback resistor. The value of the resistors connecting pins 1 and 7, + // 2 and 6, 17 and 15, and 18 and 10. + cem3320_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, float c_p, float r_f) ATTR_COLD; + cem3320_lpf4_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) ATTR_COLD; + + // Enable resonance (pin 10 connected to pin 8, via passive components) + // using the configuration in the datasheet. + // r_rc: The resistor connected to the resonance control input (pin 9). + // R_RC in the datasheet. + // r_ri: The resistor between the filter output and the resonance signal + // input (pin 8). R_RI in the datasheet. + cem3320_lpf4_device &configure_resonance(float r_rc, float r_ri) ATTR_COLD; + + // Similar to the above, but accommodates additional external circuitry that + // affects the filter's coefficients. + // r_ri_gnd: Some designs add a resistor from the resonance signal input + // (pin 8) to ground, possibly via a DC-blocking capacitor. Set + // to a negative number if no such resistor exists. + // external_gain: Gain applied to the filter output (pin 10) before routing + // to the resonance signal input (pin 8). + cem3320_lpf4_device &configure_resonance(float r_rc, float r_ri, float r_ri_gnd, float external_gain) ATTR_COLD; + +protected: + float cv_to_freq(float freq_cv) const override; + float cv_to_res(float res_cv) const override; + +private: + // Configuration, not needed in save state. + float m_r_eq; // R_EQ in the datasheet. + float m_cv2freq; // Cached computation for frequency calculations. + bool m_res_enabled; + float m_r_rc; // R_RC in the datasheet. + float m_res_a; // Cached computation for resonance calculations. +}; + +#endif // MAME_SOUND_VA_VCF_H diff --git a/src/mame/linn/linndrum.cpp b/src/mame/linn/linndrum.cpp index f69c4d150aeeb..8a0f52c441c81 100644 --- a/src/mame/linn/linndrum.cpp +++ b/src/mame/linn/linndrum.cpp @@ -2,9 +2,9 @@ // copyright-holders:m1macrophage /* -The LinnDrum (unofficially also known as LM-2) is a digital drum machine. It -stores and plays digital samples (recordings of acoustic dumps), some of which -are processed by analog filters or other analog circuits. +The LinnDrum is a digital drum machine. It stores and plays digital samples +(recordings of acoustic drums), some of which are processed by analog filters or +amplifiers. The firmware runs on a Z80. It controls the UI (reads buttons, drives displays and LEDs), synchronization with other devices, cassette I/O, and voice @@ -16,8 +16,8 @@ selection, decay time), bringing the number of possible sounds to 28. However, end-users can only trigger 23 sounds, and can control mixing and panning for 15 of them (some are grouped together). Each voice core can run independently, for a max polyphony of 10. Only one variation per core can be -active at a time. The "click" and "beep" sounds can be played independently of -each other and the voice cores. +active at a time. The "click" and "beep" sounds can be played simultaneously +with each other and the voice cores. There are multiple voice architectures: @@ -43,7 +43,7 @@ There are multiple voice architectures: sidestick. The voice core output is post-processed by a single-pole lowpass RC filter. The end-user can control the sample rate via a tuning knob. This section also includes the circuit for the "click" sound (metronome), - which triggers pulses of fixed length, and the circuit for the "beep" sound, + which triggers pulses of fixed length, and the circuit for the "beep" sound, which allows the firmware to generate pulses of arbitrary length. * The "tom / conga" section is similar to the "snare / sidestick" one. It @@ -58,12 +58,6 @@ There are multiple voice architectures: The driver is based on the LinnDrum's service manual and schematics, and is intended as an educational tool. -Reasons for MACHINE_IMPERFECT_SOUND: -* Missing a few sample checksums. -* Missing bass drum LPF and filter envelope. -* Missing tom / conga LPF and filter envelope. -* Linear, instead of tanh response for hi-hat VCA. - PCBoards: * CPU board. 2 sections in schematics: * Computer. @@ -100,6 +94,7 @@ with a high sample rate. #include "sound/spkrdev.h" #include "sound/va_eg.h" #include "sound/va_vca.h" +#include "sound/va_vcf.h" #include "speaker.h" #include "linn_linndrum.lh" @@ -113,14 +108,20 @@ with a high sample rate. #define LOG_MIX (1U << 7) #define LOG_PITCH (1U << 8) #define LOG_HAT_EG (1U << 9) +#define LOG_CV_OFFSET (1U << 10) -#define VERBOSE (LOG_GENERAL) +#define VERBOSE (LOG_GENERAL | LOG_CV_OFFSET) //#define LOG_OUTPUT_FUNC osd_printf_info #include "logmacro.h" namespace { +// Voltage rails. +constexpr const float VPLUS = 15; +constexpr const float VMINUS = -15; +constexpr const float VCC = 5; + enum mux_voices { MV_TAMBOURINE = 0, @@ -179,8 +180,176 @@ enum mixer_channels NUM_MIXER_CHANNELS }; +// The bass drum and tom/conga voices are post-processed by VCFs with hardcoded +// envelopes. The EG will quickly open up the filter in the first ~5ms, and then +// more gradually close it off. This setup allows the attack transients through, +// while filtering high-frequency noise at low signal levels. Such noise could +// be more apparent for these bass-heavy voices. +class linndrum_vcf_eg_device : public device_t, public device_sound_interface +{ +public: + // trimmer_tag: the input port tag of the CV offset trimmer. + // eg_r_output: the resistor at the output of the EG buffer (mux: R135, tom: no designation). + // cv_r_summer_output: the resistor at the output of the CV op-amp (mux: R133, tom: no designation). + linndrum_vcf_eg_device(const machine_config &mconfig, const char *tag, device_t *owner, const char *trimmer_tag, float eg_r_output, float cv_r_summer_output) ATTR_COLD; + linndrum_vcf_eg_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) ATTR_COLD; + + void trigger(); + + DECLARE_INPUT_CHANGED_MEMBER(freq_cv_offset_changed); + +protected: + void sound_stream_update(sound_stream &stream) override; + + void device_add_mconfig(machine_config &config) override ATTR_COLD; + void device_start() override ATTR_COLD; + void device_reset() override ATTR_COLD; + +private: + float get_freq_cv(float v_eg) const; + void update_freq_cv_offset(); + TIMER_DEVICE_CALLBACK_MEMBER(trigger_timer_elapsed); + + required_ioport m_offset_trimmer; + required_device m_trigger_timer; // LM556 - mux: U37A, tom: U22A + required_device m_rc; // mux: R132, R142, C70. tom: no designations. + + sound_stream *m_stream; + float m_i_offset; + + // The bass and tom/conga envelope generator circuits have the same topology, + // but some of the components differ. The differing components are passed + // as constructor arguments and stored in the const member variables below. + // The rest are defined as constants. + + // Note that the tom section in the service manual is missing component + // designations for all passive components. All component designations below + // are for the bass EG in the mux section. + + const float m_eg_r_output; // mux:R135 + const float m_cv_scale; // mux:R133-R128 voltage divider. + + static inline constexpr float TIMER_R = RES_K(510); // mux:R9 + static inline constexpr float TIMER_C = CAP_U(0.01); // mux:C7 + + // The schematic shows the charging resistor as 100K for the tom EG, and + // 10K for the bass EG. However, the description of the EG in the service + // manual states that the CV takes ~5ms to go from 50mv to -50mv in the + // attack phase. This is only possible with a 10K resistor, so using that. + static inline constexpr float EG_R_CHARGE = RES_K(10); // mux:R139 + static inline constexpr float EG_R_DISCHARGE = RES_M(1); // mux:R142 + static inline constexpr float EG_R_CHARGE_EFFECTIVE = RES_2_PARALLEL(EG_R_CHARGE, EG_R_DISCHARGE); + static inline constexpr float EG_V_TARGET = VCC * RES_VOLTAGE_DIVIDER(EG_R_CHARGE, EG_R_DISCHARGE); + static inline constexpr float EG_C = CAP_U(0.1); // mux:C70 + + static inline constexpr float CV_R_TRIMMER_MAX = RES_K(10); // no designation + static inline constexpr float CV_R_OFFSET = RES_K(47); // mux:R137 + static inline constexpr float CV_R_SUMMER_FEEDBACK = RES_K(2.4); // mux:R136 + static inline constexpr float CV_R_DIVIDER_BOTTOM = RES_K(1); // mux:R128 +}; + } // anonymous namespace +DEFINE_DEVICE_TYPE(LINNDRUM_VCF_EG, linndrum_vcf_eg_device, "linndrum_vcf_eg", "LinnDrum filter envelope generator"); + +linndrum_vcf_eg_device::linndrum_vcf_eg_device(const machine_config &mconfig, const char *tag, device_t *owner, const char *trimmer_tag, float eg_r_output, float cv_r_summer_output) + : device_t(mconfig, LINNDRUM_VCF_EG, tag, owner, 0) + , device_sound_interface(mconfig, *this) + , m_offset_trimmer(*this, trimmer_tag) + , m_trigger_timer(*this, "trigger_timer") + , m_rc(*this, "rc") + , m_stream(nullptr) + , m_i_offset(0) + , m_eg_r_output(eg_r_output) + , m_cv_scale(RES_VOLTAGE_DIVIDER(cv_r_summer_output, CV_R_DIVIDER_BOTTOM)) +{ +} + +linndrum_vcf_eg_device::linndrum_vcf_eg_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) + : linndrum_vcf_eg_device(mconfig, tag, owner, ":trimmer_bass_freq_cv_offset", 1, 1) +{ +} + +// Starts EG attack. +void linndrum_vcf_eg_device::trigger() +{ + m_rc->set_r(EG_R_CHARGE_EFFECTIVE); + m_rc->set_target_v(EG_V_TARGET); + m_trigger_timer->adjust(PERIOD_OF_555_MONOSTABLE(TIMER_R, TIMER_C)); +} + +// Starts EG release. +TIMER_DEVICE_CALLBACK_MEMBER(linndrum_vcf_eg_device::trigger_timer_elapsed) +{ + m_rc->set_r(EG_R_DISCHARGE); + m_rc->set_target_v(0); +} + +float linndrum_vcf_eg_device::get_freq_cv(float v_eg) const +{ + const float i_eg = v_eg / m_eg_r_output; + return -(i_eg + m_i_offset) * CV_R_SUMMER_FEEDBACK * m_cv_scale; +} + +void linndrum_vcf_eg_device::update_freq_cv_offset() +{ + // The frequency CV offset can be adjusted by a trimmer. + + // The code below computes the current through resistor R to GND. R1 and R2 + // are the two sides of the trimmer. + // + // V+ --- R1 --*-- R2 --- V- + // | + // R + // | + // GND (virtual) + + m_stream->update(); + + constexpr float R = CV_R_OFFSET; + const float R2 = CV_R_TRIMMER_MAX * m_offset_trimmer->read() / 100.0; + const float R1 = CV_R_TRIMMER_MAX - R2; + + // vx: voltage at the junction of all resistors. + const float vx = (R * R1 * VMINUS + R * R2 * VPLUS) / (R * R1 + R * R2 + R1 * R2); + m_i_offset = vx / R; + + LOGMASKED(LOG_CV_OFFSET, "%s: CV Offset current: %f. CV range: %f - %f\n", + tag(), m_i_offset, get_freq_cv(0), get_freq_cv(EG_V_TARGET)); +} + +DECLARE_INPUT_CHANGED_MEMBER(linndrum_vcf_eg_device::freq_cv_offset_changed) +{ + update_freq_cv_offset(); +} + +void linndrum_vcf_eg_device::sound_stream_update(sound_stream &stream) +{ + const int n = stream.samples(); + for (int i = 0; i < n; ++i) + stream.put(0, i, get_freq_cv(stream.get(0, i))); +} + +void linndrum_vcf_eg_device::device_add_mconfig(machine_config &config) +{ + TIMER(config, m_trigger_timer).configure_generic(FUNC(linndrum_vcf_eg_device::trigger_timer_elapsed)); + VA_RC_EG(config, m_rc).set_c(CAP_U(0.1)); // mux: C70, tom: no designation. + m_rc->add_route(0, *this, 1.0); +} + +void linndrum_vcf_eg_device::device_start() +{ + save_item(NAME(m_i_offset)); + m_stream = stream_alloc(1, 1, machine().sample_rate()); +} + +void linndrum_vcf_eg_device::device_reset() +{ + update_freq_cv_offset(); +} + + +namespace { class linndrum_audio_device : public device_t { @@ -189,7 +358,7 @@ class linndrum_audio_device : public device_t void mux_drum_w(int voice, u8 data, bool is_strobe = true); void snare_w(u8 data); // Snare and sidestick. - void tom_w(u8 data); // Tom and conga. + void tom_w(u8 data, bool is_strobe); // Tom and conga. void strobe_click_w(u8 data); void beep_w(int state); @@ -230,6 +399,7 @@ class linndrum_audio_device : public device_t required_device m_mux_timer; // 74LS627 (U77A). required_device_array m_mux_dac; // AM6070 (U88). required_device_array m_mux_volume; // CD4053 (U90), R60, R62. + required_device m_bass_eg; required_device m_hat_trigger_timer; // U37B (LM556). required_device m_hat_eg; required_device m_hat_vca; // CEM3360 (U91B). @@ -258,6 +428,7 @@ class linndrum_audio_device : public device_t required_memory_region m_conga_samples; // 2 x 2732 ROMs (U66, U67). required_device m_tom_timer; // 74LS627 (U77B). required_device m_tom_dac; // AM6070 (U82). + required_device m_tom_eg; required_device_array m_tom_out; // U87 (CD4051) outputs 0, 1, 4, 5, 6. bool m_tom_counting = false; // /Q1 of U73 (74LS74). u16 m_tom_counter = 0; // 14-bit counter (2 x 74LS393, U70, U71). @@ -301,8 +472,6 @@ class linndrum_audio_device : public device_t static constexpr const float OUTPUT_C_FEEDBACK = CAP_P(1000); static constexpr const float R_ON_CD4053 = RES_R(125); // Typical Ron resistance for 15V supply (-7.5V / 7.5V). - static constexpr const float VPLUS = 15; // Volts. - static constexpr const float VCC = 5; // Volts. static constexpr const float MUX_DAC_IREF = VPLUS / (RES_K(15) + RES_K(15)); // R55 + R57. static constexpr const float TOM_DAC_IREF = MUX_DAC_IREF; // Configured in the same way. // All DAC current-to-voltage converter resistors, for both positive and @@ -347,6 +516,8 @@ class linndrum_audio_device : public device_t }; }; +} // anonymous namespace + DEFINE_DEVICE_TYPE(LINNDRUM_AUDIO, linndrum_audio_device, "linndrum_audio_device", "LinnDrum audio circuits"); linndrum_audio_device::linndrum_audio_device(const machine_config &mconfig, const char *tag, device_t *owner, u32 clock) @@ -357,6 +528,7 @@ linndrum_audio_device::linndrum_audio_device(const machine_config &mconfig, cons , m_mux_timer(*this, "mux_drum_timer") , m_mux_dac(*this, "mux_drums_virtual_dac_%u", 1) , m_mux_volume(*this, "mux_drums_volume_control_%u", 1) + , m_bass_eg(*this, "bass_vcf_eg") , m_hat_trigger_timer(*this, "hat_trigger_timer") , m_hat_eg(*this, "hat_eg") , m_hat_vca(*this, "hat_vca") @@ -374,6 +546,7 @@ linndrum_audio_device::linndrum_audio_device(const machine_config &mconfig, cons , m_conga_samples(*this, ":sample_conga") , m_tom_timer(*this, "tom_conga_timer") , m_tom_dac(*this, "tom_conga_dac") + , m_tom_eg(*this, "tom_conga_vcf_eg") , m_tom_out(*this, "tom_conga_out_%u", 0) , m_volume(*this, ":pot_gain_%u", 1) , m_pan(*this, ":pot_pan_%u", 1) @@ -404,7 +577,12 @@ void linndrum_audio_device::mux_drum_w(int voice, u8 data, bool is_strobe) const bool attenuate = !BIT(data, 1) && voice != MV_CLAP && voice != MV_COWBELL; m_mux_volume[voice]->set_gain(attenuate ? ATTENUATION : 1); - if (voice == MV_HAT) + if (voice == MV_BASS) + { + if (is_strobe) + m_bass_eg->trigger(); + } + else if (voice == MV_HAT) { m_hat_open = BIT(data, 2); m_hat_triggered = is_strobe; @@ -445,7 +623,7 @@ void linndrum_audio_device::snare_w(u8 data) // While there is a capacitor (C29, 0.01 UF) attached to the DAC's Iref // input, it is too small to have a musical effect. Changes in current will - // stablizie within 0.1 ms, and such changes only happen at voice trigger. + // stabilize within 0.1 ms, and such changes only happen at voice trigger. static constexpr const float R0 = RES_K(3.3); // R70. static constexpr const float R1 = RES_K(380); // R69. @@ -466,7 +644,7 @@ void linndrum_audio_device::snare_w(u8 data) LOGMASKED(LOG_STROBES, "Strobed snare / sidestick: %02x (iref: %f)\n", data, iref); } -void linndrum_audio_device::tom_w(u8 data) +void linndrum_audio_device::tom_w(u8 data, bool is_strobe) { m_tom_counting = BIT(data, 0); if (!m_tom_counting) @@ -504,6 +682,9 @@ void linndrum_audio_device::tom_w(u8 data) for (int i = 0; i < NUM_TOM_VOICES; ++i) m_tom_out[i]->set_gain((i == selected_output) ? 1 : 0); + if (is_strobe) + m_tom_eg->trigger(); + LOGMASKED(LOG_STROBES, "Strobed tom / conga: %02x (is_tom: %d, pitch:%d, output: %d, %s)\n", data, m_tom_selected, m_tom_selected_pitch, selected_output, (selected_output >= 0) ? TOM_VOICE_NAMES[selected_output] : "none"); @@ -574,6 +755,13 @@ void linndrum_audio_device::device_add_mconfig(machine_config &config) m_mux_dac[voice]->add_route(0, m_mux_volume[voice], 1.0); } + // The bass VCF has a cutoff frequency of ~1.4KHz, which transiently + // increases to ~100KHz when the voice triggers. + LINNDRUM_VCF_EG(config, m_bass_eg, ":trimmer_bass_freq_cv_offset", RES_K(18), RES_K(5.1)); // R135, R133. + auto &bass_vcf = CEM3320_LPF4(config, "bass_vcf", CAP_P(150), RES_K(100)); + m_mux_volume[MV_BASS]->add_route(0, bass_vcf, 1.0, cem3320_lpf4_device::INPUT_AUDIO); + m_bass_eg->add_route(0, bass_vcf, 1.0, cem3320_lpf4_device::INPUT_FREQ); + TIMER(config, m_hat_trigger_timer).configure_generic(FUNC(linndrum_audio_device::hat_trigger_timer_tick)); // LM556 (U37B). VA_RC_EG(config, m_hat_eg).set_c(HAT_C22); VA_VCA(config, m_hat_vca).configure_cem3360_linear_cv(); @@ -605,23 +793,33 @@ void linndrum_audio_device::device_add_mconfig(machine_config &config) SPEAKER_SOUND(config, m_beep).set_levels(2, LEVELS); // *** Tom / conga section. + // The schematic for this section is missing designations for all passive + // components. TIMER(config, m_tom_timer).configure_generic(FUNC(linndrum_audio_device::tom_timer_tick)); // 74LS627 (U77B). DAC76(config, m_tom_dac, 0); // AM6070 (U82). // Schematic is missing the second resistor, but that's almost certainly an error. - // It is also missing component designations. m_tom_dac->configure_voltage_output(R_DAC_I2V, R_DAC_I2V); m_tom_dac->set_fixed_iref(TOM_DAC_IREF); + + // The tom VCF has a cutoff frequency of ~650Hz, which transiently + // increases to ~46KHz when the voice triggers. + LINNDRUM_VCF_EG(config, m_tom_eg, ":trimmer_tom_freq_cv_offset", RES_K(10), RES_K(10)); + auto &tom_vcf = CEM3320_LPF4(config, "tom_conga_vcf", CAP_P(330), RES_K(100)); + m_tom_dac->add_route(0, tom_vcf, 1.0, cem3320_lpf4_device::INPUT_AUDIO); + m_tom_eg->add_route(0, tom_vcf, 1.0, cem3320_lpf4_device::INPUT_FREQ); + for (int i = 0; i < NUM_TOM_VOICES; ++i) { FILTER_VOLUME(config, m_tom_out[i]); // One of U87'S (CD4051) outputs. - m_tom_dac->add_route(0, m_tom_out[i], 1.0); + tom_vcf.add_route(0, m_tom_out[i], 1.0); } - // *** Mixer. + // *** Mixer and output section. + const std::array voice_outputs = { - m_mux_volume[MV_BASS], + &bass_vcf, m_snare_out, m_sidestick_out, m_hat_vca, @@ -647,7 +845,8 @@ void linndrum_audio_device::device_add_mconfig(machine_config &config) { // The filter and gain will be configured in update_volume_and_pan(). FILTER_RC(config, m_voice_hpf[i]); - voice_outputs[i]->add_route(0, m_voice_hpf[i], 1.0); + // An op-amp inverter is attached to the output of the bass VCF. + voice_outputs[i]->add_route(0, m_voice_hpf[i], (i == MIX_BASS) ? -1.0 : 1.0); m_voice_hpf[i]->add_route(0, m_left_mixer, 1.0); m_voice_hpf[i]->add_route(0, m_right_mixer, 1.0); } @@ -834,7 +1033,7 @@ TIMER_DEVICE_CALLBACK_MEMBER(linndrum_audio_device::tom_timer_tick) if (BIT(m_tom_counter, 13)) // Counter reached 0x2000 (8192). { // All outputs of U42B and U73B (74LS74 flip-flops) are cleared. - tom_w(0); + tom_w(0, false); return; } @@ -924,7 +1123,7 @@ void linndrum_audio_device::update_volume_and_pan(int channel) // Capacitor to ground, at the wiper of the "click" volume fader. static constexpr const float C_CLICK_WIPER = CAP_U(0.047); // No designation. - // All "click" filter configurations result in singificant attenutation + // All "click" filter configurations result in significant attenuation // (< 0.2x), which makes the click very quiet. The filter characteristics // (gain and Fc) have been verified with simulations. So it is possible // there is an error in the schematic. Different capacitor values, or a @@ -1066,7 +1265,7 @@ void linndrum_audio_device::update_tom_pitch() void linndrum_audio_device::update_hat_decay() { - // When the hat is configed as "open", the EG will discharge through a 1M + // When the hat is configured as "open", the EG will discharge through a 1M // resistor. When the hat is "closed", a CD4053 will add a parallel // discharge path through the "hihat decay" pot. float eg_r = HAT_R33; @@ -1268,7 +1467,7 @@ void linndrum_state::update_tempo_timer() static constexpr const float P1_MAX = RES_K(100); // Using `100 - pot value` because the higher (the more clockwise) the pot - // is turned, the lower the resistance and the fastest the tempo. + // is turned, the lower the resistance and the faster the tempo. const float tempo_r = (100 - m_tempo_pot->read()) * P1_MAX / 100.0F; const attotime period = PERIOD_OF_555_ASTABLE(R1, R2 + tempo_r, C2); m_tempo_timer->adjust(period, 0, period); @@ -1346,7 +1545,7 @@ void linndrum_state::memory_map(address_map &map) map(0x1f85, 0x1f85).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_BASS, get_voice_data(data)); })); map(0x1f86, 0x1f86).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->snare_w(get_voice_data(data)); })); map(0x1f87, 0x1f87).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_HAT, get_voice_data(data)); })); - map(0x1f88, 0x1f88).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->tom_w(get_voice_data(data)); })); + map(0x1f88, 0x1f88).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->tom_w(get_voice_data(data), true); })); map(0x1f89, 0x1f89).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_RIDE, get_voice_data(data)); })); map(0x1f8a, 0x1f8a).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_CRASH, get_voice_data(data)); })); map(0x1f8b, 0x1f8b).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_CABASA, get_voice_data(data)); })); @@ -1604,6 +1803,19 @@ INPUT_PORTS_START(linndrum) PORT_ADJUSTER(100, "CLAPS GAIN") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::mix_changed), MIX_CLAPS) PORT_START("pot_gain_16") PORT_ADJUSTER(100, "CLICK GAIN") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::mix_changed), MIX_CLICK) + + // The service manual shows the CV being held at ~50mV when the voice is not + // triggered. The default value of the trimmer achieves that. + PORT_START("trimmer_tom_freq_cv_offset") + PORT_ADJUSTER(13, "TRIMMER: TOM/CONGA CUTOFF CV OFFSET") + PORT_CHANGED_MEMBER("linndrum_audio:tom_conga_vcf_eg", FUNC(linndrum_vcf_eg_device::freq_cv_offset_changed), 0) + + // Same as above: the default value achieves a resting CV of ~50mV. The + // default value is different from the one above, because some of the + // component values for the rest of the circuit differ. + PORT_START("trimmer_bass_freq_cv_offset") + PORT_ADJUSTER(29, "TRIMMER: BASS CUTOFF CV OFFSET") + PORT_CHANGED_MEMBER("linndrum_audio:bass_vcf_eg", FUNC(linndrum_vcf_eg_device::freq_cv_offset_changed), 0) INPUT_PORTS_END ROM_START(linndrum) @@ -1675,4 +1887,5 @@ ROM_END } // anonymous namespace +// Tagged as MACHINE_IMPERFECT_SOUND because of missing some checksums. SYST(1982, linndrum, 0, 0, linndrum, linndrum, linndrum_state, empty_init, "Linn Electronics", "LinnDrum", MACHINE_SUPPORTS_SAVE | MACHINE_IMPERFECT_SOUND)