From d11197c5488afcd5f8c7fde05987b13f9318c36c Mon Sep 17 00:00:00 2001 From: m1macrophage <168948267+m1macrophage@users.noreply.github.com> Date: Sat, 22 Mar 2025 08:09:55 -0700 Subject: [PATCH] linn/linndrum.cpp: Emulating hi-hat decay. (#13509) * Hi-hat decay knob is now working. * Open and closed hats have different decays. * Better default tuning for the snare. * Removed `strobe_` prefix from functions where it was redundant. Made it an argument in a function where it matters. --- src/mame/linn/linndrum.cpp | 212 ++++++++++++++++++++++++++++++++----- 1 file changed, 187 insertions(+), 25 deletions(-) diff --git a/src/mame/linn/linndrum.cpp b/src/mame/linn/linndrum.cpp index 47a69319ad7..0495c5e5601 100644 --- a/src/mame/linn/linndrum.cpp +++ b/src/mame/linn/linndrum.cpp @@ -63,11 +63,10 @@ Reasons for MACHINE_IMPERFECT_SOUND: * Missing a few sample checksums. * Missing bass drum LPF and filter envelope. * Missing snare / sidestick volume envelope. -* Missing hi-hat volume envelope (open and closed hats will sound the same. - Decay knob is inoperative). * Missing tom / conga LPF and filter envelope. * Inaccurate filter for "click". * Linear, instead of audio-taper volume sliders and master volume knob. +* Linear, instead of tanh response for hi-hat VCA. PCBoards: * CPU board. 2 sections in schematics: @@ -114,6 +113,7 @@ Example: #define LOG_TAPE_SYNC_ENABLE (1U << 6) #define LOG_MIX (1U << 7) #define LOG_PITCH (1U << 8) +#define LOG_HAT_VCA (1U << 9) #define VERBOSE (LOG_GENERAL) //#define LOG_OUTPUT_FUNC osd_printf_info @@ -182,15 +182,165 @@ enum mixer_channels } // anonymous namespace +// This device combines the CEM3360 and its envelope generator (EG) that process +// the hi-hat voice. +// TODO: Look into implementing the CEM3360 and a generic EG as devices under +// src/devices/sound. +class linndrum_hat_vca_device : public device_t, public device_sound_interface +{ +public: + linndrum_hat_vca_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock = 0) ATTR_COLD; + + void trigger(); + void set_open(bool open_hat); + +protected: + void device_add_mconfig(machine_config &config) override ATTR_COLD; + void device_start() override ATTR_COLD; + void device_reset() override ATTR_COLD; + void sound_stream_update(sound_stream &stream, std::vector const &inputs, std::vector &outputs) override; + +private: + static float get_cem3360_gain(float cv); + + TIMER_DEVICE_CALLBACK_MEMBER(trigger_timer_tick); + + static constexpr const float C22 = CAP_U(1); + static constexpr const float R33 = RES_M(1); + static constexpr const float R34 = RES_K(10); + static constexpr const float DECAY_POT_R_MAX = RES_K(100); + + sound_stream *m_stream = nullptr; + + required_ioport m_decay_pot; + required_device m_trigger_timer; // U37B (LM556). + + float m_rc_inv = 1.0F / (R33 * C22); + bool m_decaying = true; + bool m_decay_done = true; + attotime m_decay_start_time; +}; + +DEFINE_DEVICE_TYPE(LINNDRUM_HAT_VCA, linndrum_hat_vca_device, "linndrum_hat_vca", "LinnDrum CEM3360 VCA and EG"); + +linndrum_hat_vca_device::linndrum_hat_vca_device(const machine_config &mconfig, const char *tag, device_t *owner, uint32_t clock) + : device_t(mconfig, LINNDRUM_HAT_VCA, tag, owner, clock) + , device_sound_interface(mconfig, *this) + , m_decay_pot(*this, ":pot_tuning_7") + , m_trigger_timer(*this, "hat_trigger_timer") +{ +} + +void linndrum_hat_vca_device::trigger() +{ + m_stream->update(); + m_decaying = false; + m_decay_done = false; + m_trigger_timer->adjust(PERIOD_OF_555_MONOSTABLE(RES_K(510), CAP_U(0.01))); // R8, C4. + LOGMASKED(LOG_HAT_VCA, "Hat VCA trigerred.\n"); +} + +void linndrum_hat_vca_device::set_open(bool open_hat) +{ + // The envelope generator can run in two different modes. + // - Open hat: the capacitor is discharged through a 1M resistor. + // - Closed hat: U90 (CD4053 MUX) adds a parallel discharge path through the + // "hihat decay" knob. + m_stream->update(); + float r = R33; + if (!open_hat) + { + const float r_decay = DECAY_POT_R_MAX * m_decay_pot->read() / 100.0F; + r = RES_2_PARALLEL(R33, R34 + r_decay); + } + m_rc_inv = 1.0F / (r * C22); + LOGMASKED(LOG_HAT_VCA, "Hat decay. Open: %d, r: %g\n", open_hat, r); +} + +void linndrum_hat_vca_device::device_add_mconfig(machine_config &config) +{ + TIMER(config, m_trigger_timer).configure_generic(FUNC(linndrum_hat_vca_device::trigger_timer_tick)); +} + +void linndrum_hat_vca_device::device_start() +{ + m_stream = stream_alloc(1, 1, machine().sample_rate()); + save_item(NAME(m_rc_inv)); + save_item(NAME(m_decaying)); + save_item(NAME(m_decay_done)); + save_item(NAME(m_decay_start_time)); +} + +void linndrum_hat_vca_device::device_reset() +{ + set_open(false); +} + +void linndrum_hat_vca_device::sound_stream_update(sound_stream &stream, std::vector const &inputs, std::vector &outputs) +{ + static constexpr const float MIN_GAIN = 0.0001F; // A gain lower than this will be treated as 0. + static constexpr const float MAX_EG_CV = 5; + static constexpr const float CV_SCALE = RES_VOLTAGE_DIVIDER(RES_K(8.2), RES_K(10)); // R67, R66. + + assert(inputs.size() == 1 && outputs.size() == 1); + const read_stream_view &in = inputs[0]; + write_stream_view &out = outputs[0]; + + if (m_decay_done) + { + out.fill(0); + return; + } + + const int n = in.samples(); + if (!m_decaying) + { + const float gain = get_cem3360_gain(MAX_EG_CV * CV_SCALE); + for (int i = 0; i < n; ++i) + out.put(i, gain * in.get(i)); + return; + } + + attotime t = in.start_time() - m_decay_start_time; + assert(t >= attotime::from_double(0)); + float gain = 0; + for (int i = 0; i < n; ++i, t += in.sample_period()) + { + // TODO: The CEM3360 is based on an OTA, which means it likely has a + // tanh, rather than a linear response. But this needs more research. + const float decay = expf(-t.as_double() * m_rc_inv); + gain = get_cem3360_gain(decay * MAX_EG_CV * CV_SCALE); + out.put(i, gain * in.get(i)); + } + + if (gain < MIN_GAIN) + m_decay_done = true; +} + +float linndrum_hat_vca_device::get_cem3360_gain(float cv) +{ + // Typical linear CV for max gain, as reported on the CEM3360 datasheet. + static constexpr const float MAX_GAIN_CV = 1.93F; + return std::clamp(cv / MAX_GAIN_CV, 0, 1); +} + +TIMER_DEVICE_CALLBACK_MEMBER(linndrum_hat_vca_device::trigger_timer_tick) +{ + m_stream->update(); + m_decaying = true; + m_decay_done = false; + m_decay_start_time = machine().time(); + LOGMASKED(LOG_HAT_VCA, "Hat VCA started decay.\n"); +} class linndrum_audio_device : public device_t { public: linndrum_audio_device(const machine_config &mconfig, const char *tag, device_t *owner, u32 clock = 0) ATTR_COLD; - void strobe_mux_drum_w(int voice, u8 data); - void strobe_snare_w(u8 data); // Snare and sidestick. - void strobe_tom_w(u8 data); // Tom and conga. + 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 strobe_click_w(u8 data); void beep_w(int state); @@ -228,6 +378,7 @@ private: 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_hat_vca; std::array m_mux_counting = { false, false, false, false, false, false, false, false }; std::array m_mux_counters = { 0, 0, 0, 0, 0, 0, 0, 0 }; @@ -339,6 +490,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_hat_vca(*this, "hat_vca") , m_snare_samples(*this, ":sample_snare") , m_sidestick_samples(*this, ":sample_sidestick") , m_snare_timer(*this, "snare_sidestick_timer") @@ -366,7 +518,7 @@ linndrum_audio_device::linndrum_audio_device(const machine_config &mconfig, cons { } -void linndrum_audio_device::strobe_mux_drum_w(int voice, u8 data) +void linndrum_audio_device::mux_drum_w(int voice, u8 data, bool is_strobe) { assert(voice >= 0 && voice < NUM_MUX_VOICES); @@ -384,11 +536,18 @@ void linndrum_audio_device::strobe_mux_drum_w(int voice, u8 data) 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) + { + m_hat_vca->set_open(BIT(data, 2)); + if (is_strobe) + m_hat_vca->trigger(); + } + LOGMASKED(LOG_STROBES, "Strobed mux drum %s: %02x (gain: %f)\n", MUX_VOICE_NAMES[voice], data, m_mux_volume[voice]->gain()); } -void linndrum_audio_device::strobe_snare_w(u8 data) +void linndrum_audio_device::snare_w(u8 data) { m_snare_counting = BIT(data, 0); if (!m_snare_counting) @@ -435,7 +594,7 @@ void linndrum_audio_device::strobe_snare_w(u8 data) data, iref, gain); } -void linndrum_audio_device::strobe_tom_w(u8 data) +void linndrum_audio_device::tom_w(u8 data) { m_tom_counting = BIT(data, 0); if (!m_tom_counting) @@ -534,15 +693,18 @@ void linndrum_audio_device::device_add_mconfig(machine_config &config) for (int voice = 0; voice < NUM_MUX_VOICES; ++voice) { DAC76(config, m_mux_dac[voice], 0); // AM6070 (U88). - FILTER_VOLUME(config, m_mux_volume[voice]); // CD4053 (U90), R60, R62 (see strobe_mux_drum_w()). + FILTER_VOLUME(config, m_mux_volume[voice]); // CD4053 (U90), R60, R62 (see mux_drum_w()). m_mux_dac[voice]->add_route(0, m_mux_volume[voice], get_dac_scaler(MUX_DAC_IREF)); } + LINNDRUM_HAT_VCA(config, m_hat_vca); + m_mux_volume[MV_HAT]->add_route(0, m_hat_vca, 1.0); + // *** Snare / sidestick section. TIMER(config, m_snare_timer).configure_generic(FUNC(linndrum_audio_device::snare_timer_tick)); // 74LS627 (U80A). DAC76(config, m_snare_dac, 0); // AM6070 (U92) - FILTER_VOLUME(config, m_snare_volume); // See strobe_snare_w(). + FILTER_VOLUME(config, m_snare_volume); // See snare_w(). // DAC output scaling is incorporated in m_snare_volume's gain. m_snare_dac->add_route(0, m_snare_volume, 1.0); @@ -581,7 +743,7 @@ void linndrum_audio_device::device_add_mconfig(machine_config &config) m_mux_volume[MV_BASS], m_snare_out, m_sidestick_out, - m_mux_volume[MV_HAT], + m_hat_vca, m_tom_out[TV_HI_TOMS], m_tom_out[TV_MID_TOMS], m_tom_out[TV_LOW_TOMS], @@ -745,7 +907,7 @@ TIMER_DEVICE_CALLBACK_MEMBER(linndrum_audio_device::mux_timer_tick) if (m_mux_counters[voice] >= m_mux_samples[voice]->bytes()) { // All outputs in the voice's data latch (74LS74) are cleared. - strobe_mux_drum_w(voice, 0); + mux_drum_w(voice, 0, false); } } @@ -763,7 +925,7 @@ TIMER_DEVICE_CALLBACK_MEMBER(linndrum_audio_device::snare_timer_tick) if (BIT(m_snare_counter, 12)) // Counter reached 0x1000 (4096). { // All outputs of U41 and U42 (74LS74 flip-flops) are cleared. - strobe_snare_w(0); + snare_w(0); return; } @@ -789,7 +951,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. - strobe_tom_w(0); + tom_w(0); return; } @@ -1214,16 +1376,16 @@ void linndrum_state::memory_map(address_map &map) map(0x1f84, 0x1f84).mirror(0x0030).w("latch_u16", FUNC(output_latch_device::write)); // LEDs & outputs. // Voice strobes. - map(0x1f85, 0x1f85).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_BASS, get_voice_data(data)); })); - map(0x1f86, 0x1f86).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_snare_w(get_voice_data(data)); })); - map(0x1f87, 0x1f87).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_HAT, get_voice_data(data)); })); - map(0x1f88, 0x1f88).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_tom_w(get_voice_data(data)); })); - map(0x1f89, 0x1f89).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_RIDE, get_voice_data(data)); })); - map(0x1f8a, 0x1f8a).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_CRASH, get_voice_data(data)); })); - map(0x1f8b, 0x1f8b).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_CABASA, get_voice_data(data)); })); - map(0x1f8c, 0x1f8c).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_TAMBOURINE, get_voice_data(data)); })); - map(0x1f8d, 0x1f8d).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_COWBELL, get_voice_data(data)); })); - map(0x1f8e, 0x1f8e).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->strobe_mux_drum_w(MV_CLAP, get_voice_data(data)); })); + 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(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)); })); + map(0x1f8c, 0x1f8c).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_TAMBOURINE, get_voice_data(data)); })); + map(0x1f8d, 0x1f8d).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_COWBELL, get_voice_data(data)); })); + map(0x1f8e, 0x1f8e).mirror(0x0030).lw8(NAME([this] (u8 data) { m_audio->mux_drum_w(MV_CLAP, get_voice_data(data)); })); map(0x1f8f, 0x1f8f).mirror(0x0030).w(m_audio, FUNC(linndrum_audio_device::strobe_click_w)); // No voice data sent. map(0x1fc0, 0x1fff).r(FUNC(linndrum_state::keyboard_r)); // /READ KEYBD. @@ -1396,7 +1558,7 @@ INPUT_PORTS_START(linndrum) PORT_ADJUSTER(25, "TRIMMER: MUX DRUM TUNING") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::mux_drum_tuning_changed), 0) PORT_START("pot_tuning_1") - PORT_ADJUSTER(50, "SNARE TUNING") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::snare_tuning_changed), 0) + PORT_ADJUSTER(25, "SNARE TUNING") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::snare_tuning_changed), 0) PORT_START("pot_tuning_2") PORT_ADJUSTER(60, "HI TOM TUNING") PORT_CHANGED_MEMBER(AUDIO_TAG, FUNC(linndrum_audio_device::tom_tuning_changed), 0) PORT_START("pot_tuning_3")