From ac9ab7d12ebc3787d55acfb28f59239ceda4334f Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Sun, 17 May 2026 20:58:22 -0400 Subject: [PATCH 01/17] add DCS and new refactored CTCSS squelch. Include in json what we found. --- CMakeLists.txt | 2 + .../call_concluder/call_concluder.cc | 22 +- trunk-recorder/call_conventional.cc | 8 + trunk-recorder/call_conventional.h | 3 + trunk-recorder/global_structs.h | 84 ++++++++ trunk-recorder/recorders/analog_recorder.cc | 197 +++++++++++++++--- trunk-recorder/recorders/analog_recorder.h | 39 +++- trunk-recorder/setup_systems.cc | 19 +- trunk-recorder/source.cc | 18 +- trunk-recorder/source.h | 2 +- trunk-recorder/talkgroup.cc | 9 +- trunk-recorder/talkgroup.h | 7 +- trunk-recorder/talkgroups.cc | 19 +- 13 files changed, 361 insertions(+), 68 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 929c7efe6..314cb2513 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -289,6 +289,8 @@ list(APPEND trunk_recorder_sources #lib/gr-latency/latency_tagger.cc #lib/gr-latency-manager/lib/latency_manager_impl.cc #lib/gr-latency-manager/lib/tag_to_msg_impl.cc + new_gr_blocks/ctcss_squelch_ff.cc + new_gr_blocks/dcs_squelch_ff.cc trunk-recorder/gr_blocks/freq_xlating_fft_filter.cc trunk-recorder/gr_blocks/transmission_sink.cc trunk-recorder/gr_blocks/decoders/fsync_decode.cc diff --git a/trunk-recorder/call_concluder/call_concluder.cc b/trunk-recorder/call_concluder/call_concluder.cc index 577429b0f..08ec10668 100644 --- a/trunk-recorder/call_concluder/call_concluder.cc +++ b/trunk-recorder/call_concluder/call_concluder.cc @@ -1,5 +1,6 @@ #include "call_concluder.h" #include "../plugin_manager/plugin_manager.h" +#include "../recorders/analog_recorder.h" #include #include @@ -811,7 +812,10 @@ int create_call_json(Call_Data_t &call_info) { {"talkgroup_group", call_info.talkgroup_group}, {"color_code", call_info.color_code}, {"audio_type", call_info.audio_type}, - {"short_name", call_info.short_name} + {"short_name", call_info.short_name}, + {"tone_mode", call_info.tone_mode}, + {"tone_detected", call_info.tone_detected}, + {"tone_confidence", call_info.tone_confidence} }; if (call_info.patched_talkgroups.size() > 1) { @@ -1129,6 +1133,22 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf else if (call->get_phase2_tdma()) call_info.audio_type = "digital tdma"; else call_info.audio_type = "digital"; + // Pull the end-of-call tone verdict off the analog_recorder when present. + // Non-analog recorders never run the CTCSS/DCS sub-audible detectors, so + // these fields stay empty / "off" for trunked digital and conventional + // P25/DMR calls. + call_info.tone_mode = "off"; + call_info.tone_detected = ""; + call_info.tone_confidence = 0.0f; + if (Recorder *rec = call->get_recorder()) { + if (analog_recorder *arec = dynamic_cast(rec)) { + const auto tr = arec->get_tone_result(); + call_info.tone_mode = tr.mode; + call_info.tone_detected = tr.detected; + call_info.tone_confidence = tr.confidence; + } + } + const double min_tx_s = sys->get_min_tx_duration(); call_info.transmission_source_list.reserve(call_info.transmission_list.size()); diff --git a/trunk-recorder/call_conventional.cc b/trunk-recorder/call_conventional.cc index f1519d1c6..764a33de2 100644 --- a/trunk-recorder/call_conventional.cc +++ b/trunk-recorder/call_conventional.cc @@ -8,6 +8,14 @@ Call_conventional::Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection) : Call_impl(t, f, s, c) { this->squelch_db = squelch_db; this->signal_detection = signal_detection; + this->tone_config = Tone_Config{}; // TONE_OFF default + BOOST_LOG_TRIVIAL(info) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; +} + +Call_conventional::Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection, const Tone_Config &tone_config) : Call_impl(t, f, s, c) { + this->squelch_db = squelch_db; + this->signal_detection = signal_detection; + this->tone_config = tone_config; BOOST_LOG_TRIVIAL(info) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; } diff --git a/trunk-recorder/call_conventional.h b/trunk-recorder/call_conventional.h index 245a53ddd..2312af428 100644 --- a/trunk-recorder/call_conventional.h +++ b/trunk-recorder/call_conventional.h @@ -11,6 +11,7 @@ class Recorder; class Call_conventional : public Call_impl { public: Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection); + Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection, const Tone_Config &tone_config); time_t get_start_time(); bool is_conventional() { return true; } void restart_call(); @@ -18,9 +19,11 @@ class Call_conventional : public Call_impl { void recording_started(); double get_squelch_db(); bool get_signal_detection(); + const Tone_Config &get_tone_config() const { return tone_config; } private: double squelch_db; bool signal_detection; + Tone_Config tone_config; // TONE_OFF when channel was created without a Tone column }; #endif diff --git a/trunk-recorder/global_structs.h b/trunk-recorder/global_structs.h index e6952d596..908caf48e 100644 --- a/trunk-recorder/global_structs.h +++ b/trunk-recorder/global_structs.h @@ -2,12 +2,88 @@ #define GLOBAL_STRUCTS_H #include #include +#include +#include #include #include #include const int DB_UNSET = 999; +// Conventional-channel sub-audible squelch / identification mode. Drives +// what (if any) CTCSS or DCS block sits in the analog_recorder audio path +// and whether a side-chain detector also runs to identify the actual +// on-air tone for end-of-call reporting. +enum ToneMode { + TONE_OFF = 0, // no gating, no detection (legacy "Tone=0" channels) + TONE_CTCSS = 1, // gate on configured ctcss_hz; DCS detector runs side-chain + TONE_DCS = 2, // gate on configured dcs_code/inverted; CTCSS detector runs side-chain + TONE_SEARCH = 3 // no gate; both detectors run side-chain to identify on-air tone +}; + +struct Tone_Config { + ToneMode mode = TONE_OFF; + double ctcss_hz = 0.0; // valid only when mode == TONE_CTCSS + int dcs_code = 0; // valid only when mode == TONE_DCS (octal as decimal int) + bool dcs_inverted = false; // valid only when mode == TONE_DCS +}; + +// Parse a Tone column value into a Tone_Config. Accepted forms: +// "" or "0" / "0.0" -> TONE_OFF +// "S" / "s" -> TONE_SEARCH +// strict /D\d{3}[NI]/i -> TONE_DCS (3-digit octal, polarity required) +// any positive numeric (CTCSS Hz) -> TONE_CTCSS +// Anything else returns TONE_OFF with no warning here — the caller is +// expected to detect TONE_OFF when it was unintended and log. +inline Tone_Config parse_tone_spec(const std::string &raw) { + Tone_Config tc; // default OFF + if (raw.empty()) return tc; + + // Trim whitespace + size_t a = 0; + size_t b = raw.size(); + while (a < b && std::isspace(static_cast(raw[a]))) ++a; + while (b > a && std::isspace(static_cast(raw[b - 1]))) --b; + if (a == b) return tc; + const std::string s = raw.substr(a, b - a); + + // Search mode + if (s.size() == 1 && (s[0] == 'S' || s[0] == 's')) { + tc.mode = TONE_SEARCH; + return tc; + } + + // DCS: strict "D" + exactly 3 octal digits + 'N'/'n'/'I'/'i' + if ((s[0] == 'D' || s[0] == 'd') && s.size() == 5) { + bool ok = true; + for (int i = 1; i <= 3; ++i) { + char c = s[i]; + if (c < '0' || c > '7') { ok = false; break; } + } + char pol = s[4]; + if (ok && (pol == 'N' || pol == 'n' || pol == 'I' || pol == 'i')) { + tc.mode = TONE_DCS; + tc.dcs_code = ((s[1] - '0') * 100) + ((s[2] - '0') * 10) + (s[3] - '0'); + tc.dcs_inverted = (pol == 'I' || pol == 'i'); + return tc; + } + // Falls through to OFF below if malformed. + return tc; + } + + // CTCSS: any positive number. strtod tolerates ints and floats. + char *endp = nullptr; + const double v = std::strtod(s.c_str(), &endp); + if (endp != s.c_str() && *endp == '\0' && v > 0.0) { + tc.mode = TONE_CTCSS; + tc.ctcss_hz = v; + return tc; + } + + // Unrecognized — leave as OFF (caller can log if surprising). + return tc; +} + struct Transmission { long source; long talkgroup; @@ -174,6 +250,14 @@ struct Call_Data_t { std::vector transmission_error_list; std::vector transmission_list; + // Sub-audible squelch / identification verdict for analog conventional + // calls. Populated in Call_Concluder::create_call_data() from the + // analog_recorder; empty / "off" for digital and trunked calls. See + // ToneMode enum above for the configured-mode strings. + std::string tone_mode; // "off" | "ctcss" | "dcs" | "search" + std::string tone_detected; // "" | "173.8" | "D023N" | ... + float tone_confidence; // 0.0 (no lock) .. 1.0 (fully confident) + Call_Data_Status status; time_t process_call_time; int retry_attempt; diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index e18786e1e..b3f2cff3d 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -7,6 +7,8 @@ #include "../plugin_manager/plugin_manager.h" #include "../recorder_globals.h" +#include + using namespace std; bool analog_recorder::logging = false; @@ -38,11 +40,11 @@ std::vector design_filter(double interpolation, double deci) { } analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type) { - return gnuradio::get_initial_sptr(new analog_recorder(src, static_cast(nullptr), type, -1)); + return gnuradio::get_initial_sptr(new analog_recorder(src, static_cast(nullptr), type, Tone_Config{})); } -analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, float tone_freq) { - return gnuradio::get_initial_sptr(new analog_recorder(src, static_cast(nullptr), type, tone_freq)); +analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, const Tone_Config &tone_config) { + return gnuradio::get_initial_sptr(new analog_recorder(src, static_cast(nullptr), type, tone_config)); } void analog_recorder::set_tau(float tau) { @@ -83,7 +85,7 @@ void analog_recorder::calculate_iir_taps(float tau) { d_fbtaps[1] = -p1; } -analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type, float tone_freq) +analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type, const Tone_Config &tone_config) : gr::hier_block2("analog_recorder", gr::io_signature::make(1, 1, sizeof(gr_complex)), gr::io_signature::make(0, 0, sizeof(float))), @@ -109,13 +111,7 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type bool use_streaming = false; - if (tone_freq > 0) { - use_tone_squelch = true; - this->tone_freq = tone_freq; - } else { - use_tone_squelch = false; - this->tone_freq = 0; - } + this->tone_config = tone_config; if (config != NULL) { use_streaming = config->enable_audio_streaming; @@ -141,8 +137,39 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type // recording doesn't contain blank spaces between transmissions squelch_two = gr::analog::pwr_squelch_ff::make(-200, 0.01, 0, true); - if (use_tone_squelch) { - tone_squelch = gr::analog::ctcss_squelch_ff::make(system_channel_rate, this->tone_freq, 0.01, 0, 0, false); + // Sub-audible tone gating / identification blocks (new design). + // The audio chain below taps these in at the wav_sample_rate point (16 kHz) + // rather than the system_channel_rate point — CTCSS is 67-254 Hz and DCS + // is ~134 baud, both well within 16 kHz Nyquist, and the blocks decimate + // internally to ~1 kHz so running them at 16 kHz vs 96 kHz costs only a + // few extra taps in the internal anti-alias LPF. + switch (tone_config.mode) { + case TONE_OFF: + // No gate, no detector. Audio passes through unchanged from decim_audio. + break; + case TONE_CTCSS: + // CTCSS gates the audio path; DCS detector runs as a side-chain so we + // still spot a wrong-agency DCS keyup at end-of-call. + ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, static_cast(tone_config.ctcss_hz), true); + dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); + ctcss_block_in_path = true; + dcs_block_in_path = false; + break; + case TONE_DCS: + // DCS gates the audio path; CTCSS detector runs as a side-chain. + dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, tone_config.dcs_code, tone_config.dcs_inverted, true); + ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); + ctcss_block_in_path = false; + dcs_block_in_path = true; + break; + case TONE_SEARCH: + // No gating; both detectors run as side-chains so end-of-call can + // report whichever scored higher. + ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); + dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); + ctcss_block_in_path = false; + dcs_block_in_path = false; + break; } // k = quad_rate/(2*math.pi*max_dev) = 48k / (6.283185*5000) = 1.527 @@ -196,19 +223,45 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type low_f = gr::filter::fir_filter_fff::make(1, low_f_taps); - // using squelch + // Always: RF → channelized → demod → deemph → decim to 16 kHz audio. connect(self(), 0, prefilter, 0); connect(prefilter, 0, demod, 0); connect(demod, 0, deemph, 0); - if (use_tone_squelch) { - connect(deemph, 0, tone_squelch, 0); - connect(tone_squelch, 0, decim_audio, 0); + connect(deemph, 0, decim_audio, 0); + + // Audio gate. The tone block (if any) sits between decim_audio and the + // downstream branches so both the wav writer and the decoder_sink see the + // gated signal — matches the prior behaviour where ctcss_squelch_ff sat + // before decim_audio. The side-chain detector (if present) taps + // decim_audio directly so it can still see ungated audio and identify + // wrong-agency keyups even when the configured gate is closed. + if (ctcss_block_in_path) { + connect(decim_audio, 0, ctcss_block, 0); + connect(ctcss_block, 0, decoder_sink, 0); + connect(ctcss_block, 0, high_f, 0); + } else if (dcs_block_in_path) { + connect(decim_audio, 0, dcs_block, 0); + connect(dcs_block, 0, decoder_sink, 0); + connect(dcs_block, 0, high_f, 0); } else { - connect(deemph, 0, decim_audio, 0); + connect(decim_audio, 0, decoder_sink, 0); + connect(decim_audio, 0, high_f, 0); + } + + // Side-chain detect-only blocks. Their output is terminated in a + // null_sink so the flow graph is well-formed; only their internal + // get_verdict() state is consumed (at end-of-call by stop()). + if (ctcss_block && !ctcss_block_in_path) { + connect(decim_audio, 0, ctcss_block, 0); + ctcss_null_sink = gr::blocks::null_sink::make(sizeof(float)); + connect(ctcss_block, 0, ctcss_null_sink, 0); + } + if (dcs_block && !dcs_block_in_path) { + connect(decim_audio, 0, dcs_block, 0); + dcs_null_sink = gr::blocks::null_sink::make(sizeof(float)); + connect(dcs_block, 0, dcs_null_sink, 0); } - connect(decim_audio, 0, decoder_sink, 0); - connect(decim_audio, 0, high_f, 0); connect(high_f, 0, low_f, 0); connect(low_f, 0, squelch_two, 0); connect(squelch_two, 0, levels, 0); @@ -256,6 +309,74 @@ void analog_recorder::stop() { decoder_sink->set_fsync_enabled(false); decoder_sink->set_star_enabled(false); decoder_sink->set_tps_enabled(false); + + // ----- Tone identification verdict ------------------------------------ + // Pull verdicts from whichever new sub-audible blocks ran and pick the + // higher-confidence one. The configured (gated) block has the strongest + // claim when both fire; in TONE_SEARCH the higher confidence wins. + tone_result = Tone_Result{}; + switch (tone_config.mode) { + case TONE_OFF: tone_result.mode = "off"; break; + case TONE_CTCSS: tone_result.mode = "ctcss"; break; + case TONE_DCS: tone_result.mode = "dcs"; break; + case TONE_SEARCH: tone_result.mode = "search"; break; + } + + float ctcss_conf = 0.0f; + float dcs_conf = 0.0f; + std::string ctcss_det, dcs_det; + + if (ctcss_block) { + auto v = ctcss_block->get_verdict(); + if (v.detected_hz > 0.0f && v.confidence > 0.0f) { + char buf[16]; + // One decimal place matches scanner/CHIRP display convention for CTCSS. + snprintf(buf, sizeof(buf), "%.1f", v.detected_hz); + ctcss_det = buf; + ctcss_conf = v.confidence; + } + } + if (dcs_block) { + auto v = dcs_block->get_verdict(); + if (v.detected_code != 0 && v.confidence > 0.0f) { + char buf[16]; + snprintf(buf, sizeof(buf), "D%03d%c", v.detected_code, v.detected_inverted ? 'I' : 'N'); + dcs_det = buf; + dcs_conf = v.confidence; + } + } + + // Pick the verdict to report. Prefer the configured-gate block when both + // fire (operator's expectation: a CTCSS-configured channel should show + // CTCSS unless DCS clearly won the call). For SEARCH mode it's purely + // higher confidence. + if (tone_config.mode == TONE_CTCSS) { + if (!ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } + else if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } + } else if (tone_config.mode == TONE_DCS) { + if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } + else if (!ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } + } else if (tone_config.mode == TONE_SEARCH) { + if (ctcss_conf >= dcs_conf && !ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } + else if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } + } + // TONE_OFF leaves tone_result.detected empty. + + if (tone_config.mode != TONE_OFF) { + std::string loghdr; + if (call != NULL) { + loghdr = log_header(call->get_short_name(), call->get_call_num(), call->get_talkgroup_display(), chan_freq); + } else { + loghdr = "[?]\t\t"; + } + if (!tone_result.detected.empty()) { + BOOST_LOG_TRIVIAL(info) << loghdr << "Tone Result: " << tone_result.detected + << " (mode=" << tone_result.mode + << " conf=" << std::fixed << std::setprecision(2) << tone_result.confidence << ")"; + } else { + BOOST_LOG_TRIVIAL(info) << loghdr << "Tone Result: none (mode=" << tone_result.mode << ")"; + } + } } void analog_recorder::process_message_queues() { @@ -283,7 +404,17 @@ void analog_recorder::set_enabled(bool enabled) { } bool analog_recorder::is_squelched() { - return prefilter->is_squelched(); + // Combine the RF power squelch (prefilter) with whichever sub-audible gate + // (CTCSS or DCS) is sitting in the audio path. The recorder counts as + // "squelched" when either gate is closed — RF gone, OR carrier present but + // configured tone not detected. Side-chain detect-only blocks don't gate + // audio so they are intentionally ignored here. In TONE_OFF / TONE_SEARCH + // neither block is in the path, so behaviour matches the old prefilter- + // only semantics. + if (prefilter->is_squelched()) return true; + if (ctcss_block_in_path && ctcss_block && !ctcss_block->is_unmuted()) return true; + if (dcs_block_in_path && dcs_block && !dcs_block->is_unmuted()) return true; + return false; } double analog_recorder::get_pwr() { @@ -291,10 +422,18 @@ double analog_recorder::get_pwr() { } bool analog_recorder::is_idle() { - if (state == ACTIVE) { - return prefilter->is_squelched(); - } - return true; + // Same combined semantics as is_squelched: a configured-gate tone block + // counts as a closer. Without this, a wrong-tone keyup (RF present, tone + // wrong → audio gated to zeros and dropped at squelch_two) would never + // increment manage_conventional_call's idle_count and the call would + // sit open until RF eventually dropped. Pre-existing behaviour did the + // same thing because the stock ctcss_squelch_ff didn't expose its + // muted state to the recorder either. + if (state != ACTIVE) return true; + if (prefilter->is_squelched()) return true; + if (ctcss_block_in_path && ctcss_block && !ctcss_block->is_unmuted()) return true; + if (dcs_block_in_path && dcs_block && !dcs_block->is_unmuted()) return true; + return false; } long analog_recorder::get_talkgroup() { @@ -367,6 +506,14 @@ bool analog_recorder::start(Call *call) { setup_decoders_for_system(call->get_system()); + // Reset sub-audible block state per transmission so the verdict reflects + // only this call. Both blocks (when instantiated) maintain cumulative + // scoring across the audio they see; without reset() a quiet call after + // a confidently-identified earlier one would still report the prior tone. + if (ctcss_block) ctcss_block->reset(); + if (dcs_block) dcs_block->reset(); + tone_result = Tone_Result{}; + talkgroup = call->get_talkgroup(); chan_freq = call->get_freq(); diff --git a/trunk-recorder/recorders/analog_recorder.h b/trunk-recorder/recorders/analog_recorder.h index 833903ed1..1d1f0ac0f 100644 --- a/trunk-recorder/recorders/analog_recorder.h +++ b/trunk-recorder/recorders/analog_recorder.h @@ -21,6 +21,7 @@ #include #include +#include #if GNURADIO_VERSION < 0x030800 #include @@ -33,7 +34,6 @@ #include #include -#include #include #include #include @@ -45,6 +45,8 @@ class Source; class analog_recorder; +#include "../../new_gr_blocks/ctcss_squelch_ff.h" +#include "../../new_gr_blocks/dcs_squelch_ff.h" #include "../gr_blocks/channelizer.h" #include "../gr_blocks/decoder_wrapper.h" #include "../gr_blocks/freq_xlating_fft_filter.h" @@ -66,13 +68,13 @@ typedef std::shared_ptr analog_recorder_sptr; int plugman_signal(long unitId, const char *signaling_type, gr::blocks::SignalType sig_type, Call *call, System *system, Recorder *recorder); analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type); -analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, float tone_freq); +analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, const Tone_Config &tone_config); class analog_recorder : public gr::hier_block2, public Recorder { friend analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type); - friend analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, float tone_freq); + friend analog_recorder_sptr make_analog_recorder(Source *src, Recorder_Type type, const Tone_Config &tone_config); protected: - analog_recorder(Source *src, System *system, Recorder_Type type, float tone_freq); + analog_recorder(Source *src, System *system, Recorder_Type type, const Tone_Config &tone_config); public: ~analog_recorder(); @@ -108,11 +110,22 @@ class analog_recorder : public gr::hier_block2, public Recorder { void set_tau(float tau); float get_tau() const; + // Result of the end-of-call tone identification pass. Populated in stop() + // by polling get_verdict() on whichever new ctcss_squelch_ff / dcs_squelch_ff + // blocks ran (configured-gate, side-chain, or search mode). Empty when + // the channel was Tone=0 / OFF mode and no detector ran. + struct Tone_Result { + std::string mode; // "off" | "ctcss" | "dcs" | "search" (configured) + std::string detected; // "" | "173.8" | "D023N" | etc. + float confidence = 0.0f; + }; + Tone_Result get_tone_result() const { return tone_result; } + private: double center_freq, chan_freq; long talkgroup; long input_rate; - float tone_freq; + Tone_Config tone_config; // copy of the channel's tone configuration double system_channel_rate; double initial_rate; float quad_gain; @@ -120,7 +133,7 @@ class analog_recorder : public gr::hier_block2, public Recorder { double squelch_db; time_t timestamp; time_t starttime; - bool use_tone_squelch; + Tone_Result tone_result; State state; std::vector channel_lpf_taps; @@ -153,7 +166,19 @@ class analog_recorder : public gr::hier_block2, public Recorder { gr::filter::fir_filter_fff::sptr high_f; gr::filter::fir_filter_fff::sptr low_f; gr::analog::pwr_squelch_ff::sptr squelch_two; - gr::analog::ctcss_squelch_ff::sptr tone_squelch; + + // Sub-audible squelch / identification blocks. At most one acts as a gate + // (sits in the audio path between decim_audio and high_f); the other + // optionally runs as a side-chain detector terminated in a null_sink. + // In SEARCH mode neither gates and both run as side-chains; in OFF mode + // none are instantiated. See analog_recorder.cc constructor for the + // four-way wiring decision. + gr::blocks::ctcss_squelch_ff::sptr ctcss_block; + gr::blocks::dcs_squelch_ff::sptr dcs_block; + gr::blocks::null_sink::sptr ctcss_null_sink; + gr::blocks::null_sink::sptr dcs_null_sink; + bool ctcss_block_in_path = false; // true when ctcss_block is the main-path gate + bool dcs_block_in_path = false; // true when dcs_block is the main-path gate gr::analog::quadrature_demod_cf::sptr demod; gr::blocks::float_to_short::sptr converter; diff --git a/trunk-recorder/setup_systems.cc b/trunk-recorder/setup_systems.cc index 256dbb4f8..f15f898e3 100644 --- a/trunk-recorder/setup_systems.cc +++ b/trunk-recorder/setup_systems.cc @@ -3,7 +3,7 @@ using namespace std; bool setup_conventional_channel(System *system, double frequency, long channel_index, Config &config, gr::top_block_sptr &tb, std::vector &sources, std::vector &calls) { bool channel_added = false; Source *source = NULL; - float tone_freq = 0.0; + Tone_Config tone_config; // defaults to TONE_OFF when no channel file or no Tone column for (std::vector::iterator src_it = sources.begin(); src_it != sources.end(); src_it++) { source = *src_it; @@ -19,15 +19,12 @@ bool setup_conventional_channel(System *system, double frequency, long channel_i Call_conventional *call = NULL; if (system->has_channel_file()) { Talkgroup *tg = system->find_talkgroup_by_freq(frequency); - tone_freq = tg->tone; + tone_config = tg->tone_config; // If there is a per channel squelch setting, use it, otherwise use the system squelch setting - if (tg->squelch_db != DB_UNSET) { - call = new Call_conventional(tg->number, tg->freq, system, config, tg->squelch_db, tg->signal_detection); - } else { - call = new Call_conventional(tg->number, tg->freq, system, config, system->get_squelch_db(), tg->signal_detection); - } - + const double effective_squelch = (tg->squelch_db != DB_UNSET) ? tg->squelch_db : system->get_squelch_db(); + call = new Call_conventional(tg->number, tg->freq, system, config, effective_squelch, tg->signal_detection, tone_config); + call->set_talkgroup_tag(tg->alpha_tag); } else { call = new Call_conventional(channel_index, frequency, system, config, system->get_squelch_db(), true); // signal detection is always true when a channel file is not used @@ -36,11 +33,7 @@ bool setup_conventional_channel(System *system, double frequency, long channel_i BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " << system->get_system_type() << " channel: " << format_freq(frequency) << " Talkgroup: " << channel_index; if (system->get_system_type() == "conventional") { analog_recorder_sptr rec; - if (tone_freq > 0.0) { - rec = source->create_conventional_recorder(tb, tone_freq); - } else { - rec = source->create_conventional_recorder(tb); - } + rec = source->create_conventional_recorder(tb, tone_config); rec->start(call); rec->set_tau(system->get_tau()); //set the tau value for the recorder from the system config call->set_is_analog(true); diff --git a/trunk-recorder/source.cc b/trunk-recorder/source.cc index db2b5106a..6a7235f40 100644 --- a/trunk-recorder/source.cc +++ b/trunk-recorder/source.cc @@ -540,13 +540,13 @@ void Source::create_sigmf_recorders(gr::top_block_sptr tb, int r) { } } -analog_recorder_sptr Source::create_conventional_recorder(gr::top_block_sptr tb, float tone_freq) { +analog_recorder_sptr Source::create_conventional_recorder(gr::top_block_sptr tb, const Tone_Config &tone_config) { // Not adding it to the vector of analog_recorders. We don't want it to be available for trunk recording. // Conventional recorders are tracked seperately in analog_conv_recorders attach_detector(tb); attach_selector(tb); - analog_recorder_sptr log = make_analog_recorder(this, ANALOGC, tone_freq); + analog_recorder_sptr log = make_analog_recorder(this, ANALOGC, tone_config); analog_conv_recorders.push_back(log); log->set_selector_port(next_selector_port); tb->connect(recorder_selector, next_selector_port, log, 0); @@ -555,17 +555,9 @@ analog_recorder_sptr Source::create_conventional_recorder(gr::top_block_sptr tb, } analog_recorder_sptr Source::create_conventional_recorder(gr::top_block_sptr tb) { - // Not adding it to the vector of analog_recorders. We don't want it to be available for trunk recording. - // Conventional recorders are tracked seperately in analog_conv_recorders - attach_detector(tb); - attach_selector(tb); - - analog_recorder_sptr log = make_analog_recorder(this, ANALOGC); - analog_conv_recorders.push_back(log); - log->set_selector_port(next_selector_port); - tb->connect(recorder_selector, next_selector_port, log, 0); - next_selector_port++; - return log; + // Backwards-compat helper for channel-less conventional setups (no Tone column). + // Defaults to TONE_OFF: no CTCSS/DCS block in the chain. + return create_conventional_recorder(tb, Tone_Config{}); } sigmf_recorder_sptr Source::create_sigmf_conventional_recorder(gr::top_block_sptr tb) { // Not adding it to the vector of digital_recorders. We don't want it to be available for trunk recording. diff --git a/trunk-recorder/source.h b/trunk-recorder/source.h index 7fd43e7f1..f901edcdc 100644 --- a/trunk-recorder/source.h +++ b/trunk-recorder/source.h @@ -141,7 +141,7 @@ class Source { void create_digital_recorders(gr::top_block_sptr tb, int r); analog_recorder_sptr create_conventional_recorder(gr::top_block_sptr tb); - analog_recorder_sptr create_conventional_recorder(gr::top_block_sptr tb, float tone_freq); + analog_recorder_sptr create_conventional_recorder(gr::top_block_sptr tb, const Tone_Config &tone_config); sigmf_recorder_sptr create_sigmf_conventional_recorder(gr::top_block_sptr tb); p25_recorder_sptr create_digital_conventional_recorder(gr::top_block_sptr tb); dmr_recorder_sptr create_dmr_conventional_recorder(gr::top_block_sptr tb); diff --git a/trunk-recorder/talkgroup.cc b/trunk-recorder/talkgroup.cc index d908264dd..8601d1ef0 100644 --- a/trunk-recorder/talkgroup.cc +++ b/trunk-recorder/talkgroup.cc @@ -15,12 +15,13 @@ Talkgroup::Talkgroup(int sys_num, long num, std::string mode, std::string alpha_ // This talkgroup is for a Trunked system and freq and tone are not used this->freq = 0; this->tone = 0; + this->tone_config = Tone_Config{}; // TONE_OFF this->squelch_db = DB_UNSET; this->signal_detection = false; } -Talkgroup::Talkgroup(int sys_num, long num, double freq, double tone, std::string alpha_tag, std::string description, std::string tag, std::string group, double squelch_db, bool signal_detection) { +Talkgroup::Talkgroup(int sys_num, long num, double freq, const Tone_Config &tone_config, std::string alpha_tag, std::string description, std::string tag, std::string group, double squelch_db, bool signal_detection) { this->sys_num = sys_num; this->number = num; this->mode = "Z"; @@ -30,7 +31,11 @@ Talkgroup::Talkgroup(int sys_num, long num, double freq, double tone, std::strin this->group = group; this->active = false; this->freq = freq; - this->tone = tone; + this->tone_config = tone_config; + // Keep the legacy `tone` field populated for CTCSS so any downstream + // consumer still reading it as a Hz value (logs, plugins, etc.) keeps + // working. DCS / search modes leave it at 0. + this->tone = (tone_config.mode == TONE_CTCSS) ? tone_config.ctcss_hz : 0.0; this->squelch_db = squelch_db; this->signal_detection = signal_detection; diff --git a/trunk-recorder/talkgroup.h b/trunk-recorder/talkgroup.h index ceca81f9c..58bbe6941 100644 --- a/trunk-recorder/talkgroup.h +++ b/trunk-recorder/talkgroup.h @@ -23,10 +23,13 @@ class Talkgroup { // For Conventional double freq; - double tone; + double tone; // legacy: CTCSS Hz, kept for backwards compatibility / display. + // tone_config below is the authoritative source for the analog + // recorder's gating + detection wiring. + Tone_Config tone_config; Talkgroup(int sys_num, long num, std::string mode, std::string alpha_tag, std::string description, std::string tag, std::string group, int priority, unsigned long preferredNAC); - Talkgroup(int sys_num, long num, double freq, double tone, std::string alpha_tag, std::string description, std::string tag, std::string group, double squelch_db, bool signal_detection); + Talkgroup(int sys_num, long num, double freq, const Tone_Config &tone_config, std::string alpha_tag, std::string description, std::string tag, std::string group, double squelch_db, bool signal_detection); bool is_active(); int get_priority(); diff --git a/trunk-recorder/talkgroups.cc b/trunk-recorder/talkgroups.cc index 5b8537efb..bd493c88c 100644 --- a/trunk-recorder/talkgroups.cc +++ b/trunk-recorder/talkgroups.cc @@ -158,7 +158,8 @@ void Talkgroups::load_channels(int sys_num, std::string filename) { double squelch_db = DB_UNSET; bool signal_detector = true; double freq = 0; - double tone = 0; + Tone_Config tone_config; // defaults to TONE_OFF + std::string tone_raw; bool enable = true; if ((reader.index_of("TG Number") >= 0) && !row["TG Number"].is_null() && row["TG Number"].is_int()) { @@ -184,8 +185,18 @@ void Talkgroups::load_channels(int sys_num, std::string filename) { group = row["Category"].get(); } - if ((reader.index_of("Tone") >= 0) && row["Tone"].is_float()) { - tone = row["Tone"].get(); + if (reader.index_of("Tone") >= 0) { + // Tone is parsed as a string so we can accept CTCSS Hz (e.g. "188.8"), + // DCS codes (e.g. "D023N" / "D032I"), the search sentinel ("S"), or + // legacy 0 / empty. parse_tone_spec() handles all forms; a malformed + // value falls through to TONE_OFF and we log so the operator notices. + tone_raw = row["Tone"].get(); + tone_config = parse_tone_spec(tone_raw); + if (tone_config.mode == TONE_OFF && !tone_raw.empty() && tone_raw != "0" && tone_raw != "0.0") { + BOOST_LOG_TRIVIAL(warning) << "Unrecognized Tone value '" << tone_raw + << "' for TG " << tg_number + << " — treating as Tone=0 (no gate, no detection)"; + } } if ((reader.index_of("Frequency") >= 0) && row["Frequency"].is_num()) { @@ -213,7 +224,7 @@ void Talkgroups::load_channels(int sys_num, std::string filename) { } } if (enable) { - tg = new Talkgroup(sys_num, tg_number, freq, tone, alpha_tag, description, tag, group, squelch_db, signal_detector); + tg = new Talkgroup(sys_num, tg_number, freq, tone_config, alpha_tag, description, tag, group, squelch_db, signal_detector); talkgroups.push_back(tg); lines_pushed++; } From 7a739ae7ff1b3d2ebfdb3c1ac14e17f9f2ef3f15 Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 15:01:57 -0400 Subject: [PATCH 02/17] add DCS and new refactored CTCSS squelch. Include in json what we found. Make it so you can listen to multiple CTCSS or DPL tones on a single channel. Add search mode S in Tone -> Search for CTCSS or DPL tone on that channel Tone = 0 -> Do nothing no gate --- .../call_concluder/call_concluder.cc | 45 ++++ trunk-recorder/call_conventional.cc | 50 ++++ trunk-recorder/call_conventional.h | 22 ++ trunk-recorder/global_structs.h | 29 ++ trunk-recorder/gr_blocks/xlat_channelizer.cc | 12 + trunk-recorder/recorders/analog_recorder.cc | 50 +++- trunk-recorder/setup_systems.cc | 248 ++++++++++++------ 7 files changed, 377 insertions(+), 79 deletions(-) diff --git a/trunk-recorder/call_concluder/call_concluder.cc b/trunk-recorder/call_concluder/call_concluder.cc index 08ec10668..b381b0ba7 100644 --- a/trunk-recorder/call_concluder/call_concluder.cc +++ b/trunk-recorder/call_concluder/call_concluder.cc @@ -1149,6 +1149,34 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf } } + // Multi-row freq-group routing. When the channel CSV had several rows at + // this frequency, the recorder ran in TONE_SEARCH mode and captured + // whatever was on air; here we look up which row's metadata to use by + // matching the detected tone against each row's tone_config. If a row + // matches (including any catch-all Tone=0 / Tone=S row in the group), we + // overwrite the call_info metadata with its values. If NOTHING matches, + // mark the call SKIPPED so conclude_call() can drop it without firing + // plugins (see the discard path that follows). + if (Call_conventional *cc = dynamic_cast(call)) { + if (cc->has_alternate_channels()) { + Talkgroup *matched = cc->find_matching_channel(call_info.tone_detected); + if (matched != nullptr) { + call_info.talkgroup = matched->number; + call_info.talkgroup_tag = matched->tag; + call_info.talkgroup_alpha_tag = matched->alpha_tag; + call_info.talkgroup_description = matched->description; + call_info.talkgroup_group = matched->group; + // Log lines after this point still use call_info.talkgroup_display + // (set earlier from the Call's primary display), so the + // "Concluding Recorded Call" header may show the primary's TG + // even though the JSON metadata is the matched row's. Acceptable + // trade for not mutating the live Call object mid-conclude. + } else { + call_info.tone_skipped = true; + } + } + } + const double min_tx_s = sys->get_min_tx_duration(); call_info.transmission_source_list.reserve(call_info.transmission_list.size()); @@ -1261,6 +1289,23 @@ void Call_Concluder::conclude_call(Call *call, System *sys, const Config &config return; } + // Tone-routing SKIPPED: multi-row freq-group call whose detected tone + // didn't match any configured row (and no catch-all Tone=0 / Tone=S row + // was present). Concludes the call cleanly — recorder state cleans up + // via the standard path — but drops wav/m4a/json without firing plugins. + // Mirrors the SUPERSEDED handling above. + if (call_info.tone_skipped) { + BOOST_LOG_TRIVIAL(info) << loghdr + << "\033[0;33mSKIPPED\033[0m — detected tone '" + << (call_info.tone_detected.empty() ? "none" : call_info.tone_detected) + << "' (mode=" << call_info.tone_mode + << " conf=" << std::fixed << std::setprecision(2) + << call_info.tone_confidence << ")" + << " does not match any configured row at this freq; removing files, no plugins"; + remove_call_files(call_info); + return; + } + if (call_info.encrypted) { if (!call_info.transmission_list.empty() || call_info.min_transmissions_removed > 0) { if (create_call_json(call_info) < 0) diff --git a/trunk-recorder/call_conventional.cc b/trunk-recorder/call_conventional.cc index 764a33de2..0ba3efbb7 100644 --- a/trunk-recorder/call_conventional.cc +++ b/trunk-recorder/call_conventional.cc @@ -2,8 +2,10 @@ #include "call_conventional.h" #include "formatter.h" #include "recorders/recorder.h" +#include "talkgroup.h" #include #include +#include Call_conventional::Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection) : Call_impl(t, f, s, c) { this->squelch_db = squelch_db; @@ -69,4 +71,52 @@ double Call_conventional::get_squelch_db() { bool Call_conventional::get_signal_detection() { return signal_detection; +} + +Talkgroup *Call_conventional::find_matching_channel(const std::string &detected_tone) const { + if (alternate_channels.empty()) return nullptr; + + // Empty / no detected tone — only a Tone=0 / Tone=S catch-all row should + // claim it. (No-tone keyups on a freq with only explicit-tone rows are + // unmatched → SKIPPED.) + if (detected_tone.empty()) { + for (Talkgroup *tg : alternate_channels) { + if (tg->tone_config.mode == TONE_OFF || tg->tone_config.mode == TONE_SEARCH) { + return tg; + } + } + return nullptr; + } + + // Detected string may be a slash-joined alias list (e.g. "D565N/D703I" + // for cyclic-equivalent DCS codes in search mode). Split on '/' and try + // matching each piece against each alternate row's tone_config. Any + // matching row wins; if both halves of the alias list happen to match + // different rows, the first row in the group order wins (deterministic). + std::vector parts; + { + std::stringstream ss(detected_tone); + std::string piece; + while (std::getline(ss, piece, '/')) { + if (!piece.empty()) parts.push_back(piece); + } + } + for (Talkgroup *tg : alternate_channels) { + for (const std::string &p : parts) { + Tone_Config detected = parse_tone_spec(p); + if (detected.mode == TONE_OFF) continue; // parse failure / unknown tone token + if (tones_match(tg->tone_config, detected)) { + return tg; + } + } + } + + // No explicit-tone match — fall back to a catch-all row if the group + // has one. + for (Talkgroup *tg : alternate_channels) { + if (tg->tone_config.mode == TONE_OFF || tg->tone_config.mode == TONE_SEARCH) { + return tg; + } + } + return nullptr; } \ No newline at end of file diff --git a/trunk-recorder/call_conventional.h b/trunk-recorder/call_conventional.h index 2312af428..2ed896b28 100644 --- a/trunk-recorder/call_conventional.h +++ b/trunk-recorder/call_conventional.h @@ -3,10 +3,12 @@ #include "global_structs.h" class System; class Recorder; +class Talkgroup; #include "call.h" #include "call_impl.h" #include +#include class Call_conventional : public Call_impl { public: @@ -20,10 +22,30 @@ class Call_conventional : public Call_impl { double get_squelch_db(); bool get_signal_detection(); const Tone_Config &get_tone_config() const { return tone_config; } + + // Multi-row freq-group support. When the channel CSV has multiple rows at + // the same frequency, setup_systems creates ONE Call_conventional / + // analog_recorder for the group (squelch + signal-detect from row 0) and + // attaches the full row list here. At call-conclude time, the detected + // tone is matched against each row's tone_config; the matched row's + // metadata (TG number, alpha tag, description, etc.) overrides the + // call's defaults. If no row matches and no catch-all (Tone=0 / Tone=S) + // is present, the call is marked SKIPPED. Empty for single-row channels. + void set_alternate_channels(const std::vector &tgs) { alternate_channels = tgs; } + const std::vector &get_alternate_channels() const { return alternate_channels; } + bool has_alternate_channels() const { return alternate_channels.size() > 1; } + + // Match a detected-tone string (e.g. "118.8", "D023N", "D565N/D703I", or + // empty) against the alternate_channels list. Returns the matched + // Talkgroup* or nullptr if no row matches and no catch-all is present. + // Catch-all priority: an exact-tone row wins over a Tone=0 / Tone=S row. + Talkgroup *find_matching_channel(const std::string &detected_tone) const; + private: double squelch_db; bool signal_detection; Tone_Config tone_config; // TONE_OFF when channel was created without a Tone column + std::vector alternate_channels; }; #endif diff --git a/trunk-recorder/global_structs.h b/trunk-recorder/global_structs.h index 908caf48e..7779a3419 100644 --- a/trunk-recorder/global_structs.h +++ b/trunk-recorder/global_structs.h @@ -28,6 +28,27 @@ struct Tone_Config { bool dcs_inverted = false; // valid only when mode == TONE_DCS }; +// True iff two Tone_Configs match for routing purposes (same mode + same +// frequency/code/polarity within tolerance). Used by multi-row freq +// groups in setup_systems/call_conventional/call_concluder when a single +// recorder serves multiple logical channels and we need to map a detected +// tone to the right CSV row. +inline bool tones_match(const Tone_Config &a, const Tone_Config &b) { + if (a.mode != b.mode) return false; + switch (a.mode) { + case TONE_CTCSS: { + const double d = a.ctcss_hz - b.ctcss_hz; + return (d > -0.5 && d < 0.5); + } + case TONE_DCS: + return a.dcs_code == b.dcs_code && a.dcs_inverted == b.dcs_inverted; + case TONE_OFF: + case TONE_SEARCH: + return true; + } + return false; +} + // Parse a Tone column value into a Tone_Config. Accepted forms: // "" or "0" / "0.0" -> TONE_OFF // "S" / "s" -> TONE_SEARCH @@ -258,6 +279,14 @@ struct Call_Data_t { std::string tone_detected; // "" | "173.8" | "D023N" | ... float tone_confidence; // 0.0 (no lock) .. 1.0 (fully confident) + // SKIPPED — set when this call's freq has multiple CSV rows (a freq + // group) and the detector's tone verdict doesn't match any configured + // row's Tone column (and no catch-all Tone=0 / Tone=S row exists). The + // call is concluded (recorder state cleans up) but the wav/m4a/json + // are removed and no plugins fire. Mirrors the SUPERSEDED handling + // path in Call_Concluder::conclude_call. + bool tone_skipped = false; + Call_Data_Status status; time_t process_call_time; int retry_attempt; diff --git a/trunk-recorder/gr_blocks/xlat_channelizer.cc b/trunk-recorder/gr_blocks/xlat_channelizer.cc index 96fc1e822..62578ca4f 100644 --- a/trunk-recorder/gr_blocks/xlat_channelizer.cc +++ b/trunk-recorder/gr_blocks/xlat_channelizer.cc @@ -185,6 +185,18 @@ void xlat_channelizer::set_analog_squelch(bool analog_squelch) { squelch->set_alpha(0.01); squelch->set_ramp(10); squelch->set_gate(false); + // FLL is designed for digital symbol-rate carrier tracking; with its + // default loop bandwidth (~190 Hz) it actively tracks DCS-rate + // frequency deviations (134 baud → ~67 Hz fundamental) and cancels + // them out of the demod output. Voice (300-3000 Hz) is too fast for + // the loop to track so it's preserved either way, which is why this + // never showed up as a regression in conventional voice recording. + // For analog mode slow the loop ~1000× so DCS / CTCSS sub-audible + // signaling makes it through to the audio chain. Verified by capturing + // raw IQ vs the chain output: the same D023N transmission decodes + // cleanly off raw IQ but produces no DCS lock from the chain audio + // until this fix. + fll_band_edge->set_loop_bandwidth(2.0 * M_PI / d_samples_per_symbol / 250000.0); } else { squelch->set_alpha(0.0001); squelch->set_ramp(0); diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index b3f2cff3d..017658d01 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -339,9 +339,26 @@ void analog_recorder::stop() { if (dcs_block) { auto v = dcs_block->get_verdict(); if (v.detected_code != 0 && v.confidence > 0.0f) { - char buf[16]; - snprintf(buf, sizeof(buf), "D%03d%c", v.detected_code, v.detected_inverted ? 'I' : 'N'); - dcs_det = buf; + // In search mode the receiver genuinely can't tell apart cyclic-class + // members — e.g. D703I and D565N are literally the same shift-register + // pattern at different rotations on the air, so neither is "wrong" and + // we report all class members so the operator can see what the + // transmission could be. In configured mode the rewrite picked the + // configured form so we just print that single name. + if (tone_config.mode == TONE_SEARCH && v.aliases.size() > 1) { + std::string s; + for (const auto &kv : v.aliases) { + if (!s.empty()) s += "/"; + char b[8]; + snprintf(b, sizeof(b), "D%03d%c", kv.first, kv.second ? 'I' : 'N'); + s += b; + } + dcs_det = s; + } else { + char buf[16]; + snprintf(buf, sizeof(buf), "D%03d%c", v.detected_code, v.detected_inverted ? 'I' : 'N'); + dcs_det = buf; + } dcs_conf = v.confidence; } } @@ -357,8 +374,31 @@ void analog_recorder::stop() { if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } else if (!ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } } else if (tone_config.mode == TONE_SEARCH) { - if (ctcss_conf >= dcs_conf && !ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } - else if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } + // Search-mode tiebreak: simple max-confidence comparison. With the + // phase-diversity DCS confidence now in place, a CTCSS-aliased false + // DCS lock scores ~0.05-0.15 while a real DCS lock scores ~0.9-1.0; + // a CTCSS detector lock with all four verdict guards passed scores + // ~0.85-0.95. So whichever returns the higher confidence is genuinely + // the better identification — no need for a CTCSS priority override + // (an earlier version had one, which incorrectly forced CTCSS to win + // even when a real DCS keyup was correctly identified at conf 1.0). + const bool have_ctcss = !ctcss_det.empty(); + const bool have_dcs = !dcs_det.empty(); + if (have_ctcss && have_dcs) { + if (ctcss_conf >= dcs_conf) { + tone_result.detected = ctcss_det; + tone_result.confidence = ctcss_conf; + } else { + tone_result.detected = dcs_det; + tone_result.confidence = dcs_conf; + } + } else if (have_ctcss) { + tone_result.detected = ctcss_det; + tone_result.confidence = ctcss_conf; + } else if (have_dcs) { + tone_result.detected = dcs_det; + tone_result.confidence = dcs_conf; + } } // TONE_OFF leaves tone_result.detected empty. diff --git a/trunk-recorder/setup_systems.cc b/trunk-recorder/setup_systems.cc index f15f898e3..6ab1e2c4e 100644 --- a/trunk-recorder/setup_systems.cc +++ b/trunk-recorder/setup_systems.cc @@ -1,96 +1,196 @@ #include "./setup_systems.h" +#include "talkgroup.h" +#include using namespace std; + +// Common backend: given a chosen Call_conventional, its tone_config for the +// recorder, and a covering Source, build the per-system-type recorder and +// register it. Both the no-channel-file path (setup_conventional_channel) +// and the channel-file path (setup_conventional_channel_group) end up here. +static void build_recorder_for_call( + System *system, + Call_conventional *call, + const Tone_Config &recorder_tone_config, + Source *source, + Config & /*config*/, + gr::top_block_sptr &tb, + std::vector &calls) { + if (system->get_system_type() == "conventional") { + analog_recorder_sptr rec; + rec = source->create_conventional_recorder(tb, recorder_tone_config); + rec->start(call); + rec->set_tau(system->get_tau()); // set the tau value for the recorder from the system config + call->set_is_analog(true); + call->set_recorder((Recorder *)rec.get()); + call->set_state(RECORDING); + system->add_conventional_recorder(rec); + calls.push_back(call); + plugman_setup_recorder((Recorder *)rec.get()); + plugman_call_start(call); + } else if (system->get_system_type() == "conventionalDMR") { + // Because of dynamic mod assignment we can not start the recorder until the graph has been unlocked. + // This has something to do with the way the Selector block works. + // the manage_conventional_calls() function handles adding and starting the P25 Recorder + dmr_recorder_sptr rec; + rec = source->create_dmr_conventional_recorder(tb); + call->set_recorder((Recorder *)rec.get()); + system->add_conventionalDMR_recorder(rec); + calls.push_back(call); + } else if (system->get_system_type() == "conventionalP25") { // has to be "conventional P25" + // Because of dynamic mod assignment we can not start the recorder until the graph has been unlocked. + // This has something to do with the way the Selector block works. + // the manage_conventional_calls() function handles adding and starting the P25 Recorder + p25_recorder_sptr rec; + rec = source->create_digital_conventional_recorder(tb); + call->set_recorder((Recorder *)rec.get()); + system->add_conventionalP25_recorder(rec); + calls.push_back(call); + } else if (system->get_system_type() == "conventionalSIGMF") { + sigmf_recorder_sptr rec; + rec = source->create_sigmf_conventional_recorder(tb); + call->set_recorder((Recorder *)rec.get()); + system->add_conventionalSIGMF_recorder(rec); + calls.push_back(call); + } else { + BOOST_LOG_TRIVIAL(error) << "Error - Unknown system type: " << system->get_system_type(); + } +} + +// Find the first Source whose passband covers `frequency`, or nullptr. +static Source *find_source_covering(double frequency, std::vector &sources) { + for (Source *source : sources) { + if (source->get_min_hz() <= frequency && source->get_max_hz() >= frequency) { + return source; + } + } + return nullptr; +} + +// No-channel-file path: a single frequency, no Talkgroup metadata, no Tone +// column. Used when the system config has a "channels": [...] list instead +// of a "channelFile". bool setup_conventional_channel(System *system, double frequency, long channel_index, Config &config, gr::top_block_sptr &tb, std::vector &sources, std::vector &calls) { - bool channel_added = false; - Source *source = NULL; - Tone_Config tone_config; // defaults to TONE_OFF when no channel file or no Tone column - for (std::vector::iterator src_it = sources.begin(); src_it != sources.end(); src_it++) { - source = *src_it; - - if ((source->get_min_hz() <= frequency) && (source->get_max_hz() >= frequency)) { - channel_added = true; - if (system->get_squelch_db() == -160) { - BOOST_LOG_TRIVIAL(error) << "[" << system->get_short_name() << "]\tSquelch needs to be specified for the Source for Conventional Systems"; - return false; - } else { - channel_added = true; - } + Source *source = find_source_covering(frequency, sources); + if (!source) return false; - Call_conventional *call = NULL; - if (system->has_channel_file()) { - Talkgroup *tg = system->find_talkgroup_by_freq(frequency); - tone_config = tg->tone_config; + if (system->get_squelch_db() == -160) { + BOOST_LOG_TRIVIAL(error) << "[" << system->get_short_name() + << "]\tSquelch needs to be specified for the Source for Conventional Systems"; + return false; + } - // If there is a per channel squelch setting, use it, otherwise use the system squelch setting - const double effective_squelch = (tg->squelch_db != DB_UNSET) ? tg->squelch_db : system->get_squelch_db(); - call = new Call_conventional(tg->number, tg->freq, system, config, effective_squelch, tg->signal_detection, tone_config); + // signal detection is always true when a channel file is not used + Call_conventional *call = new Call_conventional( + channel_index, frequency, system, config, system->get_squelch_db(), true); - call->set_talkgroup_tag(tg->alpha_tag); - } else { - call = new Call_conventional(channel_index, frequency, system, config, system->get_squelch_db(), true); // signal detection is always true when a channel file is not used - } + BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " + << system->get_system_type() << " channel: " + << format_freq(frequency) + << " Talkgroup: " << channel_index; + Tone_Config tc; // TONE_OFF + build_recorder_for_call(system, call, tc, source, config, tb, calls); + return true; +} - BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " << system->get_system_type() << " channel: " << format_freq(frequency) << " Talkgroup: " << channel_index; - if (system->get_system_type() == "conventional") { - analog_recorder_sptr rec; - rec = source->create_conventional_recorder(tb, tone_config); - rec->start(call); - rec->set_tau(system->get_tau()); //set the tau value for the recorder from the system config - call->set_is_analog(true); - call->set_recorder((Recorder *)rec.get()); - call->set_state(RECORDING); - system->add_conventional_recorder(rec); - calls.push_back(call); - plugman_setup_recorder((Recorder *)rec.get()); - plugman_call_start(call); - } else if (system->get_system_type() == "conventionalDMR") { - // Because of dynamic mod assignment we can not start the recorder until the graph has been unlocked. - // This has something to do with the way the Selector block works. - // the manage_conventional_calls() function handles adding and starting the P25 Recorder - dmr_recorder_sptr rec; - rec = source->create_dmr_conventional_recorder(tb); - call->set_recorder((Recorder *)rec.get()); - system->add_conventionalDMR_recorder(rec); - calls.push_back(call); - } else if (system->get_system_type() == "conventionalP25") { // has to be "conventional P25" - // Because of dynamic mod assignment we can not start the recorder until the graph has been unlocked. - // This has something to do with the way the Selector block works. - // the manage_conventional_calls() function handles adding and starting the P25 Recorder - p25_recorder_sptr rec; - rec = source->create_digital_conventional_recorder(tb); - call->set_recorder((Recorder *)rec.get()); - system->add_conventionalP25_recorder(rec); - calls.push_back(call); - } else if (system->get_system_type() == "conventionalSIGMF") { - sigmf_recorder_sptr rec; - rec = source->create_sigmf_conventional_recorder(tb); - call->set_recorder((Recorder *)rec.get()); - system->add_conventionalSIGMF_recorder(rec); - calls.push_back(call); - } else { - BOOST_LOG_TRIVIAL(error) << "Error - Unknown system type: " << system->get_system_type(); - } +// Channel-file path: one or more Talkgroup rows all at the same frequency. +// `group[0]` is the primary row (provides squelch + signal_detection). +// Multi-row groups force the recorder into TONE_SEARCH mode so every +// transmission reaches the audio chain; the actual tone-to-metadata routing +// is done in Call_Concluder::create_call_data using the alternate_channels +// list attached to the Call_conventional. Non-matching tones become SKIPPED +// at conclusion time (no plugins fire, files removed). +bool setup_conventional_channel_group(System *system, const std::vector &group, Config &config, gr::top_block_sptr &tb, std::vector &sources, std::vector &calls) { + if (group.empty()) return false; + Talkgroup *primary = group[0]; + const double frequency = primary->freq; + + Source *source = find_source_covering(frequency, sources); + if (!source) return false; + + if (system->get_squelch_db() == -160 && primary->squelch_db == DB_UNSET) { + BOOST_LOG_TRIVIAL(error) << "[" << system->get_short_name() + << "]\tSquelch needs to be specified for the Source for Conventional Systems"; + return false; + } - // break out of the for loop - break; + // Squelch + signal_detection from the primary (row 0). Per-row Squelch + // overrides the system-wide value when set. + const double effective_squelch = + (primary->squelch_db != DB_UNSET) ? primary->squelch_db : system->get_squelch_db(); + + // Recorder tone: + // • Single row → use that row's tone_config (today's existing behavior). + // • Multiple rows → TONE_SEARCH internally so every keyup is captured; + // the per-call match step decides which row to label it with (or + // marks it SKIPPED). This stops the previous behavior of spawning N + // recorders for N rows on the same freq. + Tone_Config recorder_tone = + (group.size() == 1) ? primary->tone_config + : Tone_Config{TONE_SEARCH, 0.0, 0, false}; + + Call_conventional *call = new Call_conventional( + primary->number, primary->freq, system, config, + effective_squelch, primary->signal_detection, recorder_tone); + call->set_talkgroup_tag(primary->alpha_tag); + + if (group.size() > 1) { + call->set_alternate_channels(group); + } + + if (group.size() == 1) { + BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " + << system->get_system_type() << " channel: " + << format_freq(frequency) + << " Talkgroup: " << primary->number; + } else { + BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " + << system->get_system_type() << " channel: " + << format_freq(frequency) + << " — freq group (" << group.size() << " rows, primary TG " + << primary->number << "); recorder in search mode, " + << "metadata routed by detected tone"; + for (Talkgroup *tg : group) { + const std::string tone_str = + (tg->tone_config.mode == TONE_CTCSS) ? (std::to_string(tg->tone_config.ctcss_hz) + " Hz") : + (tg->tone_config.mode == TONE_DCS) ? (std::string("D") + std::to_string(tg->tone_config.dcs_code) + (tg->tone_config.dcs_inverted ? "I" : "N")) : + (tg->tone_config.mode == TONE_SEARCH) ? std::string("S (catch-all)") : + std::string("0 (catch-all)"); + BOOST_LOG_TRIVIAL(info) << "\t TG " << tg->number + << " tone=" << tone_str + << " \"" << tg->alpha_tag << "\""; } } - return channel_added; + + build_recorder_for_call(system, call, recorder_tone, source, config, tb, calls); + return true; } bool setup_conventional_system(System *system, Config &config, gr::top_block_sptr &tb, std::vector &sources, std::vector &calls) { bool system_added = false; if (system->has_channel_file()) { + // Group talkgroups by frequency so we only spawn ONE analog_recorder per + // unique freq, no matter how many CSV rows the operator wrote for it. + // Preserve CSV order for the "primary" (row 0) selection — the first + // row at any given freq is the one whose Squelch + Signal Detector + // settings get used. std::vector talkgroups = system->get_talkgroups(); - for (vector::iterator tg_it = talkgroups.begin(); tg_it != talkgroups.end(); tg_it++) { - Talkgroup *tg = *tg_it; - - bool channel_added = setup_conventional_channel(system, tg->freq, tg->number, config, tb, sources, calls); + std::map> by_freq; + std::vector freq_order; + for (Talkgroup *tg : talkgroups) { + if (by_freq.find(tg->freq) == by_freq.end()) { + freq_order.push_back(tg->freq); + } + by_freq[tg->freq].push_back(tg); + } + for (double freq : freq_order) { + const std::vector &group = by_freq[freq]; + bool channel_added = setup_conventional_channel_group(system, group, config, tb, sources, calls); if (!channel_added) { - BOOST_LOG_TRIVIAL(error) << "[" << system->get_short_name() << "]\t Unable to find a source for this conventional channel! Channel not added: " << format_freq(tg->freq) << " Talkgroup: " << tg->number; - // return false; + BOOST_LOG_TRIVIAL(error) << "[" << system->get_short_name() + << "]\t Unable to find a source for this conventional channel! Channel not added: " + << format_freq(freq) << " (primary TG " << group[0]->number << ")"; } else { system_added = true; } From a6b6466c9359ceabe55ac337719fede669a9782a Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 15:22:24 -0400 Subject: [PATCH 03/17] logging update to help setup logs look better --- .../call_concluder/call_concluder.cc | 37 +++++++++++--- trunk-recorder/call_conventional.cc | 6 +-- trunk-recorder/gr_blocks/xlat_channelizer.cc | 6 +-- trunk-recorder/recorders/analog_recorder.cc | 10 ++-- trunk-recorder/setup_systems.cc | 49 ++++++++++++++++--- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/trunk-recorder/call_concluder/call_concluder.cc b/trunk-recorder/call_concluder/call_concluder.cc index b381b0ba7..1fca2d302 100644 --- a/trunk-recorder/call_concluder/call_concluder.cc +++ b/trunk-recorder/call_concluder/call_concluder.cc @@ -1118,7 +1118,11 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf call_info.min_transmissions_removed = 0; call_info.color_code = -1; - const std::string loghdr = + // Non-const so we can rebuild after the multi-row freq-group routing + // override below — log lines that fire AFTER the override (the + // per-transmission summaries and the routing line itself) should reflect + // the matched row's TG number. + std::string loghdr = log_header(call_info.short_name, call_info.call_num, call_info.talkgroup_display, call_info.freq); if (const Talkgroup *tg = sys->find_talkgroup(call->get_talkgroup())) { @@ -1166,11 +1170,32 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf call_info.talkgroup_alpha_tag = matched->alpha_tag; call_info.talkgroup_description = matched->description; call_info.talkgroup_group = matched->group; - // Log lines after this point still use call_info.talkgroup_display - // (set earlier from the Call's primary display), so the - // "Concluding Recorded Call" header may show the primary's TG - // even though the JSON metadata is the matched row's. Acceptable - // trade for not mutating the live Call object mid-conclude. + + // Rebuild the formatted display string to reflect the matched row + // so subsequent log lines and the JSON's talkgroup_display field + // are consistent. (The earlier "Concluding Recorded Call" and + // "Tone Result:" log lines fired BEFORE routing was known and + // still show the primary's display — that's an unavoidable + // timing artifact, the routing decision can't be made until the + // tone verdict is available.) + { + char fmt[64]; + snprintf(fmt, sizeof(fmt), "%c[35m%10ld%c[0m", + 0x1B, matched->number, 0x1B); + call_info.talkgroup_display = fmt; + loghdr = log_header(call_info.short_name, call_info.call_num, + call_info.talkgroup_display, call_info.freq); + } + + // Tell the operator where this call was routed and why. Helps the + // log skim test for "did the right row catch this transmission". + const std::string detected_for_log = + call_info.tone_detected.empty() ? std::string("(none)") + : call_info.tone_detected; + BOOST_LOG_TRIVIAL(info) << loghdr + << "\033[0;36mROUTED\033[0m → TG " << matched->number + << " (" << (matched->alpha_tag.empty() ? "-" : matched->alpha_tag) + << ") by tone '" << detected_for_log << "'"; } else { call_info.tone_skipped = true; } diff --git a/trunk-recorder/call_conventional.cc b/trunk-recorder/call_conventional.cc index 0ba3efbb7..ea2492f5c 100644 --- a/trunk-recorder/call_conventional.cc +++ b/trunk-recorder/call_conventional.cc @@ -11,14 +11,14 @@ Call_conventional::Call_conventional(long t, double f, System *s, Config c, doub this->squelch_db = squelch_db; this->signal_detection = signal_detection; this->tone_config = Tone_Config{}; // TONE_OFF default - BOOST_LOG_TRIVIAL(info) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; + BOOST_LOG_TRIVIAL(debug) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; } Call_conventional::Call_conventional(long t, double f, System *s, Config c, double squelch_db, bool signal_detection, const Tone_Config &tone_config) : Call_impl(t, f, s, c) { this->squelch_db = squelch_db; this->signal_detection = signal_detection; this->tone_config = tone_config; - BOOST_LOG_TRIVIAL(info) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; + BOOST_LOG_TRIVIAL(debug) << "[" << sys->get_short_name() << "]\tFreq: " << format_freq(f) << "\tSquelch: " << squelch_db << " dB\tSignal Detection: " << signal_detection; } void Call_conventional::restart_call() { @@ -56,7 +56,7 @@ time_t Call_conventional::get_start_time() { void Call_conventional::set_recorder(Recorder *r) { recorder = r; - BOOST_LOG_TRIVIAL(info) << "[" << sys->get_short_name() << "]\tTG: " << this->get_talkgroup_display() << "\tFreq: " << format_freq(this->get_freq()); + BOOST_LOG_TRIVIAL(debug) << "[" << sys->get_short_name() << "]\tTG: " << this->get_talkgroup_display() << "\tFreq: " << format_freq(this->get_freq()); } void Call_conventional::recording_started() { diff --git a/trunk-recorder/gr_blocks/xlat_channelizer.cc b/trunk-recorder/gr_blocks/xlat_channelizer.cc index 62578ca4f..254735ed5 100644 --- a/trunk-recorder/gr_blocks/xlat_channelizer.cc +++ b/trunk-recorder/gr_blocks/xlat_channelizer.cc @@ -73,7 +73,7 @@ xlat_channelizer::xlat_channelizer(double input_rate, int samples_per_symbol, do channel_lpf = gr::filter::fft_filter_ccf::make(decim, channel_lpf_taps); // BOOST_LOG_TRIVIAL(info) << "\t Xlating Channelizer single-stage decimator - Decim: " << decimation << " Resampled Rate: " << resampled_rate << " Lowpass Taps: " << if_coeffs.size(); - BOOST_LOG_TRIVIAL(info) << "\t Xlating Channelizer decimator - freq_xlating taps: " << if_coeffs.size() << " Decim: " << decim << " Resampled Rate: " << resampled_rate << " Lowpass Taps: " << channel_lpf_taps.size(); + BOOST_LOG_TRIVIAL(debug) << "\t Xlating Channelizer decimator - freq_xlating taps: " << if_coeffs.size() << " Decim: " << decim << " Resampled Rate: " << resampled_rate << " Lowpass Taps: " << channel_lpf_taps.size(); // ARB Resampler double arb_rate = channel_rate / resampled_rate; @@ -103,7 +103,7 @@ xlat_channelizer::xlat_channelizer(double input_rate, int samples_per_symbol, do #else arb_taps = gr::filter::firdes::low_pass_2(arb_size, arb_size, bw, tb, arb_atten, gr::fft::window::WIN_BLACKMAN_HARRIS); #endif - BOOST_LOG_TRIVIAL(info) << "\t Channelizer ARB - Symbol Rate: " << channel_rate << " Resampled Rate: " << resampled_rate << " ARB Rate: " << arb_rate << " ARB Taps: " << arb_taps.size() << " BW: " << bw << " TB: " << tb; + BOOST_LOG_TRIVIAL(debug) << "\t Channelizer ARB - Symbol Rate: " << channel_rate << " Resampled Rate: " << resampled_rate << " ARB Rate: " << arb_rate << " ARB Taps: " << arb_taps.size() << " BW: " << bw << " TB: " << tb; arb_resampler = gr::filter::pfb_arb_resampler_ccf::make(arb_rate, arb_taps); } else if (arb_rate > 1) { BOOST_LOG_TRIVIAL(error) << "Something is probably wrong! Resampling rate too low"; @@ -125,7 +125,7 @@ xlat_channelizer::xlat_channelizer(double input_rate, int samples_per_symbol, do connect(self(), 0, freq_xlat, 0); connect(freq_xlat, 0, channel_lpf, 0); if (d_use_squelch) { - BOOST_LOG_TRIVIAL(info) << "Conventional - with Squelch"; + BOOST_LOG_TRIVIAL(debug) << "Conventional - with Squelch"; if (arb_rate == 1.0) { connect(channel_lpf, 0, squelch, 0); } else { diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index 017658d01..42357bdcf 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -189,7 +189,7 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type audio_resampler_taps = design_filter(1, (system_channel_rate / wav_sample_rate)); // Calculated to make sample rate changable -- must be an integer - BOOST_LOG_TRIVIAL(info) << "Audio Resampler Taps: " << audio_resampler_taps.size() << " Decimation: " << (system_channel_rate / wav_sample_rate); + BOOST_LOG_TRIVIAL(debug) << "Audio Resampler Taps: " << audio_resampler_taps.size() << " Decimation: " << (system_channel_rate / wav_sample_rate); // downsample from 48k to 8k decim_audio = gr::filter::fir_filter_fff::make((system_channel_rate / wav_sample_rate), audio_resampler_taps); // Calculated to make sample rate changable @@ -198,14 +198,14 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type wav_sink = gr::blocks::transmission_sink::make(1, wav_sample_rate, 16); // Configurable if (use_streaming) { - BOOST_LOG_TRIVIAL(info) << "\t Creating plugin sink..." << std::endl; + BOOST_LOG_TRIVIAL(debug) << "\t Creating plugin sink..." << std::endl; plugin_sink = gr::blocks::plugin_wrapper_impl::make(std::bind(&analog_recorder::plugin_callback_handler, this, std::placeholders::_1, std::placeholders::_2)); - BOOST_LOG_TRIVIAL(info) << "\t Plugin sink created!" << std::endl; + BOOST_LOG_TRIVIAL(debug) << "\t Plugin sink created!" << std::endl; } - BOOST_LOG_TRIVIAL(info) << "\t Creating decoder sink..." << std::endl; + BOOST_LOG_TRIVIAL(debug) << "\t Creating decoder sink..." << std::endl; decoder_sink = gr::blocks::decoder_wrapper_impl::make(wav_sample_rate, std::bind(&analog_recorder::decoder_callback_handler, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3)); - BOOST_LOG_TRIVIAL(info) << "\t Decoder sink created!" << std::endl; + BOOST_LOG_TRIVIAL(debug) << "\t Decoder sink created!" << std::endl; // Analog audio band pass from 300 to 3000 Hz // can't use gnuradio.filter.firdes.band_pass since we have different transition widths diff --git a/trunk-recorder/setup_systems.cc b/trunk-recorder/setup_systems.cc index 6ab1e2c4e..afe0613aa 100644 --- a/trunk-recorder/setup_systems.cc +++ b/trunk-recorder/setup_systems.cc @@ -138,10 +138,32 @@ bool setup_conventional_channel_group(System *system, const std::vectortone_config.mode) { + case TONE_CTCSS: + snprintf(tone_buf, sizeof(tone_buf), "%.1f Hz", primary->tone_config.ctcss_hz); + break; + case TONE_DCS: + snprintf(tone_buf, sizeof(tone_buf), "D%03d%c", + primary->tone_config.dcs_code, + primary->tone_config.dcs_inverted ? 'I' : 'N'); + break; + case TONE_SEARCH: + snprintf(tone_buf, sizeof(tone_buf), "S (search)"); + break; + case TONE_OFF: + default: + snprintf(tone_buf, sizeof(tone_buf), "0 (off)"); + break; + } BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " << system->get_system_type() << " channel: " << format_freq(frequency) - << " Talkgroup: " << primary->number; + << " TG " << primary->number + << " tone=" << tone_buf + << " \"" << primary->alpha_tag << "\"" + << " Squelch: " << effective_squelch << " dB" + << " Signal Detection: " << (primary->signal_detection ? "true" : "false"); } else { BOOST_LOG_TRIVIAL(info) << "[" << system->get_short_name() << "]\tMonitoring " << system->get_system_type() << " channel: " @@ -150,13 +172,26 @@ bool setup_conventional_channel_group(System *system, const std::vectornumber << "); recorder in search mode, " << "metadata routed by detected tone"; for (Talkgroup *tg : group) { - const std::string tone_str = - (tg->tone_config.mode == TONE_CTCSS) ? (std::to_string(tg->tone_config.ctcss_hz) + " Hz") : - (tg->tone_config.mode == TONE_DCS) ? (std::string("D") + std::to_string(tg->tone_config.dcs_code) + (tg->tone_config.dcs_inverted ? "I" : "N")) : - (tg->tone_config.mode == TONE_SEARCH) ? std::string("S (catch-all)") : - std::string("0 (catch-all)"); + char tone_buf[24]; + switch (tg->tone_config.mode) { + case TONE_CTCSS: + snprintf(tone_buf, sizeof(tone_buf), "%.1f Hz", tg->tone_config.ctcss_hz); + break; + case TONE_DCS: + snprintf(tone_buf, sizeof(tone_buf), "D%03d%c", + tg->tone_config.dcs_code, + tg->tone_config.dcs_inverted ? 'I' : 'N'); + break; + case TONE_SEARCH: + snprintf(tone_buf, sizeof(tone_buf), "S (catch-all)"); + break; + case TONE_OFF: + default: + snprintf(tone_buf, sizeof(tone_buf), "0 (catch-all)"); + break; + } BOOST_LOG_TRIVIAL(info) << "\t TG " << tg->number - << " tone=" << tone_str + << " tone=" << tone_buf << " \"" << tg->alpha_tag << "\""; } } From 6d11dd3dc263dc5bd08db787dd109f0ed667940a Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 15:25:36 -0400 Subject: [PATCH 04/17] move new blocks in to structure of cource code --- CMakeLists.txt | 4 ++-- trunk-recorder/recorders/analog_recorder.h | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 314cb2513..ef8a0b616 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -289,8 +289,8 @@ list(APPEND trunk_recorder_sources #lib/gr-latency/latency_tagger.cc #lib/gr-latency-manager/lib/latency_manager_impl.cc #lib/gr-latency-manager/lib/tag_to_msg_impl.cc - new_gr_blocks/ctcss_squelch_ff.cc - new_gr_blocks/dcs_squelch_ff.cc + trunk-recorder/gr_blocks/ctcss_squelch_ff.cc + trunk-recorder/gr_blocks/dcs_squelch_ff.cc trunk-recorder/gr_blocks/freq_xlating_fft_filter.cc trunk-recorder/gr_blocks/transmission_sink.cc trunk-recorder/gr_blocks/decoders/fsync_decode.cc diff --git a/trunk-recorder/recorders/analog_recorder.h b/trunk-recorder/recorders/analog_recorder.h index 1d1f0ac0f..5ddcfe525 100644 --- a/trunk-recorder/recorders/analog_recorder.h +++ b/trunk-recorder/recorders/analog_recorder.h @@ -45,8 +45,8 @@ class Source; class analog_recorder; -#include "../../new_gr_blocks/ctcss_squelch_ff.h" -#include "../../new_gr_blocks/dcs_squelch_ff.h" +#include "../gr_blocks/ctcss_squelch_ff.h" +#include "../gr_blocks/dcs_squelch_ff.h" #include "../gr_blocks/channelizer.h" #include "../gr_blocks/decoder_wrapper.h" #include "../gr_blocks/freq_xlating_fft_filter.h" From adfa77f9aefd91928f7b6af7fa17180ebf7d56ed Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 15:26:30 -0400 Subject: [PATCH 05/17] forgot to add files to git, oops --- trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 642 ++++++++++++++++ trunk-recorder/gr_blocks/ctcss_squelch_ff.h | 203 ++++++ trunk-recorder/gr_blocks/dcs_squelch_ff.cc | 723 +++++++++++++++++++ trunk-recorder/gr_blocks/dcs_squelch_ff.h | 263 +++++++ 4 files changed, 1831 insertions(+) create mode 100644 trunk-recorder/gr_blocks/ctcss_squelch_ff.cc create mode 100644 trunk-recorder/gr_blocks/ctcss_squelch_ff.h create mode 100644 trunk-recorder/gr_blocks/dcs_squelch_ff.cc create mode 100644 trunk-recorder/gr_blocks/dcs_squelch_ff.h diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc new file mode 100644 index 000000000..b4547e63d --- /dev/null +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -0,0 +1,642 @@ +/* + * CTCSS detector + audio gate implementation. + * + * Algorithm summary (see CTCSS_DETECTOR_V2.md for the full design rationale): + * 1. Internal anti-alias LPF + decimation from full audio rate (e.g. 16 kHz) + * to ~1 kHz sub-audible band rate. + * 2. Sliding rectangular 200 ms Goertzel window per CTCSS standard frequency + * (50 bins). Re-evaluated every 50 ms. + * 3. Adaptive noise floor = median of the 50 bin powers (sub-audible + * ambient — voice harmonics, FM noise). Per-bin SNR (dB) computed + * relative to this floor. + * 4. Live squelch: hysteresis on the *configured* bin's SNR — open + * when ≥ d_open_threshold_db for d_open_hangtime_ticks consecutive + * eval ticks, close when ≤ d_close_threshold_db for + * d_close_hangtime_ticks consecutive ticks. Free-scan: same but on + * whichever bin is currently the winner. + * 5. Identification: cumulative scoring per bin — sum of SNR-dB across + * every tick where that bin was the winner above the close threshold. + * End-of-call verdict picks the bin with the highest cumulative + * score (energy-weighted, robust against voice harmonics intermittently + * stealing windows). + */ + +#include "ctcss_squelch_ff.h" + +#include + +#include +#include +#include +#include + +namespace gr { +namespace blocks { + +namespace { + +// Standard 50-tone EIA CTCSS frequency plan (Hz). Same as the table in +// tone_detector_block.cc — kept here as a self-contained copy so this +// block doesn't depend on tone_detector_block (we want them +// independently buildable / testable). +constexpr int N_CTCSS = 50; +constexpr double CTCSS_FREQS[N_CTCSS] = { + 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, + 94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, + 131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9, + 171.3, 173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5, + 203.5, 206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1 +}; + +// Decimation target: ~1 kHz so each CTCSS bin's Goertzel window is small +// and cheap. 1 kHz also guarantees the entire CTCSS band (67-254 Hz) is +// well within Nyquist for the LPF. +constexpr double DECIM_TARGET_RATE = 1000.0; + +// Goertzel window: 500 ms at the decimated rate gives 2 Hz noise bandwidth. +// Narrow enough that adjacent CTCSS standards (2.3-7 Hz spaced at the low +// end) and voice fundamentals (drifting 80-180 Hz for typical male speech) +// land in distinct bins. The previous 200 ms / 5 Hz bandwidth blurred +// voice fundamental in with neighbouring CTCSS bins which let the +// "winner" bounce around and prevented the verdict guards from ever +// finding a sustained dominant bin. Matches the original tone_detector_ +// block's window length, which was tuned over many bench iterations. +constexpr double WINDOW_DURATION_S = 0.500; + +// Re-evaluate the squelch decision every 100 ms. With a 500 ms window +// this gives 4× overlap (75% shared audio across consecutive ticks) — +// still responsive (~200 ms open latency) without 4× the per-tick cost +// the 50 ms cadence used to incur. +constexpr double EVAL_TICK_S = 0.100; + +// Score-above-threshold bar for the cumulative tally. A bin must beat +// floor by this many dB to "win" a tick. Significantly above the open +// threshold so the verdict requires sustained dominance, not just brief +// edge cases. +constexpr float SCORING_THRESHOLD_DB = 6.0f; + +// Defaults for the squelch hysteresis. Open is generous enough that +// fast TX peeling (radio keys mid-syllable) still locks; close is well +// below open to give clean hysteresis with no chatter. +constexpr float DEFAULT_OPEN_THRESH_DB = 8.0f; +constexpr float DEFAULT_CLOSE_THRESH_DB = 4.0f; +constexpr int DEFAULT_OPEN_HANG_MS = 100; +constexpr int DEFAULT_CLOSE_HANG_MS = 150; // TIA-603 reverse-burst spec + +// Notched-reference threshold (verify mode only). Target-bin power must +// exceed this multiple of the post-notch band power for the gate to open +// and the tick to be scored. OpenAudio's default is threshRel=1.0; our +// 3.0 is tighter because (a) we have a 16-bit SDR with good SNR, (b) +// short bench calls don't tolerate transient false matches well, and +// (c) the 4-stage Q=60 notch gives a deeper null than OpenAudio's +// Teensy version. A real CTCSS sine yields target/notched ratios of +// 30-100×; sustained voice fundamentals on the configured bin yield +// 1-3× because the notch only nulls one specific frequency, leaving +// the broadband voice spectrum to fill the reference. +constexpr double NOTCH_REL_THRESH = 3.0; + +// Calibration factor that makes a pure sine input of the same amplitude +// produce equal target and notched-reference powers in the absence of +// the notch. Matches the 2.04 factor in OpenAudio analyze_CTCSS_F32.cpp +// (line 98). Without this, a 1.0-amplitude sine would read powerTone=0.5 +// (Goertzel) vs powerSum=0.5 (time-domain mean square) before the notch +// design's 2× scaling — the factor reconciles units. +constexpr double NOTCH_REF_CAL = 2.04; + +// Designs a small Hann-windowed low-pass FIR for the internal decimation +// stage. We keep this self-contained so the block has no external filter +// dependencies. cutoff_hz = ~300 Hz, transition ~150 Hz, ~32 taps gives +// >40 dB stopband — good enough to keep voice formants from aliasing +// into the sub-audible band. +std::vector design_lpf(double rate, double cutoff_hz, int n_taps) { + std::vector taps(n_taps); + const double fc_norm = cutoff_hz / rate; // normalized cutoff (cycles/sample) + const double M = n_taps - 1; + double sum = 0.0; + for (int n = 0; n < n_taps; ++n) { + const double k = n - M / 2.0; + const double sinc = (k == 0.0) ? (2.0 * fc_norm) + : (std::sin(2.0 * M_PI * fc_norm * k) / (M_PI * k)); + const double hann = 0.5 - 0.5 * std::cos(2.0 * M_PI * n / M); + const double v = sinc * hann; + taps[n] = static_cast(v); + sum += v; + } + // Normalize for unity DC gain + for (auto &t : taps) t /= static_cast(sum); + return taps; +} + +} // anonymous namespace + +ctcss_squelch_ff::sptr ctcss_squelch_ff::make(double sample_rate, float configured_pl_freq, bool gate_audio) { + return gnuradio::get_initial_sptr(new ctcss_squelch_ff(sample_rate, configured_pl_freq, gate_audio)); +} + +ctcss_squelch_ff::ctcss_squelch_ff(double sample_rate, float configured_pl_freq, bool gate_audio) + : sync_block("ctcss_squelch_ff", + io_signature::make(1, 1, sizeof(float)), + io_signature::make(1, 1, sizeof(float))), + d_sample_rate(sample_rate), + d_configured_pl_freq(configured_pl_freq), + d_configured_bin(-1), + d_gate_audio(gate_audio), + d_open_threshold_db(DEFAULT_OPEN_THRESH_DB), + d_close_threshold_db(DEFAULT_CLOSE_THRESH_DB), + d_open_hangtime_ticks(0), // computed below from ms + d_close_hangtime_ticks(0), + d_open_streak(0), + d_close_streak(0), + d_unmuted(false) { + + // Decimation factor — round to nearest int. For 16 kHz input -> 16x = 1 kHz. + d_decim_factor = std::max(1, static_cast(std::round(d_sample_rate / DECIM_TARGET_RATE))); + const double decim_rate = d_sample_rate / d_decim_factor; + + // Goertzel window in decimated samples. + d_window_samples = std::max(32, static_cast(std::round(WINDOW_DURATION_S * decim_rate))); + + // Eval tick in decimated samples. + d_eval_tick_samples = std::max(1, static_cast(std::round(EVAL_TICK_S * decim_rate))); + + // Squelch hangtimes from ms → eval ticks. + d_open_hangtime_ticks = std::max(1, static_cast(std::round(DEFAULT_OPEN_HANG_MS / 1000.0 * decim_rate / d_eval_tick_samples))); + d_close_hangtime_ticks = std::max(1, static_cast(std::round(DEFAULT_CLOSE_HANG_MS / 1000.0 * decim_rate / d_eval_tick_samples))); + + // Pre-design the anti-alias LPF for the decimator. ~300 Hz cutoff, ~32 taps. + d_decim_taps = design_lpf(d_sample_rate, 300.0, 32); + d_decim_state.assign(d_decim_taps.size(), 0.0f); + d_decim_state_idx = 0; + d_decim_phase = 0; + + // Pre-build the 50 CTCSS bin Goertzel coefficients at the decimated rate. + d_bins.resize(N_CTCSS); + for (int i = 0; i < N_CTCSS; ++i) { + d_bins[i].freq = CTCSS_FREQS[i]; + d_bins[i].coeff = 2.0 * std::cos(2.0 * M_PI * CTCSS_FREQS[i] / decim_rate); + d_bins[i].cum_score = 0.0; + d_bins[i].win_ticks = 0; + d_bins[i].energy_sum = 0.0; + } + + // Find the configured bin (within 1 Hz) so the squelch knows which + // Goertzel result to drive the gating decision off. + if (d_configured_pl_freq > 0.0f) { + for (int i = 0; i < N_CTCSS; ++i) { + if (std::abs(d_bins[i].freq - d_configured_pl_freq) < 1.0) { + d_configured_bin = i; + break; + } + } + } + + // Pre-allocate the sliding-window ring buffer (decimated samples). + d_window.assign(d_window_samples, 0.0f); + d_window_write_idx = 0; + d_window_filled = false; + d_samples_since_eval = 0; + d_total_eval_ticks = 0; + + // Notched-reference path (4-cascade Q=60 biquad notch + post-notch power) + // was prototyped here and removed. See evaluate_goertzels() comment for + // why the technique works in OpenAudio's full-audio-band setup but not + // in ours (where the upstream chain already LPFs to 300 Hz). Leaving + // the d_notch_chain / d_notched_window vectors empty keeps the work() + // hot path branch-predictor friendly. + + BOOST_LOG_TRIVIAL(debug) + << "ctcss_squelch_ff: configured_pl=" << d_configured_pl_freq << " Hz" + << " (bin=" << d_configured_bin << ")" + << " gate_audio=" << (d_gate_audio ? "true" : "false") + << " decim=" << d_decim_factor << "x → " << decim_rate << " Hz" + << " window=" << d_window_samples << " samples (" << WINDOW_DURATION_S * 1000 << " ms)" + << " eval-every=" << d_eval_tick_samples << " samples (" << EVAL_TICK_S * 1000 << " ms)" + << " open=" << d_open_threshold_db << " dB / " << DEFAULT_OPEN_HANG_MS << " ms" + << " close=" << d_close_threshold_db << " dB / " << DEFAULT_CLOSE_HANG_MS << " ms"; +} + +ctcss_squelch_ff::~ctcss_squelch_ff() = default; + +void ctcss_squelch_ff::reset() { + boost::mutex::scoped_lock lock(d_mutex); + for (auto &b : d_bins) { + b.cum_score = 0.0; + b.win_ticks = 0; + b.energy_sum = 0.0; + } + std::fill(d_window.begin(), d_window.end(), 0.0f); + std::fill(d_decim_state.begin(), d_decim_state.end(), 0.0f); + if (!d_notched_window.empty()) { + std::fill(d_notched_window.begin(), d_notched_window.end(), 0.0f); + } + for (auto &b : d_notch_chain) { + b.x1 = b.x2 = b.y1 = b.y2 = 0.0; + } + d_window_write_idx = 0; + d_window_filled = false; + d_decim_state_idx = 0; + d_decim_phase = 0; + d_samples_since_eval = 0; + d_total_eval_ticks = 0; + d_open_streak = 0; + d_close_streak = 0; + d_unmuted = false; +} + +void ctcss_squelch_ff::set_open_threshold_db(float db) { + boost::mutex::scoped_lock lock(d_mutex); + d_open_threshold_db = db; +} + +void ctcss_squelch_ff::set_close_threshold_db(float db) { + boost::mutex::scoped_lock lock(d_mutex); + d_close_threshold_db = db; +} + +void ctcss_squelch_ff::set_open_hangtime_ms(int ms) { + boost::mutex::scoped_lock lock(d_mutex); + const double decim_rate = d_sample_rate / d_decim_factor; + d_open_hangtime_ticks = std::max(1, static_cast(std::round(ms / 1000.0 * decim_rate / d_eval_tick_samples))); +} + +void ctcss_squelch_ff::set_close_hangtime_ms(int ms) { + boost::mutex::scoped_lock lock(d_mutex); + const double decim_rate = d_sample_rate / d_decim_factor; + d_close_hangtime_ticks = std::max(1, static_cast(std::round(ms / 1000.0 * decim_rate / d_eval_tick_samples))); +} + +bool ctcss_squelch_ff::is_unmuted() const { + boost::mutex::scoped_lock lock(d_mutex); + return d_unmuted; +} + +ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { + boost::mutex::scoped_lock lock(d_mutex); + ctcss_squelch_verdict v; + v.detected_hz = 0.0f; + v.confidence = 0.0f; + v.dominant_snr_db = 0.0f; + + if (d_total_eval_ticks <= 0) return v; + + // Build top-three (by win_ticks then cum_score) for diagnostics regardless + // of whether the strict guards below pass — useful when a call doesn't + // produce a confident verdict but the operator wants to see what the + // detector saw. + std::vector idx(N_CTCSS); + std::iota(idx.begin(), idx.end(), 0); + std::partial_sort(idx.begin(), idx.begin() + std::min(3, N_CTCSS), idx.end(), + [this](int a, int b) { + if (d_bins[a].win_ticks != d_bins[b].win_ticks) { + return d_bins[a].win_ticks > d_bins[b].win_ticks; + } + return d_bins[a].cum_score > d_bins[b].cum_score; + }); + for (int i = 0; i < std::min(3, N_CTCSS); ++i) { + if (d_bins[idx[i]].win_ticks <= 0) break; + const float mean_snr = static_cast(d_bins[idx[i]].cum_score / d_bins[idx[i]].win_ticks); + v.top_three.emplace_back(static_cast(d_bins[idx[i]].freq), mean_snr); + } + + // Strict verdict guards — ported from the old tone_detector_block. A + // real sustained CTCSS sine wave wins both metrics (consistency AND + // loudness) AND wins them clearly. Voice fundamentals in the CTCSS + // band (male speech ~85-180 Hz lands across bins 7-17) typically win + // only by win_ticks OR by energy_sum, not both. Without these guards + // the verdict reports whichever bin voice fundamental happened to + // sit in for that speaker on that call. + // + // Four guards (ALL must pass): + // 1. AGREEMENT: win-count winner == energy winner (a real CTCSS + // sine wins both; broadband voice typically wins one or neither) + // 2. CONSISTENCY: win-count winner has ≥70 % of all eval ticks (the + // tone was dominant for the bulk of the call) + // 3. WIN-COUNT DOMINANCE: winner has ≥1.5× the runner-up's wins + // (a single bin clearly dominates, not a tie) + // 4. ENERGY DOMINANCE: winner energy_sum > 1.5× runner-up AND > 5× + // median (loudness sanity-check vs both runner-up and noise floor) + int top_wins_i = -1; + long top_wins = 0; + long second_wins = 0; + for (int i = 0; i < N_CTCSS; ++i) { + const long w = d_bins[i].win_ticks; + if (w > top_wins) { + second_wins = top_wins; + top_wins = w; + top_wins_i = i; + } else if (w > second_wins) { + second_wins = w; + } + } + + int top_energy_i = -1; + double top_energy = 0.0; + double second_energy = 0.0; + for (int i = 0; i < N_CTCSS; ++i) { + const double e = d_bins[i].energy_sum; + if (e > top_energy) { + second_energy = top_energy; + top_energy = e; + top_energy_i = i; + } else if (e > second_energy) { + second_energy = e; + } + } + + // Median energy across all 50 bins for the noise-floor sanity check. + std::vector sorted_e(N_CTCSS); + double total_energy = 0.0; + for (int i = 0; i < N_CTCSS; ++i) { + sorted_e[i] = d_bins[i].energy_sum; + total_energy += d_bins[i].energy_sum; + } + std::sort(sorted_e.begin(), sorted_e.end()); + const double median_energy = sorted_e[N_CTCSS / 2]; + + // Spectral concentration: what fraction of total sub-audible band energy + // is at the winning bin. Used as a minimum-sanity floor (against pure + // broadband noise) rather than a tight discriminator. The math is + // top_bin_energy / (50_bins_worth_of_noise_plus_signal), so a clean + // CTCSS tone at moderate SNR (~15-20 dB) naturally lands around 10-20% + // (because the denominator includes 49 noise-floor bins regardless of + // signal strength). The original 0.30 threshold I added rejected real + // short keyups; pure broadband noise sits at ~2% (1/50), so 0.10 is a + // safe minimum. + // + // CTCSS false-positives on DCS audio used to be caught HERE (DCS audio + // can produce ~22% concentration when the bit-rate fundamental near + // 134.4 Hz contaminates the 136.5 Hz Goertzel bin), but they're now + // properly resolved at the tiebreak: with phase-diversity-recalibrated + // DCS confidence, real DCS reaches conf=1.0 and beats any CTCSS verdict + // in analog_recorder::stop()'s search-mode max-conf comparison. + const double concentration = (total_energy > 0.0 && top_energy_i >= 0) + ? d_bins[top_energy_i].energy_sum / total_energy + : 0.0; + static constexpr double MIN_CONCENTRATION_SEARCH = 0.10; + const bool concentrated = (d_configured_bin >= 0) // verify mode: skip the check + ? true + : (concentration >= MIN_CONCENTRATION_SEARCH); + + // Verdict guards. The per-tick adjacent-bin guards (and notch-ratio in + // verify mode) already filter voice fundamentals out at the tick + // level — only HIGH-CONFIDENCE ticks ever increment win_ticks. So the + // verdict's job is no longer to second-guess questionable wins; it's + // to confirm there are enough confirmed wins AND that they're not a + // statistical fluke. + // + // Replaced the prior percentage-consistency rule with an absolute + // minimum win count: ≥5 qualified ticks = ≥500 ms of confirmed CTCSS + // presence. Bench evidence: under strict per-tick guards a real 5-12s + // call yields 25-122 qualified wins; voice-only calls yield 0-3. + // Setting the bar at 5 dispatches voice-only false positives while + // accepting any call with a half-second of clean tone — which is + // what TIA-603-E paging timing assumes anyway. + static constexpr long MIN_QUALIFIED_WINS = 5; + const bool agree = (top_wins_i >= 0 && top_wins_i == top_energy_i); + const bool enough_wins = (top_wins >= MIN_QUALIFIED_WINS); // ≥500ms qualified + const bool win_dominant = (top_wins >= second_wins * 3 / 2); // ≥1.5× + const bool energy_dominant = (top_energy_i >= 0 && + top_energy > second_energy * 1.5 && + top_energy > median_energy * 5.0); // ≥5× + const bool consistent = enough_wins; // legacy name kept for the diag log + + if (agree && consistent && win_dominant && energy_dominant && concentrated) { + v.detected_hz = static_cast(d_bins[top_wins_i].freq); + v.confidence = static_cast(top_wins) / static_cast(d_total_eval_ticks); + v.dominant_snr_db = static_cast(d_bins[top_wins_i].cum_score / d_bins[top_wins_i].win_ticks); + } + + // Diagnostic — log the guard outcomes + the top wins/energy bins so we + // can see WHY a call did or didn't produce a verdict. Logged + // unconditionally for now; should be tunable / removed once tuning is + // settled. + const double w_hz = (top_wins_i >= 0) ? d_bins[top_wins_i].freq : 0.0; + const double e_hz = (top_energy_i >= 0) ? d_bins[top_energy_i].freq : 0.0; + const char *mode = (d_configured_bin >= 0) ? "verify" : "search"; + BOOST_LOG_TRIVIAL(info) + << "ctcss_squelch_ff[" << mode << "] verdict@" + << (v.detected_hz > 0 ? std::to_string(v.detected_hz) : std::string("none")) + << " | total_ticks=" << d_total_eval_ticks + << " win_winner=" << w_hz << "Hz (" << top_wins << " wins, 2nd=" << second_wins << ")" + << " energy_winner=" << e_hz << "Hz" + << " guards: agree=" << agree + << " enough_wins(>=5)=" << enough_wins << " (" << top_wins << "/" << d_total_eval_ticks << ")" + << " win_dom(>=1.5x)=" << win_dominant + << " energy_dom(>=5x)=" << energy_dominant + << " concentrated(>=0.10)=" << concentrated + << " (e_top=" << top_energy << " e_2nd=" << second_energy + << " e_med=" << median_energy << " concentration=" << concentration << ")"; + return v; +} + +void ctcss_squelch_ff::evaluate_goertzels() { + // Read the sliding window in time order (oldest first). The ring buffer's + // newest sample is at d_window_write_idx-1; oldest is at d_window_write_idx. + // For each bin, run a fresh Goertzel over the window's d_window_samples + // values. This is the simplest "rectangular sliding window" — recompute + // each tick. Cost: 50 bins × 200 samples × 2 ops = 20k ops per eval. + + std::vector powers(N_CTCSS, 0.0); + + // Don't evaluate until the window has been filled at least once — pre-fill + // values are zero and would skew the median floor calculation. + if (!d_window_filled) return; + + for (int i = 0; i < N_CTCSS; ++i) { + const double C = d_bins[i].coeff; + double s1 = 0.0, s2 = 0.0; + int read_idx = d_window_write_idx; // oldest sample position in the ring + for (int n = 0; n < d_window_samples; ++n) { + const double s = d_window[read_idx] + C * s1 - s2; + s2 = s1; + s1 = s; + if (++read_idx >= d_window_samples) read_idx = 0; + } + double e = s1 * s1 + s2 * s2 - C * s1 * s2; + if (e < 1e-12) e = 1e-12; + powers[i] = e; + } + + // "Real audio present" gate. Skip this tick entirely if the total + // Goertzel power across all 50 bins is below detection floor — this + // catches the ramp-up / ramp-down transients from pwr_squelch_cc's + // smooth fade (alpha=0.01 IIR + ramp=10), plus any other periods + // where the upstream squelch is partially muting. Without this gate, + // those ticks pick a bogus "winner" from near-zero noise and inflate + // both win_ticks and energy_sum for bins that didn't actually see a + // real tone. The threshold is well below any real voice or CTCSS + // signal (a 0.1-amplitude CTCSS sine over a 200-sample window scores + // ~100 per bin, so total across 50 bins is in the thousands; pure + // zero input scores ~5e-11 total). 1e-3 is generous headroom for + // weak signals while still rejecting silence. + double total_power = 0.0; + for (double p : powers) total_power += p; + if (total_power < 1e-3) { + // Don't increment d_total_eval_ticks either — the consistency / + // confidence ratios should be over ACTIVE-AUDIO ticks only. + return; + } + + // Cumulative loudness (amplitude domain) for the verdict guards. + // Updated only on active-audio ticks so silence doesn't pollute the + // running totals. + for (int i = 0; i < N_CTCSS; ++i) { + d_bins[i].energy_sum += std::sqrt(powers[i]); + } + + // Adaptive noise floor: median of all 50 bin powers. + std::vector sorted_powers = powers; + std::sort(sorted_powers.begin(), sorted_powers.end()); + const double median = sorted_powers[N_CTCSS / 2]; + const double floor_lin = std::max(median, 1e-12); + + // Compute SNR per bin and find the winner. + // + // Adjacent-bin guard (from GNU Radio ctcss_squelch_ff_impl.cc style, and + // the spectral-peak literature): a real CTCSS sinusoid is a knife-edge + // peak — its bin towers above both immediate CTCSS neighbors. Voice + // fundamentals smear across multiple adjacent bins (e.g. a male speaker + // sitting at ~105 Hz raises 100.0/103.5/107.2 comparably). Requiring + // power[i] > power[i-1] AND power[i] > power[i+1] disqualifies smeared + // peaks at near-zero cost. This is the per-tick voice-fundamental + // rejection that converts the median-floor metric from "report + // whichever bin voice happens to peak in" into "report bins that look + // like actual sinusoids." + int winner_idx = -1; + double winner_snr_lin = 1.0; // any bin must beat unity (0 dB) to be "winner" + for (int i = 0; i < N_CTCSS; ++i) { + const bool left_ok = (i == 0) || powers[i] > powers[i - 1]; + const bool right_ok = (i == N_CTCSS - 1) || powers[i] > powers[i + 1]; + if (!left_ok || !right_ok) continue; + const double snr_lin = powers[i] / floor_lin; + if (snr_lin > winner_snr_lin) { + winner_snr_lin = snr_lin; + winner_idx = i; + } + } + const float winner_snr_db = (winner_idx >= 0) + ? static_cast(10.0 * std::log10(winner_snr_lin)) + : 0.0f; + + // NOTE: A notched-reference path (OpenAudio analyze_CTCSS_F32 style) was + // tried here and removed. In OpenAudio it works because the input is + // the full 0-3000 Hz audio band where voice has rich harmonic content + // BUT CTCSS sits in a relatively quiet sub-audible zone — the 2-Hz + // notch then removes most of the "tone" energy while voice harmonics + // (300-3000 Hz) dominate the reference. Our upstream chain LPFs to + // 300 Hz before this block, so the notch's input is already + // voice-fundamental-dominated (80-300 Hz). The notch removes ~0.1% of + // the band energy, so target/notched ≈ CTCSS_pwr / voice_pwr ≈ 0.1 + // even for clean CTCSS — it rejects every legitimate detection that + // happens during voice (validated by raw-wav ground-truth: 20/21 + // rejected calls had configured tone at rank-1 with 18-35 dB SNR). + // Per-tick voice rejection is therefore left to the adjacent-bin + // guards above; the verdict's MIN_QUALIFIED_WINS rule + agreement + + // dominance guards catch the remaining false positives. + + // Cumulative scoring (identification path). Add the winner's SNR-dB to + // its score whenever it beats SCORING_THRESHOLD_DB. The adjacent-bin + // guards inside the winner loop already filter out voice-fundamental + // smearing (a real CTCSS sine is a knife-edge peak; voice smears + // across multiple bins and never wins the guards), so any winner we + // see here is a high-confidence per-tick observation. + if (winner_idx >= 0 && winner_snr_db >= SCORING_THRESHOLD_DB) { + d_bins[winner_idx].cum_score += winner_snr_db; + d_bins[winner_idx].win_ticks += 1; + } + d_total_eval_ticks += 1; + + // Squelch state update (gating path). + update_squelch_state(winner_idx, winner_snr_db); +} + +void ctcss_squelch_ff::update_squelch_state(int winner_idx, float winner_snr_db) { + // For configured channels: gate opens when the configured bin is the + // SNR-winner among the adjacent-bin-guarded set. Voice fundamentals + // smear across multiple bins and never beat both ±1 neighbors, so + // them being "the winner" is already strongly improbable. + // For free-scan: gate on whatever winner survived the adjacent guards. + float gating_snr_db = 0.0f; + if (d_configured_bin >= 0) { + if (winner_idx == d_configured_bin) { + gating_snr_db = winner_snr_db; + } else { + gating_snr_db = 0.0f; // configured tone isn't the winner + } + } else { + // Free-scan: any winner above threshold opens. + gating_snr_db = winner_snr_db; + } + + if (gating_snr_db >= d_open_threshold_db) { + d_open_streak += 1; + d_close_streak = 0; + if (!d_unmuted && d_open_streak >= d_open_hangtime_ticks) { + d_unmuted = true; + } + } else if (gating_snr_db < d_close_threshold_db) { + d_close_streak += 1; + d_open_streak = 0; + if (d_unmuted && d_close_streak >= d_close_hangtime_ticks) { + d_unmuted = false; + } + } else { + // Between open and close thresholds → hysteresis dead-zone, keep current state. + d_open_streak = 0; + d_close_streak = 0; + } +} + +int ctcss_squelch_ff::work(int noutput_items, + gr_vector_const_void_star &input_items, + gr_vector_void_star &output_items) { + const float *in = static_cast(input_items[0]); + float *out = static_cast(output_items[0]); + + boost::mutex::scoped_lock lock(d_mutex); + + for (int i = 0; i < noutput_items; ++i) { + // Step 1: feed the decimating LPF. + d_decim_state[d_decim_state_idx] = in[i]; + if (++d_decim_state_idx >= static_cast(d_decim_state.size())) { + d_decim_state_idx = 0; + } + if (++d_decim_phase >= d_decim_factor) { + d_decim_phase = 0; + // Compute one decimated sample by convolving taps with delay line. + float dec = 0.0f; + int idx = d_decim_state_idx; // oldest sample position + for (size_t t = 0; t < d_decim_taps.size(); ++t) { + dec += d_decim_taps[t] * d_decim_state[idx]; + if (++idx >= static_cast(d_decim_state.size())) idx = 0; + } + // Step 2: push into Goertzel sliding window. + d_window[d_window_write_idx] = dec; + if (++d_window_write_idx >= d_window_samples) { + d_window_write_idx = 0; + d_window_filled = true; + } + // Step 3: every eval_tick_samples decimated samples, evaluate. + if (++d_samples_since_eval >= d_eval_tick_samples) { + d_samples_since_eval = 0; + evaluate_goertzels(); + } + } + // Step 4: gate output. In gating mode pass input when unmuted, zero + // otherwise. In detect-only mode (d_gate_audio==false) always pass + // through — the squelch decision is still tracked internally for + // is_unmuted() / get_verdict() callers, but the audio path is left + // alone (used when this block is a side-chain detector or when + // search mode wants audio passing without gating). + if (d_gate_audio) { + out[i] = d_unmuted ? in[i] : 0.0f; + } else { + out[i] = in[i]; + } + } + + return noutput_items; +} + +} // namespace blocks +} // namespace gr diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h new file mode 100644 index 000000000..3022cd30d --- /dev/null +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h @@ -0,0 +1,203 @@ +/* + * CTCSS detector + audio gate (single block, replaces gr::analog::ctcss_squelch_ff + * AND the CTCSS portion of trunk-recorder's tone_detector_block). + * + * Float-in / float-out passthrough block. Maintains per-CTCSS-bin sliding-window + * Goertzel power estimates on an internally-decimated sub-audible-band copy of + * the input. Gates audio output to zeros when the configured tone is not heard + * (or, in free-scan mode, when no tone above threshold). Exposes a verdict + * structure that callers can read at end-of-call to identify what tone was + * actually on air (with diagnostic top-N for the wrong-agency case). + * + * Why we wrote our own (vs. gr::analog::ctcss_squelch_ff): + * - Stock block opens/closes on opaque thresholds (level, len, ramp), making + * bench tuning into trial-and-error. + * - Stock block can stay closed for 1.5+ s on the very call we're trying to + * detect (bench evidence: 5 NOT-heard events on calls <1.5 s with strong + * tones in raw audio; see DECISIONS_*.md). + * - Stock block lives in the audio path AND requires a parallel detector + * for state truth — two sources of truth, polling races. + * - Stock block has no concept of an adaptive sub-audible noise floor; + * it treats voice low-frequency harmonics the same as a real tone. + * + * What this block does differently: + * 1. Sliding 200 ms Goertzel window evaluated every 50 ms — ~100 ms time + * to lock for any call ≥ 100 ms with a real tone. (vs the 1.5 s minimum + * our block-based detector currently needs.) + * 2. Adaptive noise floor = median of all 50 bin powers. Real CTCSS + * tone reads 20-50 dB above; voice harmonics typically only 5-10 dB. + * Single SNR threshold cleanly separates them. + * 3. Energy-weighted cumulative scoring across the call. Voice harmonics + * stealing 30 % of windows can't outvote a tone that's the dominant + * energy contributor across the whole call. + * 4. Hysteresis-gated squelch: open threshold > close threshold, with + * configurable hangtimes. No chatter. + */ + +#ifndef INCLUDED_CTCSS_SQUELCH_FF_H +#define INCLUDED_CTCSS_SQUELCH_FF_H + +#include +#include +#include +#include +#include +#include + +namespace gr { +namespace blocks { + +struct ctcss_squelch_verdict { + // 0.0 if no confident lock during the call, else the detected CTCSS + // frequency in Hz. For configured channels this normally equals the + // configured tone (matches), but in shared-frequency multi-PL setups + // it can equal a *different* CTCSS standard frequency — that's the + // wrong-agency-keyed-up case operators need to see. + float detected_hz; + // Fraction of evaluation ticks (0-1) where the detected bin was the + // winner above threshold. Above ~0.5 is a confident detection. + float confidence; + // Mean SNR (dB above sub-audible noise floor) of the detected bin + // across the ticks where it was the winner. + float dominant_snr_db; + // Top three winning bins (frequency, mean SNR) for diagnostics — + // useful when the detected tone wasn't the configured one. + std::vector> top_three; +}; + +class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { +public: +#if GNURADIO_VERSION < 0x030900 + typedef boost::shared_ptr sptr; +#else + typedef std::shared_ptr sptr; +#endif + + // sample_rate: full input audio sample rate (e.g. 16000 Hz). + // configured_pl_freq: CTCSS frequency the recorder is configured for. + // When > 0, the block gates audio open/closed based on whether THIS + // tone is currently above its open/close thresholds — same role as + // ctcss_squelch_ff for live audio gating. + // When 0 (free-scan mode), the block gates open whenever ANY tone + // above the open threshold is the winner, and the verdict reports + // whichever tone scored highest cumulatively over the call. + // gate_audio: when true (default), work() zeros output samples whenever + // the squelch is muted (live audio-path gating). When false, output + // is a passthrough of input regardless of detection state — used + // when this block runs as a side-chain detector alongside a separate + // gating block (or in search mode where audio passes through + // ungated). + static sptr make(double sample_rate, float configured_pl_freq = 0.0f, bool gate_audio = true); + + ctcss_squelch_ff(double sample_rate, float configured_pl_freq, bool gate_audio); + ~ctcss_squelch_ff(); + + int work(int noutput_items, + gr_vector_const_void_star &input_items, + gr_vector_void_star &output_items) override; + + // Polled at end-of-transmission by analog_recorder for the Tone Result + // log line and the JSON `tone` field. Safe to call from any thread. + ctcss_squelch_verdict get_verdict() const; + + // True when the squelch is currently open (tone above open threshold + + // hysteresis). Useful for the recorder's tone_was_ever_unmuted() + // shortcut when polling is needed before end-of-call. + bool is_unmuted() const; + + // Reset per-call state (cumulative scores, squelch state, sliding + // window). Called from analog_recorder::start() per transmission. + void reset(); + + // Tunables. Defaults are calibrated for the bench but configurable. + void set_open_threshold_db(float db); + void set_close_threshold_db(float db); + void set_open_hangtime_ms(int ms); + void set_close_hangtime_ms(int ms); + +private: + void evaluate_goertzels(); // called every eval_tick_samples + void update_squelch_state(int winner_idx, float winner_snr_db); + + // Configuration (read-mostly; protected by d_mutex when changed). + double d_sample_rate; // full input rate, e.g. 16000 + float d_configured_pl_freq; // 0 = free-scan + int d_configured_bin; // index into d_bins; -1 if not in table + bool d_gate_audio; // true: zero output when muted; false: passthrough + + // Pre-built immutable tables. + static constexpr int N_CTCSS = 50; + struct bin_state { + double freq; // standard CTCSS Hz + double coeff; // 2 * cos(2*pi*f/decim_rate) + double cum_score; // sum of SNR-dB while this bin was the winner above threshold + int win_ticks; // how many evaluation ticks this bin was the winner above threshold + double energy_sum; // sum of sqrt(Goertzel power) across ALL ticks (loudness, + // not just winning ticks). Used by get_verdict()'s + // cross-metric agreement guard — see the four-way + // check there. + }; + std::vector d_bins; + + // Decimation: full rate → ~1 kHz sub-audible. + int d_decim_factor; // e.g. 16 (16k/1k) + std::vector d_decim_taps; // anti-alias LPF, ~32 taps, 300 Hz cutoff + std::vector d_decim_state; // FIR delay line + int d_decim_state_idx; + int d_decim_phase; // counts 0..decim_factor-1 + + // Sliding 500 ms window of decimated samples for Goertzel. + int d_window_samples; // e.g. 500 at 1 kHz + std::vector d_window; // ring buffer + int d_window_write_idx; // 0..window_samples-1 + bool d_window_filled; + + // ---- Notched-reference path (verify mode only) ---- + // OpenAudio-style detector: pass the same decimated audio through a + // 4-stage cascaded biquad notch centered on the configured tone (Q=60, + // staggered ±0.18%/±0.45% per stage → ~2 Hz total null width). The + // post-notch signal contains everything in the sub-audible band EXCEPT + // the configured tone, so its power is a "voice / noise reference." + // Per evaluation tick: target_pwr / notched_band_pwr >= NOTCH_REL_THRESH + // is required in addition to the SNR-vs-median test for the gate to + // open and the bin to score. A real sine survives the notch chain + // (target_pwr stays high, notched_pwr collapses → ratio huge); voice + // fundamentals at the configured frequency raise BOTH target_pwr and + // notched_pwr (because voice has broad spectral spread inside the + // 67-254 Hz band) so the ratio stays small. + // + // d_notch_chain is sized 4 (cascaded biquads) when d_configured_pl_freq>0 + // and is empty otherwise — search mode bypasses the notch path entirely + // since "which tone" decisions can't share a single notch. + struct biquad_state { + double b0, b1, b2; // numerator (feed-forward) + double a1, a2; // denominator (feedback), pre-negated form: + // y = b0*x + b1*x1 + b2*x2 + a1*y1 + a2*y2 + double x1, x2; // input delay line + double y1, y2; // output delay line + }; + std::vector d_notch_chain; + std::vector d_notched_window; // parallel ring buffer, same indexing as d_window + + // Evaluation cadence: every ~50 ms of decimated samples. + int d_eval_tick_samples; // e.g. 50 at 1 kHz + int d_samples_since_eval; + long d_total_eval_ticks; + + // Squelch hysteresis state. + float d_open_threshold_db; + float d_close_threshold_db; + int d_open_hangtime_ticks; // # of evals above open thresh required + int d_close_hangtime_ticks; // # of evals below close thresh required + int d_open_streak; + int d_close_streak; + bool d_unmuted; + + // Synchronization for cross-thread accessor reads. + mutable boost::mutex d_mutex; +}; + +} // namespace blocks +} // namespace gr + +#endif // INCLUDED_CTCSS_SQUELCH_FF_H diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc new file mode 100644 index 000000000..77debdd8e --- /dev/null +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc @@ -0,0 +1,723 @@ +/* + * DCS / DPL squelch + identification implementation. See dcs_squelch_ff.h + * for the design overview. + * + * Polynomial choice: g(x) = x^11 + x^10 + x^6 + x^5 + x^4 + x^2 + 1 (0xC75) + * — the one DCS / DPL transmitters use in practice. Verified end-to-end + * against the canonical W9CR D023 codeword. + */ + +#include "dcs_squelch_ff.h" + +#include +#include +#include +#include +#include + +namespace gr { +namespace blocks { + +namespace { + +// Full 112-code DCS table matching Uniden BCD-series scanner coverage: +// the original Motorola DPL 83 codes + 29 extended codes used by modern +// scanners. Same table the old tone_detector_block used; kept here as +// the single source of truth now that tone_detector_block is gone. +const int DCS_CODES[] = { + 6, 7, 15, 17, 21, 23, 25, 26, 27, 31, 32, 36, 43, 47, + 50, 51, 53, 54, 65, 71, 72, 73, 74, 114, 115, 116, 122, 125, + 131, 132, 134, 143, 145, 152, 155, 156, 162, 165, 172, 174, 205, 212, + 223, 225, 226, 232, 243, 244, 245, 246, 251, 252, 255, 261, 263, 265, + 266, 271, 274, 306, 311, 315, 325, 331, 332, 343, 346, 351, 356, 364, + 365, 371, 411, 412, 413, 423, 431, 432, 445, 446, 452, 454, 455, 462, + 464, 465, 466, 503, 506, 516, 523, 526, 532, 546, 565, 606, 612, 624, + 627, 631, 632, 654, 662, 664, 703, 712, 723, 731, 732, 734, 743, 754 +}; + +constexpr double DCS_BIT_RATE = 134.4; +constexpr int N_DCS_PHASES = 16; // ~7.4 samples/bit at 1 kHz detect + // rate → 16 phases bound max phase + // error to ~3%, so at least one + // aligns cleanly within the first + // 23-bit codeword. +constexpr int DCS_DIST_THRESH = 1; // max bit errors per frame +// Sync-aligned matching: per-frame false-positive probability for any of +// 112 codes × 2 polarities at Hamming distance ≤1 is ~0.07% on random +// bits. Two consecutive same-code matches drives per-call false-lock to +// ~0.005%. Each phase tracks match_run independently — only one phase +// needs to see 2 consecutive on the same code+polarity to confirm. +constexpr int DCS_RUN_REQUIRED = 2; +// Stricter criterion for search mode (no operator hint). A continuous +// CTCSS tone sampled at 134.4 baud produces a quasi-periodic bit pattern +// that can land at Hamming distance 1 of a real codeword for several +// consecutive frames; 2 in a row is well within reach. The bit-clock vs +// CTCSS frequency ratio (134.4/118.8 = 1.131…) is irrational, so the +// pattern *eventually* drifts — typically within 4-5 frames the +// alignment shifts enough that Hamming distance exceeds 1. Requiring 4 +// consecutive same-code matches in search mode reduces the CTCSS-aliased +// false-positive rate to near zero without hurting real DCS bursts +// (which lock all 16 phases within 2-3 frames anyway). +constexpr int DCS_RUN_REQUIRED_SEARCH = 4; +constexpr double DCS_HOLD_SECONDS = 0.3; // close-hold after last match +constexpr double DCS_ATTACK_MS = 5.0; +constexpr double DCS_DECAY_MS = 25.0; +// Search-mode stale-lock timeout. At the 1 kHz detection rate, 500 samples +// = 500 ms ≈ 3 frame periods at 134.4 baud. A real DCS signal re-confirms +// roughly every 170 ms per phase × 16 phases = much faster than this, so +// 500 ms of silence in the post-lock matches counter is strong evidence +// that whatever pattern triggered the lock has drifted out of agreement. +constexpr long DCS_STALE_LOCK_SAMPLES = 500; +// Minimum post-lock re-matches required to preserve a lock in +// d_best_* when the watchdog fires. A real transmission produces +// many tens-hundreds of re-matches; a fleeting false lock the +// watchdog catches typically has 0-3 by the time it gets cleared. +// 5 splits these cleanly. +constexpr long DCS_BEST_MIN_MATCHES = 5; + +uint16_t dcs_code_to_bits9(int octal_code) { + int hundreds = (octal_code / 100) % 10; + int tens = (octal_code / 10) % 10; + int ones = (octal_code / 1) % 10; + return static_cast(((hundreds & 0x7) << 6) | + ((tens & 0x7) << 3) | + ((ones & 0x7))); +} + +// Golay (23,12) systematic encoding using generator polynomial +// g(x) = x^11 + x^10 + x^6 + x^5 + x^4 + x^2 + 1 (binary 0xC75). This +// is the polynomial DCS / DPL transmitters use in practice — the other +// degree-11 factor of x^23+1 (0xAE3) generates the bit-reverse code and +// would identify every transmission as the wrong code. Verified against +// the canonical D023 codeword from the W9CR DPL/DCS wiki and eccpage.com. +uint32_t golay_23_12_encode(uint16_t message12) { + const uint32_t gen = 0xC75; + uint32_t reg = static_cast(message12 & 0xFFF) << 11; + for (int i = 22; i >= 11; --i) { + if (reg & (1u << i)) { + reg ^= (gen << (i - 11)); + } + } + uint32_t parity = reg & 0x7FF; + return (static_cast(message12 & 0xFFF) << 11) | parity; +} + +// Build the 23-bit shift-register pattern for one DCS code. Layout +// follows the canonical W9CR/Motorola DCS frame: +// shift bits 0..10 = parity, MSB-first (parity bit 10 → shift bit 0) +// shift bits 11..13 = sync "100" ("1" → bit 11) +// shift bits 14..22 = data, MSB-first (data bit 8 → bit 14) +uint32_t dcs_code_to_pattern(int octal_code) { + uint16_t data9 = dcs_code_to_bits9(octal_code); + uint16_t message12 = static_cast((0x4u << 9) | (data9 & 0x1FF)); + uint32_t parity = golay_23_12_encode(message12) & 0x7FF; + + uint32_t pat = 0; + for (int i = 0; i < 11; ++i) { + if (parity & (1u << i)) pat |= (1u << (10 - i)); + } + pat |= (1u << 11); + for (int i = 0; i < 9; ++i) { + if (data9 & (1u << i)) pat |= (1u << (22 - i)); + } + return pat; +} + +int popcount23(uint32_t v) { + v &= 0x7FFFFFu; +#if defined(__GNUC__) || defined(__clang__) + return __builtin_popcount(v); +#else + int c = 0; while (v) { c += v & 1u; v >>= 1; } return c; +#endif +} + +// Smallest 23-bit value across all rotations of `bits`. Used as a cyclic- +// equivalence key — two codeword patterns share a key iff one is a +// rotation of the other. +uint32_t smallest_rotation_23(uint32_t bits) { + bits &= 0x7FFFFFu; + uint32_t best = bits; + uint32_t cur = bits; + for (int k = 1; k < 23; ++k) { + cur = (((cur >> 1) | ((cur & 1u) << 22)) & 0x7FFFFFu); + if (cur < best) best = cur; + } + return best; +} + +// Per-sample EMA coefficient for a one-pole IIR with time constant tau_s. +double smoothing_alpha(double sample_rate, double tau_seconds) { + if (tau_seconds <= 0.0) return 1.0; + return 1.0 - std::exp(-1.0 / (sample_rate * tau_seconds)); +} + +} // anonymous namespace + +dcs_squelch_ff::sptr dcs_squelch_ff::make(double sample_rate, + int configured_dcs_code, + bool configured_dcs_inverted, + bool gate_audio) { + return gnuradio::get_initial_sptr(new dcs_squelch_ff(sample_rate, + configured_dcs_code, + configured_dcs_inverted, + gate_audio)); +} + +dcs_squelch_ff::dcs_squelch_ff(double sample_rate, + int configured_dcs_code, + bool configured_dcs_inverted, + bool gate_audio) + : sync_block("dcs_squelch_ff", + io_signature::make(1, 1, sizeof(float)), + io_signature::make(1, 1, sizeof(float))), + d_sample_rate(sample_rate), + d_configured_code(configured_dcs_code), + d_configured_inverted(configured_dcs_inverted), + d_gate_audio(gate_audio), + d_detect_rate(1000.0), + d_detect_decim(std::max(1, static_cast(std::round(sample_rate / 1000.0)))), + d_decim_count(0), + d_lpf_state_idx(0), + d_decim_step(DCS_BIT_RATE / 1000.0), + d_configured_class_key_n(0), + d_configured_class_key_i(0), + d_locked_code(0), + d_locked_inverted(false), + d_best_code(0), + d_best_inverted(false), + d_best_matches(0), + d_best_phases(0), + d_post_lock_matches(0), + d_samples_since_last_match(0), + d_gate(0.0), + d_gate_attack(smoothing_alpha(sample_rate, DCS_ATTACK_MS / 1000.0)), + d_gate_decay(smoothing_alpha(sample_rate, DCS_DECAY_MS / 1000.0)), + d_open(false), + d_samples_since_match(0), + d_hold_samples(static_cast(sample_rate * DCS_HOLD_SECONDS)) { + build_dcs_table(); + build_lpf(); + + // HPF runs at the detection rate (1 kHz), applied to the polyphase + // LPF output. 30 Hz cutoff strips DC bias and slow drift; tighter + // cutoffs (tried 80 Hz to kill mains hum) distorted the DCS NRZ + // shape too much — phase diversity handles hum-induced jitter. + d_hpf_a = std::exp(-2.0 * M_PI * 30.0 / d_detect_rate); + d_hpf_prev_x = 0.0; + d_hpf_prev_y = 0.0; + + d_phases.resize(N_DCS_PHASES); + reset(); + + char cfg_buf[24]; + if (d_configured_code == 0) { + snprintf(cfg_buf, sizeof(cfg_buf), "search-mode (any code)"); + } else { + snprintf(cfg_buf, sizeof(cfg_buf), "D%03d%c", + d_configured_code, d_configured_inverted ? 'I' : 'N'); + } + BOOST_LOG_TRIVIAL(debug) + << "dcs_squelch_ff: configured DCS = " << cfg_buf + << " gate_audio=" << (d_gate_audio ? "true" : "false") + << " (cyclic class keys: N=0x" << std::hex << d_configured_class_key_n + << " I=0x" << d_configured_class_key_i << std::dec + << " — gate opens on either)"; +} + +dcs_squelch_ff::~dcs_squelch_ff() = default; + +void dcs_squelch_ff::build_dcs_table() { + const int n = sizeof(DCS_CODES) / sizeof(DCS_CODES[0]); + d_dcs_table.resize(n); + for (int i = 0; i < n; ++i) { + d_dcs_table[i].code = DCS_CODES[i]; + d_dcs_table[i].pattern = dcs_code_to_pattern(DCS_CODES[i]); + } + // Pre-compute cyclic equivalence class keys. + for (int i = 0; i < n; ++i) { + const int code = DCS_CODES[i]; + const uint32_t pN = dcs_code_to_pattern(code); + const uint32_t pI = (~pN) & 0x7FFFFFu; + d_class_key[ {code, false} ] = smallest_rotation_23(pN); + d_class_key[ {code, true } ] = smallest_rotation_23(pI); + } + // Capture BOTH polarity class keys for the configured code. The gate + // will open on a match against either, since on-air polarity is a + // transmitter-hardware artifact (TIA-603-E §1.3.5.9 Modulation Type A + // vs Type B) and not part of the code the operator selected. + if (d_configured_code != 0) { + auto it_n = d_class_key.find( {d_configured_code, false} ); + auto it_i = d_class_key.find( {d_configured_code, true } ); + if (it_n != d_class_key.end()) d_configured_class_key_n = it_n->second; + if (it_i != d_class_key.end()) d_configured_class_key_i = it_i->second; + } +} + +void dcs_squelch_ff::build_lpf() { + // Hamming-windowed low-pass at ~250 Hz cutoff. Transition is 500 Hz so + // the stopband starts at the d_detect_rate Nyquist — alias-free + // decimation by d_detect_decim. + const double fc = 250.0 / d_sample_rate; + const double trans_hz = 500.0; + int n_taps = static_cast(std::ceil(4.0 * d_sample_rate / trans_hz)); + if (n_taps < 31) n_taps = 31; + if ((n_taps & 1) == 0) n_taps += 1; + + d_lpf_taps.assign(n_taps, 0.0); + double sum = 0.0; + for (int i = 0; i < n_taps; ++i) { + int k = i - n_taps / 2; + double sinc = (k == 0) ? (2.0 * fc) + : (std::sin(2.0 * M_PI * fc * k) / (M_PI * k)); + double window = 0.54 - 0.46 * std::cos(2.0 * M_PI * i / (n_taps - 1)); + d_lpf_taps[i] = sinc * window; + sum += d_lpf_taps[i]; + } + if (sum != 0.0) { + for (auto &t : d_lpf_taps) t /= sum; + } + d_lpf_state.assign(n_taps, 0.0); +} + +void dcs_squelch_ff::reset() { + boost::mutex::scoped_lock lock(d_mutex); + std::fill(d_lpf_state.begin(), d_lpf_state.end(), 0.0); + d_lpf_state_idx = 0; + d_decim_count = 0; + d_hpf_prev_x = 0.0; + d_hpf_prev_y = 0.0; + d_locked_code = 0; + d_locked_inverted = false; + d_best_code = 0; + d_best_inverted = false; + d_best_matches = 0; + d_best_phases = 0; + d_post_lock_matches = 0; + d_samples_since_last_match = 0; + d_open = false; + d_samples_since_match = 0; + d_gate = 0.0; + for (int i = 0; i < static_cast(d_phases.size()); ++i) { + auto &p = d_phases[i]; + p.decim_phase = static_cast(i) / d_phases.size(); + p.bit_integ = 0.0; + p.bit_integ_n = 0; + p.shift = 0; + p.bits_seen = 0; + p.frame_anchor = -1; + p.frame_anchor_inv = false; + p.detected_code = 0; + p.detected_inverted = false; + p.match_run = 0; + } +} + +void dcs_squelch_ff::try_dcs_match(phase_state &p) { + // Sync-aligned matching. DCS frame has fixed "100" sync at logical + // positions 12-14 → shift bits 11..13 = (1, 0, 0). Inverted-polarity + // codewords present "011" instead. Accept both. + const uint32_t shift = p.shift & 0x7FFFFFu; + const uint32_t sync_bits = (shift >> 11) & 0x7u; + bool sync_inverted; + uint32_t cmp; + if (sync_bits == 0x1u) { sync_inverted = false; cmp = shift; } + else if (sync_bits == 0x6u) { sync_inverted = true; cmp = (~shift) & 0x7FFFFFu; } + else return; + + // Frame-alignment gate: the codeword cycles every 23 bits and naive + // "100"/"011" subpatterns appear at multiple positions in any cyclic + // codeword. The real sync fires at the same (bits_seen % 23) every + // codeword; cyclic-alias sync detections land at consistent but + // different modular positions. Lock the first match's modular + // position as the frame anchor; ignore sync detections at other + // positions. + const int frame_pos = static_cast(p.bits_seen % 23); + if (p.frame_anchor >= 0) { + if (frame_pos != p.frame_anchor || sync_inverted != p.frame_anchor_inv) { + return; + } + } + + int best_code = 0; + int best_dist = 24; + for (const auto &entry : d_dcs_table) { + const int d = popcount23(cmp ^ entry.pattern); + if (d < best_dist) { best_dist = d; best_code = entry.code; } + } + if (best_dist > DCS_DIST_THRESH || best_code == 0) return; + + // First valid match on this phase locks the frame anchor. + if (p.frame_anchor < 0) { + p.frame_anchor = frame_pos; + p.frame_anchor_inv = sync_inverted; + } + const bool best_inverted = sync_inverted; + if (best_code == p.detected_code && best_inverted == p.detected_inverted) { + ++p.match_run; + } else { + p.detected_code = best_code; + p.detected_inverted = best_inverted; + p.match_run = 1; + } + + // Fast-lock for sub-1s DCS bursts: exact (dist=0) match to a code in + // the same cyclic class as the configured one is conclusive. False- + // match probability for any random 23-bit pattern hitting a specific + // codeword exactly is ~1/8.4M. We accept either polarity's class key + // (see header comment near d_configured_class_key_*). + bool fast_lock = false; + if (best_dist == 0 && d_configured_code != 0) { + auto it = d_class_key.find( {best_code, best_inverted} ); + if (it != d_class_key.end() && + (it->second == d_configured_class_key_n || + it->second == d_configured_class_key_i)) { + fast_lock = true; + } + } + // Stricter run-required threshold in search mode — see DCS_RUN_REQUIRED_SEARCH. + const int run_required = (d_configured_code == 0) + ? DCS_RUN_REQUIRED_SEARCH + : DCS_RUN_REQUIRED; + if ((fast_lock || p.match_run >= run_required) && d_locked_code == 0) { + // Diagnostic one-shot per transmission: log the RAW match so we can see + // what the detector actually identified, regardless of whether the + // configured-class disambiguation below rewrites the labels. This is + // the only place inside the block where "first lock event" is + // unambiguously detectable (d_locked_code is still zero), and it + // fires at most once per reset(). Cheap, useful for tuning. + BOOST_LOG_TRIVIAL(info) + << "dcs_squelch_ff: first lock on D" << std::setw(3) << std::setfill('0') + << p.detected_code << (p.detected_inverted ? "I" : "N") + << " (sync=" << (sync_inverted ? "011/inverted" : "100/normal") + << ", dist=" << best_dist + << ", run=" << p.match_run + << ", fast=" << (fast_lock ? "yes" : "no") + << ", configured=D" << std::setw(3) << std::setfill('0') + << d_configured_code << (d_configured_inverted ? "I" : "N") + << ")" << std::setfill(' '); + + // Cyclic-class disambiguation: + // With operator hint: if the detected pair is cyclically equivalent + // to the configured code in EITHER polarity, report the configured + // form (so D627N on air matches "D031I" channel cleanly, AND so a + // Type-B-modulated D023N transmission — which is bit-for-bit equal + // to D023I — still reports "D023N" when the channel is configured + // for D023N). + // Without hint (search mode): pick the N member of the cyclic + // class — matches Uniden BCD scanner display convention. + int out_code = p.detected_code; + bool out_inverted = p.detected_inverted; + auto it = d_class_key.find( {p.detected_code, p.detected_inverted} ); + if (d_configured_code != 0) { + if (it != d_class_key.end() && + (it->second == d_configured_class_key_n || + it->second == d_configured_class_key_i)) { + out_code = d_configured_code; + out_inverted = d_configured_inverted; + } + } else if (it != d_class_key.end()) { + const uint32_t key = it->second; + for (const auto &kv : d_class_key) { + if (kv.second == key && !kv.first.second) { + out_code = kv.first.first; + out_inverted = false; + break; + } + } + } + d_locked_code = out_code; + d_locked_inverted = out_inverted; + } +} + +void dcs_squelch_ff::process_one(float sample, bool &gate_should_open) { + gate_should_open = false; + + // LPF state ring (cheap per-sample work). + const int n_taps = static_cast(d_lpf_taps.size()); + d_lpf_state[d_lpf_state_idx] = static_cast(sample); + d_lpf_state_idx = (d_lpf_state_idx + 1) % n_taps; + + ++d_decim_count; + if (d_decim_count < d_detect_decim) return; + d_decim_count = 0; + + // ---- Stale-lock watchdog (search mode only) ------------------------- + // Drives the "abandon a hypothesis that stops being confirmed" behaviour + // expected from a real scanner. Increments every detection-rate tick + // while locked; if no re-confirmation arrives within + // DCS_STALE_LOCK_SAMPLES (~500 ms), the lock is treated as stale and the + // detector starts looking again. Limited to search mode because in + // configured-mode the gate logic depends on d_locked_code and we don't + // want a transient gap to close the audio gate mid-transmission. + if (d_configured_code == 0 && d_locked_code != 0) { + ++d_samples_since_last_match; + if (d_samples_since_last_match > DCS_STALE_LOCK_SAMPLES) { + // Before clearing, preserve the lock in d_best_* IF it had + // accumulated enough re-matches to be trustworthy. This makes the + // watchdog safe at end-of-transmission: a clean DCS keyup that + // ended naturally still produces a verdict at end-of-call even + // though no further matches are arriving. Fleeting false locks + // (caught by the watchdog before they accumulated evidence) are + // not preserved. + int locked_phases = 0; + for (const auto &p : d_phases) { + if (p.frame_anchor >= 0) ++locked_phases; + } + const bool worth_saving = d_post_lock_matches >= DCS_BEST_MIN_MATCHES; + const bool better_than_existing = d_post_lock_matches > d_best_matches; + if (worth_saving && better_than_existing) { + d_best_code = d_locked_code; + d_best_inverted = d_locked_inverted; + d_best_matches = d_post_lock_matches; + d_best_phases = locked_phases; + } + + BOOST_LOG_TRIVIAL(info) + << "dcs_squelch_ff: stale lock cleared — was D" + << std::setw(3) << std::setfill('0') << d_locked_code + << (d_locked_inverted ? "I" : "N") + << " with " << d_post_lock_matches << " re-matches, " + << locked_phases << "/" << N_DCS_PHASES << " phases, " + << "no new confirmation in " << d_samples_since_last_match + << " ms; " + << (worth_saving ? "preserved as best-of-call" : "discarded (below threshold)") + << "; resetting all phase anchors to search again" + << std::setfill(' '); + d_locked_code = 0; + d_locked_inverted = false; + d_post_lock_matches = 0; + d_samples_since_last_match = 0; + // Reset per-phase tracking but keep shift register / bits_seen — + // we want the search to continue with fresh frame_anchor decisions + // but not lose the bit-clock recovery state. + for (auto &p : d_phases) { + p.frame_anchor = -1; + p.frame_anchor_inv = false; + p.detected_code = 0; + p.detected_inverted = false; + p.match_run = 0; + } + } + } + + // Anti-alias LPF: convolve taps with delay line (oldest first). + int idx = (d_lpf_state_idx == 0) ? (n_taps - 1) : (d_lpf_state_idx - 1); + double y_lpf = 0.0; + for (int k = 0; k < n_taps; ++k) { + y_lpf += d_lpf_taps[k] * d_lpf_state[idx]; + idx = (idx == 0) ? (n_taps - 1) : (idx - 1); + } + + // HPF strips DC bias / slow drift. + const double hpf_y = d_hpf_a * (d_hpf_prev_y + y_lpf - d_hpf_prev_x); + d_hpf_prev_x = y_lpf; + d_hpf_prev_y = hpf_y; + + // Distribute to every parallel bit-clock phase. + bool any_match_this_tick = false; + for (auto &p : d_phases) { + p.bit_integ += hpf_y; + p.bit_integ_n += 1; + p.decim_phase += d_decim_step; + if (p.decim_phase < 1.0) continue; + p.decim_phase -= 1.0; + + if (p.bit_integ_n <= 0) { + p.bit_integ = 0.0; + continue; + } + const double avg = p.bit_integ / p.bit_integ_n; + const uint32_t bit = (avg >= 0.0) ? 1u : 0u; + p.shift = ((p.shift << 1) | bit) & 0x7FFFFFu; + p.bits_seen++; + p.bit_integ = 0.0; + p.bit_integ_n = 0; + + if (p.bits_seen < 23) continue; + + const int locked_before = d_locked_code; + const int phase_run_before = p.match_run; + const int phase_code_before = p.detected_code; + const bool phase_inv_before = p.detected_inverted; + try_dcs_match(p); + if (d_locked_code != 0 && d_locked_code != locked_before) { + any_match_this_tick = true; + // Initial lock: prime the watchdog so the first interval gets a + // fresh full timeout before it can expire. + d_samples_since_last_match = 0; + } else if (d_locked_code != 0 + && p.match_run > phase_run_before + && p.detected_code == phase_code_before + && p.detected_inverted == phase_inv_before) { + // This phase ACTUALLY incremented match_run on this tick for the + // SAME (code, polarity) it was already tracking — i.e. a genuine + // re-confirmation of the locked code at a new 23-bit frame boundary. + // The previous version compared p.match_run > 0 which was true on + // every per-phase tick after that phase ever had any match, so + // d_post_lock_matches incremented at the per-detect-rate cadence + // (~134 ticks/sec/phase summed over phases) instead of once per + // frame. The buggy old behaviour drove confidence to 1.00 in + // milliseconds for any lock — including spurious CTCSS-tone-aliased + // false positives — so the search-mode tiebreak in analog_recorder + // could never prefer a CTCSS verdict (capped at ~0.95) over a one- + // shot DCS lock. With this fix the counter actually reflects how + // many genuine frames the locked code re-confirmed on, so a real + // DCS stream climbs to 1.0 over its first second of audio while a + // single-frame false lock stays at 0. + any_match_this_tick = true; + ++d_post_lock_matches; + // Fresh re-confirmation — reset the stale-lock watchdog. + d_samples_since_last_match = 0; + } + } + + // Gate decision: + // Configured mode: gate opens when latched code's cyclic class + // matches the configured class in EITHER polarity + // (handles TIA-603-E Type A vs Type B transmitters + // — see d_configured_class_key_* in the header). + // Search mode (configured_code == 0): gate opens on ANY lock. + if (d_locked_code != 0) { + if (d_configured_code == 0) { + gate_should_open = true; + } else { + auto it = d_class_key.find( {d_locked_code, d_locked_inverted} ); + if (it != d_class_key.end() && + (it->second == d_configured_class_key_n || + it->second == d_configured_class_key_i)) { + gate_should_open = true; + } + } + } +} + +int dcs_squelch_ff::work(int noutput_items, + gr_vector_const_void_star &input_items, + gr_vector_void_star &output_items) { + const float *in = static_cast(input_items[0]); + float *out = static_cast(output_items[0]); + + boost::mutex::scoped_lock lock(d_mutex); + + for (int i = 0; i < noutput_items; ++i) { + bool gate_should_open = false; + process_one(in[i], gate_should_open); + + if (gate_should_open) { + d_open = true; + d_samples_since_match = 0; + } else if (d_open) { + ++d_samples_since_match; + if (d_samples_since_match >= d_hold_samples) { + d_open = false; + } + } + + const double target = d_open ? 1.0 : 0.0; + const double alpha = d_open ? d_gate_attack : d_gate_decay; + d_gate += alpha * (target - d_gate); + + // Apply the smoothed gate only when audio-gating is enabled. Detect-only + // / search-mode side-chains pass input through unchanged while still + // running the detector so get_verdict()/is_unmuted() report correctly. + if (d_gate_audio) { + out[i] = in[i] * static_cast(d_gate); + } else { + out[i] = in[i]; + } + } + + return noutput_items; +} + +bool dcs_squelch_ff::is_unmuted() { + boost::mutex::scoped_lock lock(d_mutex); + return d_open; +} + +dcs_squelch_verdict dcs_squelch_ff::get_verdict() const { + boost::mutex::scoped_lock lock(d_mutex); + dcs_squelch_verdict v{}; + + // Composite DCS confidence with two independent factors so it's directly + // comparable to the CTCSS detector's wins/total ratio at the search-mode + // tiebreak in analog_recorder::stop(). + // + // phase_diversity = min(1, locked_phases / EFFECTIVE_PHASES) + // A real DCS NRZ transmission only engages ~half the 16 polyphase + // bit-clock trackers (the half whose decim_phase offset puts the + // integration window in the MIDDLE of each bit period; the other + // half straddle bit boundaries and never accumulate clean bits). + // So EFFECTIVE_PHASES = 8 — a signal that engages ≥8 phases scores + // 1.0. CTCSS-aliased false locks typically only engage 1-3 phases + // so they score 0.1-0.4. This is the single strongest discriminator. + // + // match_rate = min(1, d_post_lock_matches / DCS_CONFIDENCE_FULL_MATCHES) + // How many post-lock re-confirmations of the locked code have + // accumulated, saturating at ~1.7 s of clean reception. With the + // counter fix in process_one this only counts actual same-code + // frame re-matches, not per-tick increments. + // + // Composite: real DCS quickly reaches both factors ≈ 1.0 → conf ≈ 1.0. + // CTCSS false: phase_diversity stays low (1-3/16 = 0.06-0.19) and + // match_rate may also stay modest (the false pattern usually drifts + // out before reaching 10 re-confirms) → conf typically < 0.15. + // Pick the lock to report from. If we have an active lock, use it. + // Otherwise fall back to the watchdog-preserved best-of-call (so a + // transmission that ended cleanly still produces a verdict). If + // neither exists, no DCS was found. + int reported_code; + bool reported_inverted; + long matches; + int phases; + if (d_locked_code != 0) { + reported_code = d_locked_code; + reported_inverted = d_locked_inverted; + matches = d_post_lock_matches; + phases = 0; + for (const auto &p : d_phases) { + if (p.frame_anchor >= 0) ++phases; + } + } else if (d_best_code != 0) { + reported_code = d_best_code; + reported_inverted = d_best_inverted; + matches = d_best_matches; + phases = d_best_phases; + } else { + v.confidence = 0.0f; + return v; + } + v.detected_code = reported_code; + v.detected_inverted = reported_inverted; + // Phase diversity: how many of the 16 polyphase bit-clock trackers + // independently locked their frame anchor on the same code. Empirically + // only ~half the phases ever lock for a real DCS transmission — the + // other half happen to sample near bit boundaries (where the integrator + // gets noisy mid-transition bits) and never accumulate a confident + // pattern. So divide by EFFECTIVE_PHASES rather than the full N_DCS_PHASES + // — a real signal that engages 8-10 phases should read ~1.0, not 0.5. + // CTCSS-aliased false locks typically engage 1-3 phases so they still + // score low (0.1-0.4). + static constexpr int EFFECTIVE_PHASES = 8; + const float phase_diversity = std::min(1.0f, + static_cast(phases) / static_cast(EFFECTIVE_PHASES)); + const float match_rate = std::min(1.0f, + static_cast(matches) / + static_cast(DCS_CONFIDENCE_FULL_MATCHES)); + v.confidence = phase_diversity * match_rate; + auto it = d_class_key.find( {reported_code, reported_inverted} ); + if (it == d_class_key.end()) { + v.aliases.emplace_back(d_locked_code, d_locked_inverted); + return v; + } + const uint32_t key = it->second; + for (const auto &kv : d_class_key) { + if (kv.second == key) v.aliases.push_back(kv.first); + } + return v; +} + +} /* namespace blocks */ +} /* namespace gr */ diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.h b/trunk-recorder/gr_blocks/dcs_squelch_ff.h new file mode 100644 index 000000000..ed2c77d73 --- /dev/null +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.h @@ -0,0 +1,263 @@ +/* + * DCS / DPL squelch + identification. + * + * Single-block source of truth for DCS: + * - Detects on-air DCS bit-stream (16 parallel bit-clock phases, 112-code + * Golay-(23,12) table, both polarities, frame-anchor + cyclic-class + * disambiguation). + * - Gates audio with a smooth EMA fade. In configured mode the gate opens + * when the detected code is cyclically equivalent to the configured one + * (so D031I and D627N — same on-air bit pattern at different rotations — + * both open the gate of a "D031I" channel). In search mode (constructed + * with code=0) the gate opens on the first locked code. + * - Exposes an end-of-call verdict: detected code, polarity, and the full + * cyclic-class alias list. + * + * Replaces the DCS portion of the old tone_detector_block. CTCSS is handled + * separately by ctcss_squelch_ff — the two signal types use very different + * DSP (Goertzel sweep vs Golay-(23,12) bit-stream) and stay in their own + * blocks for clarity and so the analog_recorder chain can pick which one is + * in the audio path based on channel configuration. + */ + +#ifndef INCLUDED_DCS_SQUELCH_FF_H +#define INCLUDED_DCS_SQUELCH_FF_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace gr { +namespace blocks { + +// DCS_CONFIDENCE_FULL_MATCHES: how many post-lock re-confirmations to call +// a DCS detection "fully confident" (verdict.confidence = 1.0). At ~134 +// baud, one re-confirm fires every 23 bits ≈ 170 ms; 10 re-confirms = ~1.7 s +// of sustained lock. A spurious one-shot lock (CTCSS bit-clock aliasing, +// burst noise) seldom produces even 2 re-confirms — those calls show +// confidence ≤ 0.1 and lose to any decent CTCSS verdict in search-mode +// reporting. +static constexpr long DCS_CONFIDENCE_FULL_MATCHES = 10; + +// End-of-call DCS verdict. Caller (analog_recorder) reads this once the +// recording closes to populate the Tone Result log line and the JSON +// `tone` field. +struct dcs_squelch_verdict { + // 0 if no DCS was confidently observed during the call, else the + // detected octal code (decimal-encoded, e.g. 31, 162, 754). + int detected_code; + // Polarity of the detected code (true = inverted / "I" / "R" suffix). + // Meaningful only when detected_code != 0. + bool detected_inverted; + // Every cyclic-class sibling of the detected code in the standard 112- + // code DCS table. Golay (23,12) is cyclic so several (code, polarity) + // pairs share the same on-air bit pattern at different rotations; the + // detector picks one (preferring the configured form when there's an + // operator hint) but the full class is reported here for diagnostics. + // Empty when no DCS was detected. + std::vector> aliases; + // Confidence in the detection (0.0 = no lock, 1.0 = fully confirmed). + // Used by analog_recorder's search-mode reporting to choose between + // CTCSS and DCS verdicts when both fire. Defined as + // min(1.0, frames_matched_since_lock / DCS_CONFIDENCE_FULL_MATCHES) + // where DCS_CONFIDENCE_FULL_MATCHES≈10. A real DCS stream re-matches + // every 23 bits ≈ 170 ms, so a 2 s call reaches ≈1.0. A spurious + // single-frame false lock (CTCSS bit-clock aliasing, etc.) caps out + // near 0.05-0.10 — naturally loses to a strong CTCSS verdict in the + // search-mode comparison. + float confidence; +}; + +class BLOCKS_API dcs_squelch_ff : virtual public sync_block { +public: +#if GNURADIO_VERSION < 0x030900 + typedef boost::shared_ptr sptr; +#else + typedef std::shared_ptr sptr; +#endif + + // sample_rate: input audio rate (Hz, typically wav rate 16k) + // configured_dcs_code: octal code as a decimal integer (0 = search mode) + // configured_dcs_inverted: polarity of the configured code (meaningful + // only when configured_dcs_code != 0) + // gate_audio: when true (default), work() applies the + // smoothed gate to output samples. When false, + // input is passed through unchanged while the + // detector still runs — used for side-chain + // "what tone is this?" identification next to + // a separate gating block, and for SEARCH-mode + // channels where audio is not gated. + static sptr make(double sample_rate, + int configured_dcs_code = 0, + bool configured_dcs_inverted = false, + bool gate_audio = true); + + dcs_squelch_ff(double sample_rate, + int configured_dcs_code, + bool configured_dcs_inverted, + bool gate_audio); + ~dcs_squelch_ff(); + + int work(int noutput_items, + gr_vector_const_void_star &input_items, + gr_vector_void_star &output_items) override; + + // True when the audio gate is currently open (configured DCS or any + // cyclic-class sibling actively matching, plus the close-hold window). + bool is_unmuted(); + + // End-of-call verdict. Safe to call from any thread. + dcs_squelch_verdict get_verdict() const; + + // Reset per-call state (latched lock, per-phase shift registers, gate + // smoother). Called from analog_recorder::start() per transmission. + void reset(); + +private: + double d_sample_rate; + int d_configured_code; // 0 = search mode + bool d_configured_inverted; + bool d_gate_audio; // false = passthrough, true = apply d_gate to output + + // Sub-audible detection rate: bit-clock recovery and codeword matching + // run at this rate, audio I/O stays at d_sample_rate. The LPF is a + // polyphase anti-alias filter — its taps span the full input window + // but only one output is computed per d_detect_decim input samples. + double d_detect_rate; + int d_detect_decim; + int d_decim_count; + + // Anti-alias low-pass filter (Hamming, ~250 Hz cutoff at d_sample_rate). + std::vector d_lpf_taps; + std::vector d_lpf_state; + int d_lpf_state_idx; + + // One-pole IIR high-pass at ~30 Hz strips DC bias and slow drift. Runs + // at d_detect_rate, applied to the polyphase LPF output. + double d_hpf_a; + double d_hpf_prev_x; + double d_hpf_prev_y; + + // 16 parallel integrate-and-dump bit clocks. With ~7.4 samples/bit at + // 1 kHz detection rate, 16 phases bound max phase error to ~3% — at + // least one phase aligns cleanly within the first 23-bit codeword so + // sub-1s DCS bursts can lock on a single frame. + double d_decim_step; + struct phase_state { + double decim_phase; // [0, 1) + double bit_integ; + int bit_integ_n; + uint32_t shift; // last 23 bits, LSB = newest + long bits_seen; + // Frame-alignment anchor: the first sync-aligned valid match locks + // the expected frame position (bits_seen % 23). Subsequent sync + // detections only count if they occur at the same modular position; + // otherwise they're cyclic-alias false-syncs at other positions in + // the codeword that happen to look like "100" or "011". + int frame_anchor; // -1 = not yet locked + bool frame_anchor_inv; // polarity of the locked frame + + // Tracking the candidate code being accumulated on this phase. + int detected_code; + bool detected_inverted; + int match_run; + }; + std::vector d_phases; + + // Standard DCS code table (112 codes covering the standard Motorola + // DPL 83 plus the extended set used by Uniden BCD-series scanners). + struct dcs_entry { + int code; // octal as decimal integer + uint32_t pattern; // 23-bit shift-register pattern (LSB = newest bit) + }; + std::vector d_dcs_table; + + // Cyclic equivalence class key per (code, polarity) pair. Two pairs + // share a key iff their on-air bit patterns are rotations of each + // other — i.e. they look identical on-air at different sync alignments. + std::map, uint32_t> d_class_key; + // Class keys of the configured code for BOTH polarities. TIA-603-E + // §1.3.5.9 Table 6 defines two equally-valid CDCSS Modulation senses: + // Type A: data 1 → +Δf, data 0 → −Δf + // Type B: data 1 → −Δf, data 0 → +Δf + // A radio with a "D023" channel will key Type A OR Type B depending on + // its hardware/firmware. From the receiver's point of view a Type-B + // D023N transmission is indistinguishable from a Type-A D023I one + // (every bit on the wire is flipped). Real-world scanners treat + // polarity as transmitter-determined and gate on EITHER. We do the + // same: keep both class keys so the gate opens for D023 regardless of + // which sense the transmitter is using. The raw detected polarity is + // still reported in the verdict (and the first-lock log line) so the + // operator can tell which sense their radio actually uses. + uint32_t d_configured_class_key_n; // {configured_code, false} + uint32_t d_configured_class_key_i; // {configured_code, true} + + // Latched detection — once any phase confirms a code, we keep it for + // the rest of the recording window so a brief mid-call signal dropout + // doesn't erase a confident earlier identification. + int d_locked_code; + bool d_locked_inverted; + + // Snapshot of the strongest confident lock seen so far this call. When + // the stale-lock watchdog fires (a transmission ended cleanly OR a + // false lock drifted out), the active state in d_locked_* is cleared + // so the detector can search again — but if it had accumulated real + // evidence we save it here. Updated only when d_post_lock_matches at + // unlock time exceeds DCS_BEST_MIN_MATCHES (so brief false locks + // aren't preserved). get_verdict() falls back to this when d_locked_* + // is zero, which fixes the regression where a watchdog clearing a + // good lock at end-of-transmission caused the call verdict to lose + // all DCS evidence. + int d_best_code; + bool d_best_inverted; + long d_best_matches; + int d_best_phases; + + // Confidence counter: every tick where any phase's match_run >= 2 + // re-confirms the latched lock, this increments. Reset to 0 by reset(). + // get_verdict() converts to a [0,1] confidence using + // DCS_CONFIDENCE_FULL_MATCHES. A spurious one-shot lock (CTCSS + // bit-clock aliasing) stays at 0 because no second match_run forms. + long d_post_lock_matches; + + // Stale-lock watchdog (search mode only). Counts detection-rate ticks + // since the last successful re-confirmation while locked. If it exceeds + // a timeout (~500 ms), the lock is treated as stale: d_locked_code is + // cleared, all per-phase frame anchors and match_runs are reset, and + // the detector starts searching again. This mirrors what a real + // scanner does — it never permanently latches on a guess that stops + // being confirmed. CTCSS-aliased false locks fail re-confirmation + // within a few hundred ms because the 134.4/118.8 ratio is irrational + // (the alignment drifts out of phase quickly), so the watchdog catches + // them and lets the detector recover. Disabled in configured mode so a + // real D023N gate isn't briefly forced closed by a transient. + long d_samples_since_last_match; + + // Audio gate state. Smoothed [0,1] level applied per output sample. + double d_gate; // current gate value + double d_gate_attack; // per-sample EMA alpha (rising) + double d_gate_decay; // per-sample EMA alpha (falling) + bool d_open; // logical open/closed (drives target gate) + long d_samples_since_match; + long d_hold_samples; + + mutable boost::mutex d_mutex; + + void build_dcs_table(); + void build_lpf(); + void try_dcs_match(phase_state &p); + // Process one input sample. Returns true if this sample updated + // d_locked_code OR if d_locked_code already qualifies the gate to be + // open for the current channel's configuration. + void process_one(float sample, bool &gate_should_open); +}; + +} /* namespace blocks */ +} /* namespace gr */ + +#endif /* INCLUDED_DCS_SQUELCH_FF_H */ From a539eb5cb8e0f29096db54c18b1a51798a07ce25 Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 18:55:56 -0400 Subject: [PATCH 06/17] reverse polarity for routing for ctcss multi channel. accept the calls channel number instead of the transmissions tg number. Since all calls for multichannl are recorded under the top channels channel id. --- trunk-recorder/call_concluder/call_concluder.cc | 9 +++++++++ trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 2 +- trunk-recorder/gr_blocks/dcs_squelch_ff.cc | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/trunk-recorder/call_concluder/call_concluder.cc b/trunk-recorder/call_concluder/call_concluder.cc index 1fca2d302..ad1276726 100644 --- a/trunk-recorder/call_concluder/call_concluder.cc +++ b/trunk-recorder/call_concluder/call_concluder.cc @@ -1171,6 +1171,15 @@ Call_Data_t Call_Concluder::create_call_data(Call *call, System *sys, const Conf call_info.talkgroup_description = matched->description; call_info.talkgroup_group = matched->group; + // The transmissions were recorded under the primary row's TG (the + // recorder doesn't know the routing target until the tone is + // decoded at end-of-call). Push the routed TG into every + // transmission so the consistency loop below doesn't see a + // Call/Transmission mismatch and clobber our routing. + for (Transmission &t : call_info.transmission_list) { + t.talkgroup = matched->number; + } + // Rebuild the formatted display string to reflect the matched row // so subsequent log lines and the JSON's talkgroup_display field // are consistent. (The earlier "Concluding Recorded Call" and diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc index b4547e63d..8e624c9c7 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -413,7 +413,7 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { const double w_hz = (top_wins_i >= 0) ? d_bins[top_wins_i].freq : 0.0; const double e_hz = (top_energy_i >= 0) ? d_bins[top_energy_i].freq : 0.0; const char *mode = (d_configured_bin >= 0) ? "verify" : "search"; - BOOST_LOG_TRIVIAL(info) + BOOST_LOG_TRIVIAL(debug) << "ctcss_squelch_ff[" << mode << "] verdict@" << (v.detected_hz > 0 ? std::to_string(v.detected_hz) : std::string("none")) << " | total_ticks=" << d_total_eval_ticks diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc index 77debdd8e..bb85acc2e 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc @@ -386,7 +386,7 @@ void dcs_squelch_ff::try_dcs_match(phase_state &p) { // the only place inside the block where "first lock event" is // unambiguously detectable (d_locked_code is still zero), and it // fires at most once per reset(). Cheap, useful for tuning. - BOOST_LOG_TRIVIAL(info) + BOOST_LOG_TRIVIAL(debug) << "dcs_squelch_ff: first lock on D" << std::setw(3) << std::setfill('0') << p.detected_code << (p.detected_inverted ? "I" : "N") << " (sync=" << (sync_inverted ? "011/inverted" : "100/normal") @@ -474,7 +474,7 @@ void dcs_squelch_ff::process_one(float sample, bool &gate_should_open) { d_best_phases = locked_phases; } - BOOST_LOG_TRIVIAL(info) + BOOST_LOG_TRIVIAL(debug) << "dcs_squelch_ff: stale lock cleared — was D" << std::setw(3) << std::setfill('0') << d_locked_code << (d_locked_inverted ? "I" : "N") From 8038f6159b04f8f7c7456239b955855eaf218f24 Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Mon, 18 May 2026 20:22:29 -0400 Subject: [PATCH 07/17] update documentation --- README.md | 2 + docs/CONFIGURE.md | 7 +- docs/SQUELCH.md | 2 + docs/TONE-SQUELCH.md | 192 +++++++++++++++++++++++++++++++++++++++++++ docs/intro.md | 2 + 5 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 docs/TONE-SQUELCH.md diff --git a/README.md b/README.md index f81e1ba3b..fdbe24f26 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ RTL-SDR dongles; HackRF; Ettus USRP B200, B210, B205; BladeRF; Airspy; SDRplay ## Setup * [Configuring a system](docs/CONFIGURE.md) +* [Adjusting Squelch for Conventional Systems](docs/SQUELCH.md) +* [CTCSS / DCS Tone Squelch (analog conventional)](docs/TONE-SQUELCH.md) * [Uploading to OpenMHz](./docs/OpenMHz.md) * [FAQ](docs/FAQ.md) diff --git a/docs/CONFIGURE.md b/docs/CONFIGURE.md index 76a0c64f5..e00d20834 100644 --- a/docs/CONFIGURE.md +++ b/docs/CONFIGURE.md @@ -852,7 +852,7 @@ This file allows for you to specify additional information about conventional ch |-------------|----------|-------| | TG Number | ✔️ | The Talkgroup Number formatted as a decimal number. This has to be the first column | | Frequency | ✔️ | The frequency in MHz or Hz for the channel (decimal point must be used for MHz) | -| Tone | | The CTCSS Tone for the talkgroup. | +| Tone | | Tone-squelch spec. Empty / `0` = no tone. A positive number = CTCSS frequency in Hz (e.g. `118.8`). `D` = DCS, with 3-digit octal code and polarity (e.g. `D023N`, `D131I`). `S` = search mode (record everything, identify the tone in the call metadata). Multiple rows at the same Frequency become one shared recorder routed by detected tone — see [TONE-SQUELCH.md](TONE-SQUELCH.md) for the full feature, multi-row freq groups, and the SKIPPED behavior for non-matching tones. | | Alpha Tag | | A 16 character description that is intended as a shortened display on radio displays | | Description | | A longer description of the talkgroup | | Category | | The category for the Talkgroup | @@ -868,6 +868,11 @@ A **Header Row** is required for the file, with a header provided for each of th | --------- | --------- | -------- | ------------- | ---------------------- | ------ | ------ | ------------------- | ---- | ---- | | 300 | 462275000 | 94.8 | Town A Police | Town A Police Dispatch | Police | Town A | | false | | | 325 | 462275000 | 151.4 | Town B DPW | Town B Trash Dispatch | DPW | Town B | false | | -50 | +| 410 | 155857500 | D023N | County Fire | County Fire Ops | Fire | County | | | -48 | +| 510 | 155250000 | S | FD Paging | Fire Paging Out | Fire | Paging | | | -48 | + +Multiple rows can share the same `Frequency` to route one shared recorder to +different talkgroups by detected tone — see [TONE-SQUELCH.md](TONE-SQUELCH.md). ## unitTagsFile diff --git a/docs/SQUELCH.md b/docs/SQUELCH.md index 1220bcaa6..1e78c2ba1 100644 --- a/docs/SQUELCH.md +++ b/docs/SQUELCH.md @@ -18,6 +18,8 @@ The **Signal Detector** automatically tries to find a power threshold to separat Each Conventional Recorder also has a Squelch setting. Once a Conventional Recorder has been enabled and is receiving samples from the source, it will use a Squelch block and only process samples above a certain power level. This prevent noise from being recorded. The Squelch value needs to be hand tuned, and can be set for the entire Conventional System, or per channel. +For analog channels you can also configure a **sub-audible tone squelch** (CTCSS or DCS) per channel, gate audio on a specific tone, or share one recorder across multiple talkgroups on the same frequency. See [TONE-SQUELCH.md](TONE-SQUELCH.md). + ## Configuring a Conventional System diff --git a/docs/TONE-SQUELCH.md b/docs/TONE-SQUELCH.md new file mode 100644 index 000000000..d310bf42a --- /dev/null +++ b/docs/TONE-SQUELCH.md @@ -0,0 +1,192 @@ +# Tone Squelch (CTCSS / DCS) + +Analog conventional channels often use a sub-audible tone or digital code to share +one RF frequency between multiple talkgroups. Only receivers configured for that +specific tone open audio; everything else stays muted even though they're all +listening to the same carrier. Trunk-recorder supports the same idea via the +**Tone** column in the `channelFile` CSV. + +Three tone families are supported: + +| Family | What it is | Tone column value | +|--------|-----------|-------------------| +| **None** | No tone; record any signal that breaks squelch | empty, `0`, or `0.0` | +| **CTCSS** | Continuous Tone-Coded Squelch System — a sub-audible sine tone (67–254 Hz) | The frequency in Hz, e.g. `118.8`, `151.4` | +| **DCS** | Digital Coded Squelch (a.k.a. DPL/Motorola, CDCSS) — a 134.4 baud Golay codeword | `D` + 3-digit octal code + polarity, e.g. `D023N`, `D131I` | +| **Search** | Detect the tone but don't gate on it — report whatever's on air | `S` or `s` | + +## Modes in detail + +### `0` / empty — no tone + +The recorder records everything the squelch lets through, like a stock +conventional channel before this feature existed. No tone detection runs. + +### CTCSS (e.g. `118.8`) + +Enter the tone frequency in Hz as a number, e.g. `118.8`. trunk-recorder +recognizes the full standard PL set from 67.0 Hz up through 254.1 Hz. +Only transmissions carrying that tone will be recorded. + +### DCS (e.g. `D023N`, `D131I`) + +Enter the 3-digit code (with the leading zero — `D023N`, not `D23N`) +followed by `N` or `I` to match what your code list shows. You'll usually +see `N`; some older radios use `I`. If you're not sure, try `N` first. + +trunk-recorder is forgiving on the `N` vs `I` suffix and will record the +channel regardless of which one the transmitter actually uses. + +One thing worth knowing: DCS has a small number of codes that look +identical over the air — for example, a `D023N` channel will also pick up +`D047I` transmissions, because the two signals are indistinguishable to +any receiver. This is a quirk of the DCS protocol itself, not specific to +trunk-recorder. The pairs are rare and almost never both used in the same +geographic area, but if you ever see unexpected recordings on a DCS +channel, this might be why. + +### Search (`S`) + +The recorder records everything the squelch lets through (no gate), but both +the CTCSS and DCS detectors run side-chain. At end of call, the +identified tone is written to the JSON sidecar and the call log — useful for +discovering what tones are actually in use on a channel before configuring +specific channels for them. + +## End-of-call JSON fields + +Every call's `*.json` sidecar gains three flat fields: + +```json +{ + "tone_mode": "ctcss", // "off" | "ctcss" | "dcs" | "search" + "tone_detected": "118.8", // "" | "" | "D" | "D/D" + "tone_confidence": 0.957 // 0.0 – 1.0 +} +``` + +- `tone_mode` — what the recorder was *configured* to do for this channel. +- `tone_detected` — what was actually heard. Empty when nothing was + identified. In search mode, when a DCS transmission is one of the + ambiguous pairs (see the DCS section above), both possibilities are + reported joined with `/`, e.g. `D023N/D047I`. +- `tone_confidence` — for CTCSS, this is the spectral concentration; for DCS + it's phase diversity × match rate. Use it to threshold consumers downstream. + +For non-analog (P25/DMR/digital) recorders the fields are still emitted but +will always be `"off"` / `""` / `0.0`. + +## Multi-row freq groups + +A common operator scenario: one frequency carries three or more separate +talkgroup labels, each gated by a different tone. Express this directly by +writing one CSV row per (TG, tone) pair, *sharing the same Frequency*: + +```csv +TG Number,Frequency,Tone,Squelch,Alpha Tag,Description,Tag,Category,Enable +203,154325000,D223N,-48,County Fairfield,County Fairfield,County Fire,Fire,true +204,154325000,151.4,-48,County Eaton,County Eaton,County Fire,Fire,true +205,154325000,118.8,-48,County North,County North,County Fire,Fire,true +``` + +Trunk-recorder collapses all rows at the same frequency into a **single +analog recorder running in search mode**, then routes each captured call to +the matching row by detected tone. This is much cheaper than three separate +recorders sharing an antenna, and it correctly produces three independent +"call streams" tagged with the right talkgroup, alpha tag, and category. + +Setup behavior: + +- The first row at a freq is the **primary** — its `Squelch` and + `Signal Detector` settings drive the shared recorder. +- All other rows are **alternates** — only their TG/Alpha/Description/Tag/ + Category metadata is used (their per-row Squelch values are ignored). +- A row with `Tone=0`/empty or `Tone=S` in a group acts as a **catch-all** + for that frequency: any tone (or no tone) that doesn't match a specific + alternate ends up routed to it. + +### What happens when no row matches: SKIPPED + +If the detected tone doesn't match any row's `Tone` value **and** there's no +catch-all row in the group, the call is concluded as **SKIPPED**: + +- No WAV files are kept. +- No plugins fire (no upload, no stream, no log emission to external systems). +- A single log line records what was dropped and why: + + ``` + [system] 17C TG: Freq: 154.325 MHz SKIPPED — detected tone '127.3' did not match any allowed row + ``` + +This makes the multi-row config a **strict allow-list**, not a catch-all. +Use it when you specifically want to ignore traffic on a frequency that +isn't from one of your configured groups. + +If you want the more permissive "record everything, label what we can" +behavior, add a single `Tone=S` row to the group — it acts as a fallback +bucket that catches non-matching transmissions. + +## Examples + +### Single channel with a CTCSS tone + +```csv +TG Number,Frequency,Tone,Alpha Tag,Description,Tag,Category +100,154265000,151.4,FD Disp,Fire Dispatch,Fire,County +``` + +### Single channel with a DCS code + +```csv +TG Number,Frequency,Tone,Alpha Tag,Description,Tag,Category +106,155857500,D162N,FD South,Fire South Ops,Fire,County +``` + +### Search-only channel (discover tones) + +```csv +TG Number,Frequency,Tone,Alpha Tag,Description,Tag,Category +109,155250000,S,FD Paging,Fire Paging Out,Fire,Paging +``` + +Every call on this channel concludes with `tone_detected` populated; no calls +are dropped. + +### Multi-row freq group, strict allow-list + +```csv +TG Number,Frequency,Tone,Alpha Tag,Description,Tag,Category +203,154325000,D223N,County A,County A Fire,Fire,County +204,154325000,151.4,County B,County B Fire,Fire,County +205,154325000,118.8,County C,County C Fire,Fire,County +``` + +Three TGs share 154.325 MHz; only D223N, 151.4 Hz CTCSS, and 118.8 Hz CTCSS +make it to disk. Anything else gets SKIPPED. + +### Multi-row freq group, with catch-all + +```csv +TG Number,Frequency,Tone,Alpha Tag,Description,Tag,Category +203,154325000,D223N,County A,County A Fire,Fire,County +204,154325000,151.4,County B,County B Fire,Fire,County +299,154325000,S,County Misc,County Other Traffic,Fire,County +``` + +D223N and 151.4 Hz are labelled specifically; everything else lands on +TG 299 with the detected tone in the sidecar. + +## Tips + +- The CTCSS detector typically locks within 200–400 ms of carrier; DCS + needs at least one full codeword (~170 ms) plus a confirming repeat, + so very short keyups (< 250 ms) may still record but with empty + `tone_detected`. +- For DCS, don't sweat the `N`/`I` suffix — pick whichever your code list + shows (usually `N`). trunk-recorder will record either polarity. +- The end-of-call diagnostic verdict from each detector is emitted at + `debug` log level; turn it on (`"logLevel": "debug"` in config) when + tuning a new channel. +- The recorder still runs all of trunk-recorder's normal squelch and + signal-detection logic. Tone gating is *additional* — it's evaluated + on audio that has already passed the squelch. diff --git a/docs/intro.md b/docs/intro.md index e5fc190e1..a5545a368 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -51,6 +51,8 @@ RTL-SDR dongles; HackRF; Ettus USRP B200, B210, B205; BladeRF; Airspy ## Setup * [Configuring a system](docs/CONFIGURE.md) +* [Adjusting Squelch for Conventional Systems](docs/SQUELCH.md) +* [CTCSS / DCS Tone Squelch (analog conventional)](docs/TONE-SQUELCH.md) * [FAQ](docs/FAQ.md) ### Troubleshooting From d97c9e7912a68517c8edcd66cb7cbd8ec7d30d4e Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Tue, 19 May 2026 21:30:30 -0400 Subject: [PATCH 08/17] Fix signal detector being stuck open by having it time out if no audio recieved within callTimeout. This makes sense, and helps it stop flapping open/closed or being stuck open all the time and eating CPU --- docs/intro.md | 2 +- trunk-recorder/monitor_systems.cc | 146 +++++++++++++++++++++--------- 2 files changed, 105 insertions(+), 43 deletions(-) diff --git a/docs/intro.md b/docs/intro.md index a5545a368..4710f225c 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -64,6 +64,6 @@ For those not familiar, trunking systems allow a large number of user groups to Most trunking system types (such as SmartNet and P25) set aside one of the radio frequencies as a "control channel" that manages and broadcasts radio frequency assignments. When someone presses the Push to Talk button on their radio, the radio sends a message to the system which then assigns a voice frequency and broadcasts a Channel Grant message about it on the control channel. This lets the radio know what frequency to transmit on and tells other radios set to the same talkgroup to listen. -In order to follow all of the transmissions, Trunk Recorder constantly listens to and decodes the control channel. When a frequency is granted to a talkgroup, Trunk Recorder creates a monitoring process which decodes the portion of the radio spectrum for that frequency from the SDR that is already pulling it in. +In order to follow all the transmissions, Trunk Recorder constantly listens to and decodes the control channel. When a frequency is granted to a talkgroup, Trunk Recorder creates a monitoring process which decodes the portion of the radio spectrum for that frequency from the SDR that is already pulling it in. No message is transmitted on the control channel when a conversation on a talkgroup is over. The monitoring process keeps track of transmissions and if there has been no activity for a specified period, it ends the recording. diff --git a/trunk-recorder/monitor_systems.cc b/trunk-recorder/monitor_systems.cc index dcf48d0ec..d4cfbb7af 100644 --- a/trunk-recorder/monitor_systems.cc +++ b/trunk-recorder/monitor_systems.cc @@ -239,53 +239,115 @@ void print_status(std::vector &sources, std::vector &systems void manage_conventional_call(Call *call, Config &config) { - if (call->get_recorder()) { - // if any recording has happened - - if (call->get_current_length() > 0) { - - BOOST_LOG_TRIVIAL(trace) << "[" << call->get_short_name() << "]\t\033[0;34m" << call->get_call_num() << "C\033[0m Call Length: " << call->get_current_length() << "s\t Idle: " << call->get_recorder()->is_idle() << "\t Squelched: " << call->get_recorder()->is_squelched() << " Idle Count: " << call->get_idle_count(); + if (!call->get_recorder()) return; + Recorder *recorder = call->get_recorder(); + + // P25 Conventional and DMR Conventional recorders are constructed but not + // started during setup_systems (the flow graph has to be unlocked before + // their dynamic-modulation assignment can run). For those, the first + // tick here lazy-starts the recorder. For analog conventional this is + // already done in setup_systems and is_active() is true from the start. + if (!recorder->is_active() && call->get_current_length() == 0) { + recorder->start(call); + call->set_state(RECORDING); + plugman_call_start(call); + BOOST_LOG_TRIVIAL(trace) << "[" << call->get_short_name() + << "]\t\033[0;34m" << call->get_call_num() + << "C\033[0m Starting P25 Conventional Recorder "; + return; + } - // means that the squelch is on and it has stopped recording - if (call->get_recorder()->is_idle()) { - // increase the number of periods it has not been recording for - call->set_noise(call->get_recorder()->get_pwr()); - call->increase_idle_count(); - } else { - call->set_signal(call->get_recorder()->get_pwr()); - if (call->get_idle_count() > 0) { - // if it starts recording again, then reset the idle count - call->reset_idle_count(); - } + // Idle tracking. + // + // Three cases: + // 1. Port enabled. Track idle/active normally — drives the + // stuck-open safety net (port enabled, audio never flowed) AND + // the regular call_timeout conclude path (active call wound + // down). + // 2. Port disabled BUT current_length > 0. The detector just + // released the port after a real call (Phase C bidirectional + // dispatch). The wav_sink still has the call open; we need + // idle ticks to accumulate so the timeout below concludes + // the call and finalises the wav. We treat this as "always + // idle, count up" — the port can't re-open with new audio + // (the detector won't trip on a closed RF input), so it's + // monotonic toward conclude. + // 3. Port disabled AND current_length == 0. Nothing has happened + // yet — no call to conclude, nothing to gate, nothing to + // track. Reset the counter so the next detector-driven open + // starts from a fresh budget (without this, a long disabled + // interval can race with the next enable and fire Stuck-open + // ~70 ms after Enable — observed empirically). + if (recorder->is_enabled()) { + if (recorder->is_idle()) { + call->set_noise(recorder->get_pwr()); + call->increase_idle_count(); + } else { + call->set_signal(recorder->get_pwr()); + if (call->get_idle_count() > 0) { + call->reset_idle_count(); } + } + } else if (call->get_current_length() > 0) { + // Port was open with audio recorded; the detector then closed it + // (Phase C bidirectional disable). Continue counting so the + // timeout below concludes the call. + call->increase_idle_count(); + } else { + // No port, no audio. Hold the counter at zero. + if (call->get_idle_count() > 0) { + call->reset_idle_count(); + } + } - // if no additional recording has happened in the past X periods, stop and open new file - if (call->get_idle_count() > config.call_timeout) { - Recorder *recorder = call->get_recorder(); - call->conclude_call(); - call->restart_call(); - if (recorder != NULL) { - plugman_setup_recorder(recorder); - plugman_call_start(call); - } - } else if ((call->get_current_length() > call->get_system()->get_max_duration()) && (call->get_system()->get_max_duration() > 0)) { - Recorder *recorder = call->get_recorder(); - call->conclude_call(); - call->restart_call(); - if (recorder != NULL) { - plugman_setup_recorder(recorder); - plugman_call_start(call); - } + BOOST_LOG_TRIVIAL(trace) << "[" << call->get_short_name() + << "]\t\033[0;34m" << call->get_call_num() + << "C\033[0m Call Length: " << call->get_current_length() + << "s\t Idle: " << recorder->is_idle() + << "\t Squelched: " << recorder->is_squelched() + << " Idle Count: " << call->get_idle_count(); + + // Timeout fired. Two cases: + // • current_length > 0: a real call wound down — conclude + restart + // for the next transmission (existing behaviour). + // • current_length == 0: signal_detector enabled the port but + // nothing materialised in the channel passband. Disable the + // selector port so the DSP chain stops consuming source samples; + // signal_detector will re-enable on the next real trigger. + if (call->get_idle_count() > config.call_timeout) { + if (call->get_current_length() > 0) { + call->conclude_call(); + call->restart_call(); + if (recorder != NULL) { + plugman_setup_recorder(recorder); + plugman_call_start(call); } - } else if (!call->get_recorder()->is_active()) { - // P25 Conventional and DMR Recorders need a have the graph unlocked before they can start recording. - Recorder *recorder = call->get_recorder(); - recorder->start(call); - call->set_state(RECORDING); + } else if (recorder->is_enabled()) { + // Stuck-open: enabled by signal_detector but no audio reached + // wav_sink. Release the port; signal_detector will re-enable + // on the next trigger. + BOOST_LOG_TRIVIAL(info) << "[" << call->get_short_name() + << "]\t\033[0;34m" << call->get_call_num() + << "C\033[0m Stuck-open recorder on " + << format_freq(call->get_freq()) + << " disabled after " << call->get_idle_count() + << " idle ticks with no audio"; + recorder->set_enabled(false); + call->reset_idle_count(); + } else { + // Idle counter ran past timeout on a recorder that was already + // disabled. Just hold the counter so it doesn't grow unbounded. + call->reset_idle_count(); + } + } else if (call->get_current_length() > 0 + && call->get_system()->get_max_duration() > 0 + && call->get_current_length() > call->get_system()->get_max_duration()) { + // Max-duration cap reached during a long running call. + call->conclude_call(); + call->restart_call(); + if (recorder != NULL) { + plugman_setup_recorder(recorder); plugman_call_start(call); - BOOST_LOG_TRIVIAL(trace) << "[" << call->get_short_name() << "]\t\033[0;34m" << call->get_call_num() << "C\033[0m Starting P25 Convetional Recorder "; - - // plugman_setup_recorder((Recorder *)recorder->get()); } } } From 768903c56e5c6d5e40dcc2533bbffc9606518ecd Mon Sep 17 00:00:00 2001 From: Thegreatcodeholio Date: Tue, 19 May 2026 22:31:44 -0400 Subject: [PATCH 09/17] Update logging for Signal Detector enabled and disables. Show when Time Out, show when we close naturally. --- trunk-recorder/monitor_systems.cc | 19 +++++++++++++------ trunk-recorder/source.cc | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/trunk-recorder/monitor_systems.cc b/trunk-recorder/monitor_systems.cc index d4cfbb7af..ab341bcb5 100644 --- a/trunk-recorder/monitor_systems.cc +++ b/trunk-recorder/monitor_systems.cc @@ -318,6 +318,15 @@ void manage_conventional_call(Call *call, Config &config) { if (call->get_current_length() > 0) { call->conclude_call(); call->restart_call(); + // restart_call() -> recorder->start() re-closes the selector port on + // signal-detection channels; signal_detector re-trips for the next + // transmission. Companion to the "Signal Detector Opened" line. + if (!recorder->is_enabled()) { + BOOST_LOG_TRIVIAL(info) << "\t[ " << recorder->get_num() << " ] " + << recorder->get_type_string() + << "\tSignal Detector Closed - Freq: " << format_freq(recorder->get_freq()) + << "\tSignal Ended"; + } if (recorder != NULL) { plugman_setup_recorder(recorder); plugman_call_start(call); @@ -326,12 +335,10 @@ void manage_conventional_call(Call *call, Config &config) { // Stuck-open: enabled by signal_detector but no audio reached // wav_sink. Release the port; signal_detector will re-enable // on the next trigger. - BOOST_LOG_TRIVIAL(info) << "[" << call->get_short_name() - << "]\t\033[0;34m" << call->get_call_num() - << "C\033[0m Stuck-open recorder on " - << format_freq(call->get_freq()) - << " disabled after " << call->get_idle_count() - << " idle ticks with no audio"; + BOOST_LOG_TRIVIAL(info) << "\t[ " << recorder->get_num() << " ] " + << recorder->get_type_string() + << "\tSignal Detector Closed - Freq: " << format_freq(recorder->get_freq()) + << "\tTimed Out - No Audio"; recorder->set_enabled(false); call->reset_idle_count(); } else { diff --git a/trunk-recorder/source.cc b/trunk-recorder/source.cc index 6a7235f40..f45a0c6ed 100644 --- a/trunk-recorder/source.cc +++ b/trunk-recorder/source.cc @@ -482,7 +482,7 @@ void Source::enable_detected_recorders() { Recorder *recorder = *it; if (!recorder->is_enabled()) { recorder->set_enabled(true); - BOOST_LOG_TRIVIAL(info) << "\t[ " << recorder->get_num() << " ] " << recorder->get_type_string() << "\tEnabled - Freq: " << format_freq(recorder->get_freq()) << "\t Detected Signal: " << floor(rssi) << "dBM (Threshold: " << floor(threshold) << "dBM)"; + BOOST_LOG_TRIVIAL(info) << "\t[ " << recorder->get_num() << " ] " << recorder->get_type_string() << "\tSignal Detector Opened - Freq: " << format_freq(recorder->get_freq()) << "\t Detected Signal: " << floor(rssi) << "dBM (Threshold: " << floor(threshold) << "dBM)"; } } } From 7b850680c805b54079943c62979594d30c86ed0a Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 21 May 2026 10:58:01 -0400 Subject: [PATCH 10/17] move tone functions out of global_structs --- CMakeLists.txt | 1 + trunk-recorder/call_conventional.cc | 1 + trunk-recorder/global_structs.h | 79 ----------------------------- trunk-recorder/talkgroups.cc | 1 + trunk-recorder/tone_manager.cc | 69 +++++++++++++++++++++++++ trunk-recorder/tone_manager.h | 21 ++++++++ 6 files changed, 93 insertions(+), 79 deletions(-) create mode 100644 trunk-recorder/tone_manager.cc create mode 100644 trunk-recorder/tone_manager.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ef8a0b616..da73d9fd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,6 +256,7 @@ list(APPEND trunk_recorder_sources trunk-recorder/formatter.cc trunk-recorder/source.cc trunk-recorder/call_conventional.cc + trunk-recorder/tone_manager.cc trunk-recorder/systems/p25_trunking.cc trunk-recorder/systems/p25_parser.cc trunk-recorder/systems/smartnet_parser.cc diff --git a/trunk-recorder/call_conventional.cc b/trunk-recorder/call_conventional.cc index ea2492f5c..650e1e905 100644 --- a/trunk-recorder/call_conventional.cc +++ b/trunk-recorder/call_conventional.cc @@ -3,6 +3,7 @@ #include "formatter.h" #include "recorders/recorder.h" #include "talkgroup.h" +#include "tone_manager.h" #include #include #include diff --git a/trunk-recorder/global_structs.h b/trunk-recorder/global_structs.h index 7779a3419..1b275dd5d 100644 --- a/trunk-recorder/global_structs.h +++ b/trunk-recorder/global_structs.h @@ -2,8 +2,6 @@ #define GLOBAL_STRUCTS_H #include #include -#include -#include #include #include #include @@ -28,83 +26,6 @@ struct Tone_Config { bool dcs_inverted = false; // valid only when mode == TONE_DCS }; -// True iff two Tone_Configs match for routing purposes (same mode + same -// frequency/code/polarity within tolerance). Used by multi-row freq -// groups in setup_systems/call_conventional/call_concluder when a single -// recorder serves multiple logical channels and we need to map a detected -// tone to the right CSV row. -inline bool tones_match(const Tone_Config &a, const Tone_Config &b) { - if (a.mode != b.mode) return false; - switch (a.mode) { - case TONE_CTCSS: { - const double d = a.ctcss_hz - b.ctcss_hz; - return (d > -0.5 && d < 0.5); - } - case TONE_DCS: - return a.dcs_code == b.dcs_code && a.dcs_inverted == b.dcs_inverted; - case TONE_OFF: - case TONE_SEARCH: - return true; - } - return false; -} - -// Parse a Tone column value into a Tone_Config. Accepted forms: -// "" or "0" / "0.0" -> TONE_OFF -// "S" / "s" -> TONE_SEARCH -// strict /D\d{3}[NI]/i -> TONE_DCS (3-digit octal, polarity required) -// any positive numeric (CTCSS Hz) -> TONE_CTCSS -// Anything else returns TONE_OFF with no warning here — the caller is -// expected to detect TONE_OFF when it was unintended and log. -inline Tone_Config parse_tone_spec(const std::string &raw) { - Tone_Config tc; // default OFF - if (raw.empty()) return tc; - - // Trim whitespace - size_t a = 0; - size_t b = raw.size(); - while (a < b && std::isspace(static_cast(raw[a]))) ++a; - while (b > a && std::isspace(static_cast(raw[b - 1]))) --b; - if (a == b) return tc; - const std::string s = raw.substr(a, b - a); - - // Search mode - if (s.size() == 1 && (s[0] == 'S' || s[0] == 's')) { - tc.mode = TONE_SEARCH; - return tc; - } - - // DCS: strict "D" + exactly 3 octal digits + 'N'/'n'/'I'/'i' - if ((s[0] == 'D' || s[0] == 'd') && s.size() == 5) { - bool ok = true; - for (int i = 1; i <= 3; ++i) { - char c = s[i]; - if (c < '0' || c > '7') { ok = false; break; } - } - char pol = s[4]; - if (ok && (pol == 'N' || pol == 'n' || pol == 'I' || pol == 'i')) { - tc.mode = TONE_DCS; - tc.dcs_code = ((s[1] - '0') * 100) + ((s[2] - '0') * 10) + (s[3] - '0'); - tc.dcs_inverted = (pol == 'I' || pol == 'i'); - return tc; - } - // Falls through to OFF below if malformed. - return tc; - } - - // CTCSS: any positive number. strtod tolerates ints and floats. - char *endp = nullptr; - const double v = std::strtod(s.c_str(), &endp); - if (endp != s.c_str() && *endp == '\0' && v > 0.0) { - tc.mode = TONE_CTCSS; - tc.ctcss_hz = v; - return tc; - } - - // Unrecognized — leave as OFF (caller can log if surprising). - return tc; -} - struct Transmission { long source; long talkgroup; diff --git a/trunk-recorder/talkgroups.cc b/trunk-recorder/talkgroups.cc index bd493c88c..09ea29949 100644 --- a/trunk-recorder/talkgroups.cc +++ b/trunk-recorder/talkgroups.cc @@ -1,4 +1,5 @@ #include "talkgroups.h" +#include "tone_manager.h" #include #include diff --git a/trunk-recorder/tone_manager.cc b/trunk-recorder/tone_manager.cc new file mode 100644 index 000000000..38e13a8ff --- /dev/null +++ b/trunk-recorder/tone_manager.cc @@ -0,0 +1,69 @@ +#include "tone_manager.h" + +#include +#include + +bool tones_match(const Tone_Config &a, const Tone_Config &b) { + if (a.mode != b.mode) return false; + switch (a.mode) { + case TONE_CTCSS: { + const double d = a.ctcss_hz - b.ctcss_hz; + return (d > -0.5 && d < 0.5); + } + case TONE_DCS: + return a.dcs_code == b.dcs_code && a.dcs_inverted == b.dcs_inverted; + case TONE_OFF: + case TONE_SEARCH: + return true; + } + return false; +} + +Tone_Config parse_tone_spec(const std::string &raw) { + Tone_Config tc; // default OFF + if (raw.empty()) return tc; + + // Trim whitespace + size_t a = 0; + size_t b = raw.size(); + while (a < b && std::isspace(static_cast(raw[a]))) ++a; + while (b > a && std::isspace(static_cast(raw[b - 1]))) --b; + if (a == b) return tc; + const std::string s = raw.substr(a, b - a); + + // Search mode + if (s.size() == 1 && (s[0] == 'S' || s[0] == 's')) { + tc.mode = TONE_SEARCH; + return tc; + } + + // DCS: strict "D" + exactly 3 octal digits + 'N'/'n'/'I'/'i' + if ((s[0] == 'D' || s[0] == 'd') && s.size() == 5) { + bool ok = true; + for (int i = 1; i <= 3; ++i) { + char c = s[i]; + if (c < '0' || c > '7') { ok = false; break; } + } + char pol = s[4]; + if (ok && (pol == 'N' || pol == 'n' || pol == 'I' || pol == 'i')) { + tc.mode = TONE_DCS; + tc.dcs_code = ((s[1] - '0') * 100) + ((s[2] - '0') * 10) + (s[3] - '0'); + tc.dcs_inverted = (pol == 'I' || pol == 'i'); + return tc; + } + // Falls through to OFF below if malformed. + return tc; + } + + // CTCSS: any positive number. strtod tolerates ints and floats. + char *endp = nullptr; + const double v = std::strtod(s.c_str(), &endp); + if (endp != s.c_str() && *endp == '\0' && v > 0.0) { + tc.mode = TONE_CTCSS; + tc.ctcss_hz = v; + return tc; + } + + // Unrecognized — leave as OFF (caller can log if surprising). + return tc; +} diff --git a/trunk-recorder/tone_manager.h b/trunk-recorder/tone_manager.h new file mode 100644 index 000000000..fb9520487 --- /dev/null +++ b/trunk-recorder/tone_manager.h @@ -0,0 +1,21 @@ +#ifndef TONE_MANAGER_H +#define TONE_MANAGER_H + +#include "global_structs.h" +#include + +// Returns true iff two Tone_Configs match for routing purposes (same mode + +// same frequency/code/polarity within tolerance). Used when a single recorder +// serves multiple logical channels and we need to map a detected tone to the +// right CSV row. +bool tones_match(const Tone_Config &a, const Tone_Config &b); + +// Parses a Tone column value into a Tone_Config. Accepted forms: +// "" or "0" / "0.0" -> TONE_OFF +// "S" / "s" -> TONE_SEARCH +// strict /D\d{3}[NI]/i -> TONE_DCS (3-digit octal, polarity required) +// any positive numeric (CTCSS Hz) -> TONE_CTCSS +// Anything else returns TONE_OFF — the caller is expected to detect that and log. +Tone_Config parse_tone_spec(const std::string &raw); + +#endif From 98a5d58c44c15014990763f5cf5fa398ad64a080 Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 21 May 2026 12:01:21 -0400 Subject: [PATCH 11/17] Start moving OOT blocks into their own namespace Co-authored-by: Copilot --- trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 4 ++-- trunk-recorder/gr_blocks/ctcss_squelch_ff.h | 4 ++-- trunk-recorder/gr_blocks/dcs_squelch_ff.cc | 4 ++-- trunk-recorder/gr_blocks/dcs_squelch_ff.h | 4 ++-- trunk-recorder/recorders/analog_recorder.cc | 12 ++++++------ trunk-recorder/recorders/analog_recorder.h | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc index 8e624c9c7..8ef52b44c 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -31,7 +31,7 @@ #include namespace gr { -namespace blocks { +namespace trunkrecorder { namespace { @@ -638,5 +638,5 @@ int ctcss_squelch_ff::work(int noutput_items, return noutput_items; } -} // namespace blocks +} // namespace trunkrecorder } // namespace gr diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h index 3022cd30d..05df2ae03 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h @@ -45,7 +45,7 @@ #include namespace gr { -namespace blocks { +namespace trunkrecorder { struct ctcss_squelch_verdict { // 0.0 if no confident lock during the call, else the detected CTCSS @@ -197,7 +197,7 @@ class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { mutable boost::mutex d_mutex; }; -} // namespace blocks +} // namespace trunkrecorder } // namespace gr #endif // INCLUDED_CTCSS_SQUELCH_FF_H diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc index bb85acc2e..64e338559 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc @@ -16,7 +16,7 @@ #include namespace gr { -namespace blocks { +namespace trunkrecorder { namespace { @@ -719,5 +719,5 @@ dcs_squelch_verdict dcs_squelch_ff::get_verdict() const { return v; } -} /* namespace blocks */ +} /* namespace trunkrecorder */ } /* namespace gr */ diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.h b/trunk-recorder/gr_blocks/dcs_squelch_ff.h index ed2c77d73..c847e5aa5 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.h +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.h @@ -33,7 +33,7 @@ #include namespace gr { -namespace blocks { +namespace trunkrecorder { // DCS_CONFIDENCE_FULL_MATCHES: how many post-lock re-confirmations to call // a DCS detection "fully confident" (verdict.confidence = 1.0). At ~134 @@ -257,7 +257,7 @@ class BLOCKS_API dcs_squelch_ff : virtual public sync_block { void process_one(float sample, bool &gate_should_open); }; -} /* namespace blocks */ +} /* namespace trunkrecorder */ } /* namespace gr */ #endif /* INCLUDED_DCS_SQUELCH_FF_H */ diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index 42357bdcf..8bcd4c84e 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -150,23 +150,23 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type case TONE_CTCSS: // CTCSS gates the audio path; DCS detector runs as a side-chain so we // still spot a wrong-agency DCS keyup at end-of-call. - ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, static_cast(tone_config.ctcss_hz), true); - dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); + ctcss_block = gr::trunkrecorder::ctcss_squelch_ff::make(wav_sample_rate, static_cast(tone_config.ctcss_hz), true); + dcs_block = gr::trunkrecorder::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); ctcss_block_in_path = true; dcs_block_in_path = false; break; case TONE_DCS: // DCS gates the audio path; CTCSS detector runs as a side-chain. - dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, tone_config.dcs_code, tone_config.dcs_inverted, true); - ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); + dcs_block = gr::trunkrecorder::dcs_squelch_ff::make(wav_sample_rate, tone_config.dcs_code, tone_config.dcs_inverted, true); + ctcss_block = gr::trunkrecorder::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); ctcss_block_in_path = false; dcs_block_in_path = true; break; case TONE_SEARCH: // No gating; both detectors run as side-chains so end-of-call can // report whichever scored higher. - ctcss_block = gr::blocks::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); - dcs_block = gr::blocks::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); + ctcss_block = gr::trunkrecorder::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); + dcs_block = gr::trunkrecorder::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); ctcss_block_in_path = false; dcs_block_in_path = false; break; diff --git a/trunk-recorder/recorders/analog_recorder.h b/trunk-recorder/recorders/analog_recorder.h index 5ddcfe525..02e6943fb 100644 --- a/trunk-recorder/recorders/analog_recorder.h +++ b/trunk-recorder/recorders/analog_recorder.h @@ -173,10 +173,10 @@ class analog_recorder : public gr::hier_block2, public Recorder { // In SEARCH mode neither gates and both run as side-chains; in OFF mode // none are instantiated. See analog_recorder.cc constructor for the // four-way wiring decision. - gr::blocks::ctcss_squelch_ff::sptr ctcss_block; - gr::blocks::dcs_squelch_ff::sptr dcs_block; - gr::blocks::null_sink::sptr ctcss_null_sink; - gr::blocks::null_sink::sptr dcs_null_sink; + gr::trunkrecorder::ctcss_squelch_ff::sptr ctcss_block; + gr::trunkrecorder::dcs_squelch_ff::sptr dcs_block; + gr::blocks::null_sink::sptr ctcss_null_sink; + gr::blocks::null_sink::sptr dcs_null_sink; bool ctcss_block_in_path = false; // true when ctcss_block is the main-path gate bool dcs_block_in_path = false; // true when dcs_block is the main-path gate From 742550a7accf0f0bf4bb66b468d07d1f2b3788fb Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 21 May 2026 15:07:20 -0400 Subject: [PATCH 12/17] prune dead code Co-authored-by: Copilot --- trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 57 ++++++-------------- trunk-recorder/gr_blocks/ctcss_squelch_ff.h | 30 +---------- 2 files changed, 18 insertions(+), 69 deletions(-) diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc index 8ef52b44c..9abd098b3 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -1,7 +1,7 @@ /* * CTCSS detector + audio gate implementation. * - * Algorithm summary (see CTCSS_DETECTOR_V2.md for the full design rationale): + * Algorithm summary: * 1. Internal anti-alias LPF + decimation from full audio rate (e.g. 16 kHz) * to ~1 kHz sub-audible band rate. * 2. Sliding rectangular 200 ms Goertzel window per CTCSS standard frequency @@ -27,7 +27,6 @@ #include #include -#include #include namespace gr { @@ -39,8 +38,7 @@ namespace { // tone_detector_block.cc — kept here as a self-contained copy so this // block doesn't depend on tone_detector_block (we want them // independently buildable / testable). -constexpr int N_CTCSS = 50; -constexpr double CTCSS_FREQS[N_CTCSS] = { +constexpr double CTCSS_FREQS[ctcss_squelch_ff::N_CTCSS] = { 67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5, 94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3, 131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9, @@ -83,26 +81,6 @@ constexpr float DEFAULT_CLOSE_THRESH_DB = 4.0f; constexpr int DEFAULT_OPEN_HANG_MS = 100; constexpr int DEFAULT_CLOSE_HANG_MS = 150; // TIA-603 reverse-burst spec -// Notched-reference threshold (verify mode only). Target-bin power must -// exceed this multiple of the post-notch band power for the gate to open -// and the tick to be scored. OpenAudio's default is threshRel=1.0; our -// 3.0 is tighter because (a) we have a 16-bit SDR with good SNR, (b) -// short bench calls don't tolerate transient false matches well, and -// (c) the 4-stage Q=60 notch gives a deeper null than OpenAudio's -// Teensy version. A real CTCSS sine yields target/notched ratios of -// 30-100×; sustained voice fundamentals on the configured bin yield -// 1-3× because the notch only nulls one specific frequency, leaving -// the broadband voice spectrum to fill the reference. -constexpr double NOTCH_REL_THRESH = 3.0; - -// Calibration factor that makes a pure sine input of the same amplitude -// produce equal target and notched-reference powers in the absence of -// the notch. Matches the 2.04 factor in OpenAudio analyze_CTCSS_F32.cpp -// (line 98). Without this, a 1.0-amplitude sine would read powerTone=0.5 -// (Goertzel) vs powerSum=0.5 (time-domain mean square) before the notch -// design's 2× scaling — the factor reconciles units. -constexpr double NOTCH_REF_CAL = 2.04; - // Designs a small Hann-windowed low-pass FIR for the internal decimation // stage. We keep this self-contained so the block has no external filter // dependencies. cutoff_hz = ~300 Hz, transition ~150 Hz, ~32 taps gives @@ -197,13 +175,6 @@ ctcss_squelch_ff::ctcss_squelch_ff(double sample_rate, float configured_pl_freq, d_samples_since_eval = 0; d_total_eval_ticks = 0; - // Notched-reference path (4-cascade Q=60 biquad notch + post-notch power) - // was prototyped here and removed. See evaluate_goertzels() comment for - // why the technique works in OpenAudio's full-audio-band setup but not - // in ours (where the upstream chain already LPFs to 300 Hz). Leaving - // the d_notch_chain / d_notched_window vectors empty keeps the work() - // hot path branch-predictor friendly. - BOOST_LOG_TRIVIAL(debug) << "ctcss_squelch_ff: configured_pl=" << d_configured_pl_freq << " Hz" << " (bin=" << d_configured_bin << ")" @@ -226,12 +197,6 @@ void ctcss_squelch_ff::reset() { } std::fill(d_window.begin(), d_window.end(), 0.0f); std::fill(d_decim_state.begin(), d_decim_state.end(), 0.0f); - if (!d_notched_window.empty()) { - std::fill(d_notched_window.begin(), d_notched_window.end(), 0.0f); - } - for (auto &b : d_notch_chain) { - b.x1 = b.x2 = b.y1 = b.y2 = 0.0; - } d_window_write_idx = 0; d_window_filled = false; d_decim_state_idx = 0; @@ -398,9 +363,7 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { const bool energy_dominant = (top_energy_i >= 0 && top_energy > second_energy * 1.5 && top_energy > median_energy * 5.0); // ≥5× - const bool consistent = enough_wins; // legacy name kept for the diag log - - if (agree && consistent && win_dominant && energy_dominant && concentrated) { + if (agree && enough_wins && win_dominant && energy_dominant && concentrated) { v.detected_hz = static_cast(d_bins[top_wins_i].freq); v.confidence = static_cast(top_wins) / static_cast(d_total_eval_ticks); v.dominant_snr_db = static_cast(d_bins[top_wins_i].cum_score / d_bins[top_wins_i].win_ticks); @@ -416,6 +379,7 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { BOOST_LOG_TRIVIAL(debug) << "ctcss_squelch_ff[" << mode << "] verdict@" << (v.detected_hz > 0 ? std::to_string(v.detected_hz) : std::string("none")) + << " dom_snr=" << v.dominant_snr_db << "dB" << " | total_ticks=" << d_total_eval_ticks << " win_winner=" << w_hz << "Hz (" << top_wins << " wins, 2nd=" << second_wins << ")" << " energy_winner=" << e_hz << "Hz" @@ -425,7 +389,18 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { << " energy_dom(>=5x)=" << energy_dominant << " concentrated(>=0.10)=" << concentrated << " (e_top=" << top_energy << " e_2nd=" << second_energy - << " e_med=" << median_energy << " concentration=" << concentration << ")"; + << " e_med=" << median_energy << " concentration=" << concentration << ")" + << " top3:[" + << [&]() { + std::string s; + for (size_t i = 0; i < v.top_three.size(); ++i) { + if (i) s += ", "; + s += std::to_string(v.top_three[i].first) + "Hz/" + + std::to_string(v.top_three[i].second) + "dB"; + } + return s; + }() + << "]"; return v; } diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h index 05df2ae03..44b4e17b7 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h @@ -89,6 +89,8 @@ class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { // ungated). static sptr make(double sample_rate, float configured_pl_freq = 0.0f, bool gate_audio = true); + static constexpr int N_CTCSS = 50; + ctcss_squelch_ff(double sample_rate, float configured_pl_freq, bool gate_audio); ~ctcss_squelch_ff(); @@ -126,7 +128,6 @@ class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { bool d_gate_audio; // true: zero output when muted; false: passthrough // Pre-built immutable tables. - static constexpr int N_CTCSS = 50; struct bin_state { double freq; // standard CTCSS Hz double coeff; // 2 * cos(2*pi*f/decim_rate) @@ -152,33 +153,6 @@ class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { int d_window_write_idx; // 0..window_samples-1 bool d_window_filled; - // ---- Notched-reference path (verify mode only) ---- - // OpenAudio-style detector: pass the same decimated audio through a - // 4-stage cascaded biquad notch centered on the configured tone (Q=60, - // staggered ±0.18%/±0.45% per stage → ~2 Hz total null width). The - // post-notch signal contains everything in the sub-audible band EXCEPT - // the configured tone, so its power is a "voice / noise reference." - // Per evaluation tick: target_pwr / notched_band_pwr >= NOTCH_REL_THRESH - // is required in addition to the SNR-vs-median test for the gate to - // open and the bin to score. A real sine survives the notch chain - // (target_pwr stays high, notched_pwr collapses → ratio huge); voice - // fundamentals at the configured frequency raise BOTH target_pwr and - // notched_pwr (because voice has broad spectral spread inside the - // 67-254 Hz band) so the ratio stays small. - // - // d_notch_chain is sized 4 (cascaded biquads) when d_configured_pl_freq>0 - // and is empty otherwise — search mode bypasses the notch path entirely - // since "which tone" decisions can't share a single notch. - struct biquad_state { - double b0, b1, b2; // numerator (feed-forward) - double a1, a2; // denominator (feedback), pre-negated form: - // y = b0*x + b1*x1 + b2*x2 + a1*y1 + a2*y2 - double x1, x2; // input delay line - double y1, y2; // output delay line - }; - std::vector d_notch_chain; - std::vector d_notched_window; // parallel ring buffer, same indexing as d_window - // Evaluation cadence: every ~50 ms of decimated samples. int d_eval_tick_samples; // e.g. 50 at 1 kHz int d_samples_since_eval; From e864c5a24fd0a4264da53b615f51eb41dc3c214d Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 21 May 2026 17:05:30 -0400 Subject: [PATCH 13/17] remove mutex from work() loops Co-authored-by: Copilot --- trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 8 +------- trunk-recorder/gr_blocks/ctcss_squelch_ff.h | 20 ++++++++++++++------ trunk-recorder/gr_blocks/dcs_squelch_ff.cc | 4 +--- trunk-recorder/gr_blocks/dcs_squelch_ff.h | 5 ++++- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc index 9abd098b3..ab5ba369c 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -209,30 +209,25 @@ void ctcss_squelch_ff::reset() { } void ctcss_squelch_ff::set_open_threshold_db(float db) { - boost::mutex::scoped_lock lock(d_mutex); d_open_threshold_db = db; } void ctcss_squelch_ff::set_close_threshold_db(float db) { - boost::mutex::scoped_lock lock(d_mutex); d_close_threshold_db = db; } void ctcss_squelch_ff::set_open_hangtime_ms(int ms) { - boost::mutex::scoped_lock lock(d_mutex); const double decim_rate = d_sample_rate / d_decim_factor; d_open_hangtime_ticks = std::max(1, static_cast(std::round(ms / 1000.0 * decim_rate / d_eval_tick_samples))); } void ctcss_squelch_ff::set_close_hangtime_ms(int ms) { - boost::mutex::scoped_lock lock(d_mutex); const double decim_rate = d_sample_rate / d_decim_factor; d_close_hangtime_ticks = std::max(1, static_cast(std::round(ms / 1000.0 * decim_rate / d_eval_tick_samples))); } bool ctcss_squelch_ff::is_unmuted() const { - boost::mutex::scoped_lock lock(d_mutex); - return d_unmuted; + return d_unmuted.load(std::memory_order_acquire); } ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { @@ -568,7 +563,6 @@ int ctcss_squelch_ff::work(int noutput_items, const float *in = static_cast(input_items[0]); float *out = static_cast(output_items[0]); - boost::mutex::scoped_lock lock(d_mutex); for (int i = 0; i < noutput_items; ++i) { // Step 1: feed the decimating LPF. diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h index 44b4e17b7..859469657 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.h +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.h @@ -37,6 +37,7 @@ #ifndef INCLUDED_CTCSS_SQUELCH_FF_H #define INCLUDED_CTCSS_SQUELCH_FF_H +#include #include #include #include @@ -159,15 +160,22 @@ class BLOCKS_API ctcss_squelch_ff : virtual public sync_block { long d_total_eval_ticks; // Squelch hysteresis state. - float d_open_threshold_db; - float d_close_threshold_db; - int d_open_hangtime_ticks; // # of evals above open thresh required - int d_close_hangtime_ticks; // # of evals below close thresh required + // Threshold / hangtime fields are written by setters (control thread) and + // read by work() (scheduler thread). Declared atomic so work() can read + // them without holding d_mutex — which in turn lets us drop the wide mutex + // lock that previously spanned the entire sample loop. + std::atomic d_open_threshold_db; + std::atomic d_close_threshold_db; + std::atomic d_open_hangtime_ticks; // # of evals above open thresh required + std::atomic d_close_hangtime_ticks; // # of evals below close thresh required int d_open_streak; int d_close_streak; - bool d_unmuted; + // Written by work() via update_squelch_state(); read by is_unmuted() and + // get_verdict() from the control thread. Atomic so is_unmuted() needs no lock. + std::atomic d_unmuted; - // Synchronization for cross-thread accessor reads. + // Serializes reset() against get_verdict() on the control thread. + // NOT held by work() — see update_squelch_state() and the field comments above. mutable boost::mutex d_mutex; }; diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc index 64e338559..cd7c00ebc 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc @@ -599,7 +599,6 @@ int dcs_squelch_ff::work(int noutput_items, const float *in = static_cast(input_items[0]); float *out = static_cast(output_items[0]); - boost::mutex::scoped_lock lock(d_mutex); for (int i = 0; i < noutput_items; ++i) { bool gate_should_open = false; @@ -633,8 +632,7 @@ int dcs_squelch_ff::work(int noutput_items, } bool dcs_squelch_ff::is_unmuted() { - boost::mutex::scoped_lock lock(d_mutex); - return d_open; + return d_open.load(std::memory_order_acquire); } dcs_squelch_verdict dcs_squelch_ff::get_verdict() const { diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.h b/trunk-recorder/gr_blocks/dcs_squelch_ff.h index c847e5aa5..48ecfcfce 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.h +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.h @@ -23,6 +23,7 @@ #ifndef INCLUDED_DCS_SQUELCH_FF_H #define INCLUDED_DCS_SQUELCH_FF_H +#include #include #include #include @@ -242,7 +243,9 @@ class BLOCKS_API dcs_squelch_ff : virtual public sync_block { double d_gate; // current gate value double d_gate_attack; // per-sample EMA alpha (rising) double d_gate_decay; // per-sample EMA alpha (falling) - bool d_open; // logical open/closed (drives target gate) + // Written by work(); read by is_unmuted() from the control thread. + // Atomic so is_unmuted() needs no lock. + std::atomic d_open; // logical open/closed (drives target gate) long d_samples_since_match; long d_hold_samples; From 8b10b13174035bf0210c7290149ceab8b4d3a626 Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 21 May 2026 17:36:03 -0400 Subject: [PATCH 14/17] remove side-chain analysis in single tone modes Co-authored-by: Copilot --- trunk-recorder/recorders/analog_recorder.cc | 21 ++++----------------- trunk-recorder/recorders/analog_recorder.h | 14 ++++++++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index 8bcd4c84e..64535dbd3 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -148,18 +148,11 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type // No gate, no detector. Audio passes through unchanged from decim_audio. break; case TONE_CTCSS: - // CTCSS gates the audio path; DCS detector runs as a side-chain so we - // still spot a wrong-agency DCS keyup at end-of-call. ctcss_block = gr::trunkrecorder::ctcss_squelch_ff::make(wav_sample_rate, static_cast(tone_config.ctcss_hz), true); - dcs_block = gr::trunkrecorder::dcs_squelch_ff::make(wav_sample_rate, 0, false, false); ctcss_block_in_path = true; - dcs_block_in_path = false; break; case TONE_DCS: - // DCS gates the audio path; CTCSS detector runs as a side-chain. dcs_block = gr::trunkrecorder::dcs_squelch_ff::make(wav_sample_rate, tone_config.dcs_code, tone_config.dcs_inverted, true); - ctcss_block = gr::trunkrecorder::ctcss_squelch_ff::make(wav_sample_rate, 0.0f, false); - ctcss_block_in_path = false; dcs_block_in_path = true; break; case TONE_SEARCH: @@ -231,10 +224,7 @@ analog_recorder::analog_recorder(Source *src, System *system, Recorder_Type type // Audio gate. The tone block (if any) sits between decim_audio and the // downstream branches so both the wav writer and the decoder_sink see the - // gated signal — matches the prior behaviour where ctcss_squelch_ff sat - // before decim_audio. The side-chain detector (if present) taps - // decim_audio directly so it can still see ungated audio and identify - // wrong-agency keyups even when the configured gate is closed. + // gated signal. if (ctcss_block_in_path) { connect(decim_audio, 0, ctcss_block, 0); connect(ctcss_block, 0, decoder_sink, 0); @@ -363,16 +353,13 @@ void analog_recorder::stop() { } } - // Pick the verdict to report. Prefer the configured-gate block when both - // fire (operator's expectation: a CTCSS-configured channel should show - // CTCSS unless DCS clearly won the call). For SEARCH mode it's purely - // higher confidence. + // Pick the verdict to report. CTCSS and DCS configured modes each have + // only one block so there is nothing to arbitrate. SEARCH mode runs both + // and picks by higher confidence. if (tone_config.mode == TONE_CTCSS) { if (!ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } - else if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } } else if (tone_config.mode == TONE_DCS) { if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } - else if (!ctcss_det.empty()) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; } } else if (tone_config.mode == TONE_SEARCH) { // Search-mode tiebreak: simple max-confidence comparison. With the // phase-diversity DCS confidence now in place, a CTCSS-aliased false diff --git a/trunk-recorder/recorders/analog_recorder.h b/trunk-recorder/recorders/analog_recorder.h index 02e6943fb..67efe262e 100644 --- a/trunk-recorder/recorders/analog_recorder.h +++ b/trunk-recorder/recorders/analog_recorder.h @@ -167,12 +167,14 @@ class analog_recorder : public gr::hier_block2, public Recorder { gr::filter::fir_filter_fff::sptr low_f; gr::analog::pwr_squelch_ff::sptr squelch_two; - // Sub-audible squelch / identification blocks. At most one acts as a gate - // (sits in the audio path between decim_audio and high_f); the other - // optionally runs as a side-chain detector terminated in a null_sink. - // In SEARCH mode neither gates and both run as side-chains; in OFF mode - // none are instantiated. See analog_recorder.cc constructor for the - // four-way wiring decision. + // Sub-audible squelch / identification blocks. + // CTCSS: only ctcss_block, wired as the main-path gate. + // DCS: only dcs_block, wired as the main-path gate. + // SEARCH: both blocks, neither gating — each taps decim_audio and drains + // into its own null_sink; get_verdict() is called at end-of-call + // and the higher-confidence result wins. + // OFF: neither instantiated. + // See analog_recorder.cc constructor for the wiring. gr::trunkrecorder::ctcss_squelch_ff::sptr ctcss_block; gr::trunkrecorder::dcs_squelch_ff::sptr dcs_block; gr::blocks::null_sink::sptr ctcss_null_sink; From b8a07560dfe01193c8bb724fd68162b6b186cb42 Mon Sep 17 00:00:00 2001 From: Taclane Date: Fri, 22 May 2026 20:17:18 -0400 Subject: [PATCH 15/17] split single-tone CTCSS from search/multi modes --- trunk-recorder/gr_blocks/ctcss_squelch_ff.cc | 187 +++++++++++++------ 1 file changed, 125 insertions(+), 62 deletions(-) diff --git a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc index ab5ba369c..1cb7b0cb3 100644 --- a/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/ctcss_squelch_ff.cc @@ -1,24 +1,23 @@ /* * CTCSS detector + audio gate implementation. * - * Algorithm summary: - * 1. Internal anti-alias LPF + decimation from full audio rate (e.g. 16 kHz) - * to ~1 kHz sub-audible band rate. - * 2. Sliding rectangular 200 ms Goertzel window per CTCSS standard frequency - * (50 bins). Re-evaluated every 50 ms. - * 3. Adaptive noise floor = median of the 50 bin powers (sub-audible - * ambient — voice harmonics, FM noise). Per-bin SNR (dB) computed - * relative to this floor. - * 4. Live squelch: hysteresis on the *configured* bin's SNR — open - * when ≥ d_open_threshold_db for d_open_hangtime_ticks consecutive - * eval ticks, close when ≤ d_close_threshold_db for - * d_close_hangtime_ticks consecutive ticks. Free-scan: same but on - * whichever bin is currently the winner. - * 5. Identification: cumulative scoring per bin — sum of SNR-dB across - * every tick where that bin was the winner above the close threshold. - * End-of-call verdict picks the bin with the highest cumulative - * score (energy-weighted, robust against voice harmonics intermittently - * stealing windows). + * Two operating modes selected at construction time: + * + * DECODE (configured_pl_freq maps to a known CTCSS standard, d_configured_bin >= 0): + * Single-bin path. Only the configured CTCSS frequency and its two + * immediate table-neighbours are computed per tick. The neighbours + * provide a local spectral floor reference (not a pass/fail guard). + * Window: 150 ms (7 Hz BW — fine; target freq is known). + * Tick: 50 ms → first eval at ~150 ms → gate-open possible by ~200 ms. + * Verdict: win_ticks > 0 → return configured Hz + confidence; no guards. + * + * SEARCH (configured_pl_freq == 0): + * Full 50-bin path. Every CTCSS standard frequency is computed each tick. + * Adaptive noise floor = median of all 50 bin powers. + * Adjacent-bin guard rejects voice-fundamental smear. + * Window: 500 ms (2 Hz BW — needed to separate adjacent CTCSS standards). + * Tick: 100 ms → first eval at ~500 ms. + * Verdict: four-guard test (agree, enough_wins, win_dominant, energy_dominant). */ #include "ctcss_squelch_ff.h" @@ -51,21 +50,20 @@ constexpr double CTCSS_FREQS[ctcss_squelch_ff::N_CTCSS] = { // well within Nyquist for the LPF. constexpr double DECIM_TARGET_RATE = 1000.0; -// Goertzel window: 500 ms at the decimated rate gives 2 Hz noise bandwidth. -// Narrow enough that adjacent CTCSS standards (2.3-7 Hz spaced at the low -// end) and voice fundamentals (drifting 80-180 Hz for typical male speech) -// land in distinct bins. The previous 200 ms / 5 Hz bandwidth blurred -// voice fundamental in with neighbouring CTCSS bins which let the -// "winner" bounce around and prevented the verdict guards from ever -// finding a sustained dominant bin. Matches the original tone_detector_ -// block's window length, which was tuned over many bench iterations. -constexpr double WINDOW_DURATION_S = 0.500; - -// Re-evaluate the squelch decision every 100 ms. With a 500 ms window -// this gives 4× overlap (75% shared audio across consecutive ticks) — -// still responsive (~200 ms open latency) without 4× the per-tick cost -// the 50 ms cadence used to incur. -constexpr double EVAL_TICK_S = 0.100; +// Search mode: 500 ms window / 100 ms tick (2 Hz noise BW). +// Required to separate adjacent CTCSS standards (min spacing 2.3 Hz at +// the low end). Previous 200 ms / 5 Hz blurred voice fundamental into +// neighbouring bins and caused the winner to bounce. +constexpr double WINDOW_DURATION_SEARCH_S = 0.500; +constexpr double EVAL_TICK_SEARCH_S = 0.100; + +// Decode mode: 150 ms window / 50 ms tick (7 Hz noise BW). +// The target frequency is already known so fine bin separation is not +// needed. Shorter window means the first eval fires at ~150 ms, enabling +// gate-open on sub-500 ms transmissions that would previously never reach +// the first eval tick and return verdict=none. +constexpr double WINDOW_DURATION_DECODE_S = 0.150; +constexpr double EVAL_TICK_DECODE_S = 0.050; // Score-above-threshold bar for the cumulative tally. A bin must beat // floor by this many dB to "win" a tick. Significantly above the open @@ -131,11 +129,31 @@ ctcss_squelch_ff::ctcss_squelch_ff(double sample_rate, float configured_pl_freq, d_decim_factor = std::max(1, static_cast(std::round(d_sample_rate / DECIM_TARGET_RATE))); const double decim_rate = d_sample_rate / d_decim_factor; + // Find the configured bin BEFORE timing selection so that is_decode + // derives from d_configured_bin >= 0 rather than configured_pl_freq > 0. + // The two conditions diverge if a non-standard PL frequency is passed: + // > 0 would select decode timing while the code falls through to the + // search path — wrong window width. CTCSS_FREQS is a compile-time + // constant so we can scan it here without building d_bins first. + if (configured_pl_freq > 0.0f) { + for (int i = 0; i < N_CTCSS; ++i) { + if (std::abs(CTCSS_FREQS[i] - configured_pl_freq) < 1.0) { + d_configured_bin = i; + break; + } + } + } + + // Select window/tick timing based on mode. + const bool is_decode = (d_configured_bin >= 0); + const double window_s = is_decode ? WINDOW_DURATION_DECODE_S : WINDOW_DURATION_SEARCH_S; + const double tick_s = is_decode ? EVAL_TICK_DECODE_S : EVAL_TICK_SEARCH_S; + // Goertzel window in decimated samples. - d_window_samples = std::max(32, static_cast(std::round(WINDOW_DURATION_S * decim_rate))); + d_window_samples = std::max(32, static_cast(std::round(window_s * decim_rate))); // Eval tick in decimated samples. - d_eval_tick_samples = std::max(1, static_cast(std::round(EVAL_TICK_S * decim_rate))); + d_eval_tick_samples = std::max(1, static_cast(std::round(tick_s * decim_rate))); // Squelch hangtimes from ms → eval ticks. d_open_hangtime_ticks = std::max(1, static_cast(std::round(DEFAULT_OPEN_HANG_MS / 1000.0 * decim_rate / d_eval_tick_samples))); @@ -157,17 +175,6 @@ ctcss_squelch_ff::ctcss_squelch_ff(double sample_rate, float configured_pl_freq, d_bins[i].energy_sum = 0.0; } - // Find the configured bin (within 1 Hz) so the squelch knows which - // Goertzel result to drive the gating decision off. - if (d_configured_pl_freq > 0.0f) { - for (int i = 0; i < N_CTCSS; ++i) { - if (std::abs(d_bins[i].freq - d_configured_pl_freq) < 1.0) { - d_configured_bin = i; - break; - } - } - } - // Pre-allocate the sliding-window ring buffer (decimated samples). d_window.assign(d_window_samples, 0.0f); d_window_write_idx = 0; @@ -176,12 +183,13 @@ ctcss_squelch_ff::ctcss_squelch_ff(double sample_rate, float configured_pl_freq, d_total_eval_ticks = 0; BOOST_LOG_TRIVIAL(debug) - << "ctcss_squelch_ff: configured_pl=" << d_configured_pl_freq << " Hz" + << "ctcss_squelch_ff[" << (is_decode ? "decode" : "search") << "]" + << " configured_pl=" << d_configured_pl_freq << " Hz" << " (bin=" << d_configured_bin << ")" << " gate_audio=" << (d_gate_audio ? "true" : "false") << " decim=" << d_decim_factor << "x → " << decim_rate << " Hz" - << " window=" << d_window_samples << " samples (" << WINDOW_DURATION_S * 1000 << " ms)" - << " eval-every=" << d_eval_tick_samples << " samples (" << EVAL_TICK_S * 1000 << " ms)" + << " window=" << d_window_samples << " samples (" << window_s * 1000.0 << " ms)" + << " eval-every=" << d_eval_tick_samples << " samples (" << tick_s * 1000.0 << " ms)" << " open=" << d_open_threshold_db << " dB / " << DEFAULT_OPEN_HANG_MS << " ms" << " close=" << d_close_threshold_db << " dB / " << DEFAULT_CLOSE_HANG_MS << " ms"; } @@ -239,6 +247,27 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { if (d_total_eval_ticks <= 0) return v; + // --- DECODE MODE: no 50-bin scan needed. The gate opening is already the + // detection confirmation; win_ticks > 0 means the configured tone was + // observed above SCORING_THRESHOLD_DB on at least one eval tick. --- + if (d_configured_bin >= 0) { + const auto &cb = d_bins[d_configured_bin]; + if (cb.win_ticks > 0) { + v.detected_hz = static_cast(CTCSS_FREQS[d_configured_bin]); + v.confidence = static_cast(cb.win_ticks) / static_cast(d_total_eval_ticks); + v.dominant_snr_db = static_cast(cb.cum_score / cb.win_ticks); + } + BOOST_LOG_TRIVIAL(debug) + << "ctcss_squelch_ff[decode] verdict@" + << (v.detected_hz > 0.0f ? std::to_string(v.detected_hz) : std::string("none")) + << " dom_snr=" << v.dominant_snr_db << "dB" + << " | total_ticks=" << d_total_eval_ticks + << " win_ticks=" << cb.win_ticks + << " conf=" << v.confidence; + return v; + } + + // --- SEARCH MODE: full 50-bin scan + verdict guards --- // Build top-three (by win_ticks then cum_score) for diagnostics regardless // of whether the strict guards below pass — useful when a call doesn't // produce a confident verdict but the operator wants to see what the @@ -333,12 +362,12 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { ? d_bins[top_energy_i].energy_sum / total_energy : 0.0; static constexpr double MIN_CONCENTRATION_SEARCH = 0.10; - const bool concentrated = (d_configured_bin >= 0) // verify mode: skip the check + const bool concentrated = (d_configured_bin >= 0) // decode mode: skip the check ? true : (concentration >= MIN_CONCENTRATION_SEARCH); // Verdict guards. The per-tick adjacent-bin guards (and notch-ratio in - // verify mode) already filter voice fundamentals out at the tick + // decode mode) already filter voice fundamentals out at the tick // level — only HIGH-CONFIDENCE ticks ever increment win_ticks. So the // verdict's job is no longer to second-guess questionable wins; it's // to confirm there are enough confirmed wins AND that they're not a @@ -370,9 +399,8 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { // settled. const double w_hz = (top_wins_i >= 0) ? d_bins[top_wins_i].freq : 0.0; const double e_hz = (top_energy_i >= 0) ? d_bins[top_energy_i].freq : 0.0; - const char *mode = (d_configured_bin >= 0) ? "verify" : "search"; BOOST_LOG_TRIVIAL(debug) - << "ctcss_squelch_ff[" << mode << "] verdict@" + << "ctcss_squelch_ff[search] verdict@" << (v.detected_hz > 0 ? std::to_string(v.detected_hz) : std::string("none")) << " dom_snr=" << v.dominant_snr_db << "dB" << " | total_ticks=" << d_total_eval_ticks @@ -400,17 +428,52 @@ ctcss_squelch_verdict ctcss_squelch_ff::get_verdict() const { } void ctcss_squelch_ff::evaluate_goertzels() { - // Read the sliding window in time order (oldest first). The ring buffer's - // newest sample is at d_window_write_idx-1; oldest is at d_window_write_idx. - // For each bin, run a fresh Goertzel over the window's d_window_samples - // values. This is the simplest "rectangular sliding window" — recompute - // each tick. Cost: 50 bins × 200 samples × 2 ops = 20k ops per eval. + // Don't evaluate until the window has been filled at least once. + if (!d_window_filled) return; - std::vector powers(N_CTCSS, 0.0); + // --- VERIFY MODE: configured frequency known — 3-bin path --- + // Only the configured bin and its two immediate CTCSS-table neighbours + // are computed. + if (d_configured_bin >= 0) { + auto goertzel = [&](int i) -> double { + const double C = d_bins[i].coeff; + double s1 = 0.0, s2 = 0.0; + int ri = d_window_write_idx; + for (int n = 0; n < d_window_samples; ++n) { + const double tmp = d_window[ri] + C * s1 - s2; + s2 = s1; + s1 = tmp; + if (++ri >= d_window_samples) ri = 0; + } + const double e = s1 * s1 + s2 * s2 - C * s1 * s2; + return std::max(e, 1e-12); + }; - // Don't evaluate until the window has been filled at least once — pre-fill - // values are zero and would skew the median floor calculation. - if (!d_window_filled) return; + const double cfg_power = goertzel(d_configured_bin); + + double floor_sum = 0.0; + int floor_n = 0; + if (d_configured_bin > 0) { floor_sum += goertzel(d_configured_bin - 1); ++floor_n; } + if (d_configured_bin < N_CTCSS-1) { floor_sum += goertzel(d_configured_bin + 1); ++floor_n; } + const double floor_lin = std::max(floor_n > 0 ? floor_sum / floor_n : cfg_power, 1e-12); + + // Silence gate: skip tick if total power is near-zero (upstream squelch ramp). + if (cfg_power + floor_sum < 1e-4) return; + + const double snr_lin = cfg_power / floor_lin; + const float snr_db = static_cast(10.0 * std::log10(std::max(snr_lin, 1e-12))); + + if (snr_db >= SCORING_THRESHOLD_DB) { + d_bins[d_configured_bin].cum_score += snr_db; + d_bins[d_configured_bin].win_ticks += 1; + } + d_total_eval_ticks += 1; + update_squelch_state(d_configured_bin, snr_db); + return; + } + + // --- SEARCH MODE: full 50-bin path --- + std::vector powers(N_CTCSS, 0.0); for (int i = 0; i < N_CTCSS; ++i) { const double C = d_bins[i].coeff; From 02bc53dcfdd3d1715cb296e60c3608f7434ae041 Mon Sep 17 00:00:00 2001 From: Taclane Date: Wed, 27 May 2026 12:22:32 -0400 Subject: [PATCH 16/17] DCS fixes --- trunk-recorder/gr_blocks/dcs_squelch_ff.cc | 206 ++++++++++---------- trunk-recorder/gr_blocks/dcs_squelch_ff.h | 41 ++-- trunk-recorder/recorders/analog_recorder.cc | 23 +-- 3 files changed, 129 insertions(+), 141 deletions(-) diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc index cd7c00ebc..f8603a450 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.cc +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.cc @@ -180,8 +180,9 @@ dcs_squelch_ff::dcs_squelch_ff(double sample_rate, d_decim_count(0), d_lpf_state_idx(0), d_decim_step(DCS_BIT_RATE / 1000.0), - d_configured_class_key_n(0), - d_configured_class_key_i(0), + d_configured_class_key(0), + d_configured_match_count(0), + d_first_decode_match_logged(false), d_locked_code(0), d_locked_inverted(false), d_best_code(0), @@ -220,9 +221,7 @@ dcs_squelch_ff::dcs_squelch_ff(double sample_rate, BOOST_LOG_TRIVIAL(debug) << "dcs_squelch_ff: configured DCS = " << cfg_buf << " gate_audio=" << (d_gate_audio ? "true" : "false") - << " (cyclic class keys: N=0x" << std::hex << d_configured_class_key_n - << " I=0x" << d_configured_class_key_i << std::dec - << " — gate opens on either)"; + << " cyclic_key=0x" << std::hex << d_configured_class_key << std::dec; } dcs_squelch_ff::~dcs_squelch_ff() = default; @@ -242,15 +241,14 @@ void dcs_squelch_ff::build_dcs_table() { d_class_key[ {code, false} ] = smallest_rotation_23(pN); d_class_key[ {code, true } ] = smallest_rotation_23(pI); } - // Capture BOTH polarity class keys for the configured code. The gate - // will open on a match against either, since on-air polarity is a - // transmitter-hardware artifact (TIA-603-E §1.3.5.9 Modulation Type A - // vs Type B) and not part of the code the operator selected. + // Single polarity-strict class key. Cyclic rotations at the configured + // polarity are accepted (e.g. D047I when configured D023N because they + // are the same physical bit sequence at different frame offsets). + // The complementary polarity class is a distinct code set — intentionally + // rejected. See d_configured_class_key in the header. if (d_configured_code != 0) { - auto it_n = d_class_key.find( {d_configured_code, false} ); - auto it_i = d_class_key.find( {d_configured_code, true } ); - if (it_n != d_class_key.end()) d_configured_class_key_n = it_n->second; - if (it_i != d_class_key.end()) d_configured_class_key_i = it_i->second; + auto it = d_class_key.find( {d_configured_code, d_configured_inverted} ); + if (it != d_class_key.end()) d_configured_class_key = it->second; } } @@ -293,8 +291,10 @@ void dcs_squelch_ff::reset() { d_best_inverted = false; d_best_matches = 0; d_best_phases = 0; - d_post_lock_matches = 0; - d_samples_since_last_match = 0; + d_post_lock_matches = 0; + d_configured_match_count = 0; + d_first_decode_match_logged = false; + d_samples_since_last_match = 0; d_open = false; d_samples_since_match = 0; d_gate = 0.0; @@ -361,62 +361,44 @@ void dcs_squelch_ff::try_dcs_match(phase_state &p) { p.match_run = 1; } - // Fast-lock for sub-1s DCS bursts: exact (dist=0) match to a code in - // the same cyclic class as the configured one is conclusive. False- - // match probability for any random 23-bit pattern hitting a specific - // codeword exactly is ~1/8.4M. We accept either polarity's class key - // (see header comment near d_configured_class_key_*). - bool fast_lock = false; - if (best_dist == 0 && d_configured_code != 0) { + // Fast-lock for decode mode: any match within the acceptance threshold + // (dist ≤ DCS_DIST_THRESH = 1) against the configured cyclic class + // immediately elevates this phase to gate-open eligible without waiting + // for a second consecutive frame. In decode mode the code is already + // known, so a single dist≤1 frame is strong evidence (~2.9×10⁻⁶ + // false-positive probability per frame for a random 23-bit window). + // This guarantees gate-open within one frame period (~171 ms) — well + // under the ETSI TS 103 236 §4.3 350 ms DCS decoder response limit. + // Search mode uses the two-frame accumulator below (no fast-lock) so + // the stricter DCS_RUN_REQUIRED_SEARCH guard still applies there. + if (best_dist <= DCS_DIST_THRESH && d_configured_code != 0) { auto it = d_class_key.find( {best_code, best_inverted} ); - if (it != d_class_key.end() && - (it->second == d_configured_class_key_n || - it->second == d_configured_class_key_i)) { - fast_lock = true; + if (it != d_class_key.end() && it->second == d_configured_class_key) { + p.match_run = DCS_RUN_REQUIRED; // gate-open eligible this tick } } - // Stricter run-required threshold in search mode — see DCS_RUN_REQUIRED_SEARCH. - const int run_required = (d_configured_code == 0) - ? DCS_RUN_REQUIRED_SEARCH - : DCS_RUN_REQUIRED; - if ((fast_lock || p.match_run >= run_required) && d_locked_code == 0) { - // Diagnostic one-shot per transmission: log the RAW match so we can see - // what the detector actually identified, regardless of whether the - // configured-class disambiguation below rewrites the labels. This is - // the only place inside the block where "first lock event" is - // unambiguously detectable (d_locked_code is still zero), and it - // fires at most once per reset(). Cheap, useful for tuning. + + // Search mode: latch the first confirmed code so the gate can open and + // post-lock re-confirmation tracking can begin. Decode mode uses a + // live phase scan in process_one() — no latch needed or used here. + if (d_configured_code != 0) return; + + if (p.match_run >= DCS_RUN_REQUIRED_SEARCH && d_locked_code == 0) { BOOST_LOG_TRIVIAL(debug) - << "dcs_squelch_ff: first lock on D" << std::setw(3) << std::setfill('0') + << "dcs_squelch_ff[search]: first lock on D" << std::setw(3) << std::setfill('0') << p.detected_code << (p.detected_inverted ? "I" : "N") << " (sync=" << (sync_inverted ? "011/inverted" : "100/normal") << ", dist=" << best_dist << ", run=" << p.match_run - << ", fast=" << (fast_lock ? "yes" : "no") - << ", configured=D" << std::setw(3) << std::setfill('0') - << d_configured_code << (d_configured_inverted ? "I" : "N") << ")" << std::setfill(' '); - // Cyclic-class disambiguation: - // With operator hint: if the detected pair is cyclically equivalent - // to the configured code in EITHER polarity, report the configured - // form (so D627N on air matches "D031I" channel cleanly, AND so a - // Type-B-modulated D023N transmission — which is bit-for-bit equal - // to D023I — still reports "D023N" when the channel is configured - // for D023N). - // Without hint (search mode): pick the N member of the cyclic - // class — matches Uniden BCD scanner display convention. + // N-canonical disambiguation for search-mode display (matches + // Uniden BCD scanner convention: always report the N-polarity member + // of the cyclic class). int out_code = p.detected_code; bool out_inverted = p.detected_inverted; auto it = d_class_key.find( {p.detected_code, p.detected_inverted} ); - if (d_configured_code != 0) { - if (it != d_class_key.end() && - (it->second == d_configured_class_key_n || - it->second == d_configured_class_key_i)) { - out_code = d_configured_code; - out_inverted = d_configured_inverted; - } - } else if (it != d_class_key.end()) { + if (it != d_class_key.end()) { const uint32_t key = it->second; for (const auto &kv : d_class_key) { if (kv.second == key && !kv.first.second) { @@ -516,7 +498,7 @@ void dcs_squelch_ff::process_one(float sample, bool &gate_should_open) { d_hpf_prev_y = hpf_y; // Distribute to every parallel bit-clock phase. - bool any_match_this_tick = false; + bool cfg_match_this_tick = false; // decode mode gate trigger for (auto &p : d_phases) { p.bit_integ += hpf_y; p.bit_integ_n += 1; @@ -542,55 +524,48 @@ void dcs_squelch_ff::process_one(float sample, bool &gate_should_open) { const int phase_code_before = p.detected_code; const bool phase_inv_before = p.detected_inverted; try_dcs_match(p); - if (d_locked_code != 0 && d_locked_code != locked_before) { - any_match_this_tick = true; - // Initial lock: prime the watchdog so the first interval gets a - // fresh full timeout before it can expire. - d_samples_since_last_match = 0; - } else if (d_locked_code != 0 - && p.match_run > phase_run_before - && p.detected_code == phase_code_before - && p.detected_inverted == phase_inv_before) { - // This phase ACTUALLY incremented match_run on this tick for the - // SAME (code, polarity) it was already tracking — i.e. a genuine - // re-confirmation of the locked code at a new 23-bit frame boundary. - // The previous version compared p.match_run > 0 which was true on - // every per-phase tick after that phase ever had any match, so - // d_post_lock_matches incremented at the per-detect-rate cadence - // (~134 ticks/sec/phase summed over phases) instead of once per - // frame. The buggy old behaviour drove confidence to 1.00 in - // milliseconds for any lock — including spurious CTCSS-tone-aliased - // false positives — so the search-mode tiebreak in analog_recorder - // could never prefer a CTCSS verdict (capped at ~0.95) over a one- - // shot DCS lock. With this fix the counter actually reflects how - // many genuine frames the locked code re-confirmed on, so a real - // DCS stream climbs to 1.0 over its first second of audio while a - // single-frame false lock stays at 0. - any_match_this_tick = true; - ++d_post_lock_matches; - // Fresh re-confirmation — reset the stale-lock watchdog. - d_samples_since_last_match = 0; - } - } - - // Gate decision: - // Configured mode: gate opens when latched code's cyclic class - // matches the configured class in EITHER polarity - // (handles TIA-603-E Type A vs Type B transmitters - // — see d_configured_class_key_* in the header). - // Search mode (configured_code == 0): gate opens on ANY lock. - if (d_locked_code != 0) { if (d_configured_code == 0) { - gate_should_open = true; + // Search mode: track d_locked_code changes for confidence + watchdog. + if (d_locked_code != 0 && d_locked_code != locked_before) { + d_samples_since_last_match = 0; + } else if (d_locked_code != 0 + && p.match_run > phase_run_before + && p.detected_code == phase_code_before + && p.detected_inverted == phase_inv_before) { + ++d_post_lock_matches; + d_samples_since_last_match = 0; + } } else { - auto it = d_class_key.find( {d_locked_code, d_locked_inverted} ); - if (it != d_class_key.end() && - (it->second == d_configured_class_key_n || - it->second == d_configured_class_key_i)) { - gate_should_open = true; + // Decode mode: check if this phase now confirms the configured class. + if (p.match_run >= DCS_RUN_REQUIRED) { + auto ck = d_class_key.find({p.detected_code, p.detected_inverted}); + if (ck != d_class_key.end() && ck->second == d_configured_class_key) + cfg_match_this_tick = true; } } } + + if (cfg_match_this_tick) { + ++d_configured_match_count; + if (!d_first_decode_match_logged) { + d_first_decode_match_logged = true; + BOOST_LOG_TRIVIAL(debug) + << "dcs_squelch_ff[decode]: first gate-open match for D" + << std::setw(3) << std::setfill('0') << d_configured_code + << (d_configured_inverted ? 'I' : 'N') << std::setfill(' '); + } + } + + // Gate decision. + // Decode mode: live phase scan — does any phase currently confirm a + // code in the configured cyclic class? No latch, no state from a + // prior tick; just: "is the configured code present right now?" + // Search mode: gate follows d_locked_code (any confirmed code). + if (d_configured_code != 0) { + if (cfg_match_this_tick) gate_should_open = true; + } else { + if (d_locked_code != 0) gate_should_open = true; + } } int dcs_squelch_ff::work(int noutput_items, @@ -639,9 +614,28 @@ dcs_squelch_verdict dcs_squelch_ff::get_verdict() const { boost::mutex::scoped_lock lock(d_mutex); dcs_squelch_verdict v{}; - // Composite DCS confidence with two independent factors so it's directly - // comparable to the CTCSS detector's wins/total ratio at the search-mode - // tiebreak in analog_recorder::stop(). + // Decode mode: the gate either opened (d_configured_match_count > 0) or + // did not. Confidence scales with how many detection ticks confirmed the + // configured class, analogous to ctcss_squelch_ff's win_ticks / total ratio. + if (d_configured_code != 0) { + if (d_configured_match_count > 0) { + v.detected_code = d_configured_code; + v.detected_inverted = d_configured_inverted; + v.confidence = std::min(1.0f, + static_cast(d_configured_match_count) / + static_cast(DCS_CONFIDENCE_FULL_MATCHES)); + auto it = d_class_key.find({d_configured_code, d_configured_inverted}); + if (it != d_class_key.end()) { + const uint32_t key = it->second; + for (const auto &kv : d_class_key) { + if (kv.second == key) v.aliases.push_back(kv.first); + } + } + } + return v; + } + + // Search mode: composite confidence from phase diversity + match rate. // // phase_diversity = min(1, locked_phases / EFFECTIVE_PHASES) // A real DCS NRZ transmission only engages ~half the 16 polyphase diff --git a/trunk-recorder/gr_blocks/dcs_squelch_ff.h b/trunk-recorder/gr_blocks/dcs_squelch_ff.h index 48ecfcfce..1a4fa4964 100644 --- a/trunk-recorder/gr_blocks/dcs_squelch_ff.h +++ b/trunk-recorder/gr_blocks/dcs_squelch_ff.h @@ -182,21 +182,13 @@ class BLOCKS_API dcs_squelch_ff : virtual public sync_block { // share a key iff their on-air bit patterns are rotations of each // other — i.e. they look identical on-air at different sync alignments. std::map, uint32_t> d_class_key; - // Class keys of the configured code for BOTH polarities. TIA-603-E - // §1.3.5.9 Table 6 defines two equally-valid CDCSS Modulation senses: - // Type A: data 1 → +Δf, data 0 → −Δf - // Type B: data 1 → −Δf, data 0 → +Δf - // A radio with a "D023" channel will key Type A OR Type B depending on - // its hardware/firmware. From the receiver's point of view a Type-B - // D023N transmission is indistinguishable from a Type-A D023I one - // (every bit on the wire is flipped). Real-world scanners treat - // polarity as transmitter-determined and gate on EITHER. We do the - // same: keep both class keys so the gate opens for D023 regardless of - // which sense the transmitter is using. The raw detected polarity is - // still reported in the verdict (and the first-lock log line) so the - // operator can tell which sense their radio actually uses. - uint32_t d_configured_class_key_n; // {configured_code, false} - uint32_t d_configured_class_key_i; // {configured_code, true} + // Polarity-strict cyclic equivalence class key for the configured + // (code, polarity) pair. Two on-air patterns share this key iff they + // are rotations of each other at the SAME polarity. D023N and D047I + // share one key; D023I and D047N share a different key. The gate + // opens only for matches against this single key — the complementary + // polarity class is a distinct code set and is intentionally rejected. + uint32_t d_configured_class_key; // Latched detection — once any phase confirms a code, we keep it for // the rest of the recording window so a brief mid-call signal dropout @@ -219,13 +211,22 @@ class BLOCKS_API dcs_squelch_ff : virtual public sync_block { long d_best_matches; int d_best_phases; - // Confidence counter: every tick where any phase's match_run >= 2 - // re-confirms the latched lock, this increments. Reset to 0 by reset(). - // get_verdict() converts to a [0,1] confidence using - // DCS_CONFIDENCE_FULL_MATCHES. A spurious one-shot lock (CTCSS - // bit-clock aliasing) stays at 0 because no second match_run forms. + // Search-mode confidence counter: increments once per detection tick + // where a phase re-confirms the latched d_locked_code. Unused in + // decode mode. get_verdict() converts to [0,1] via + // DCS_CONFIDENCE_FULL_MATCHES. long d_post_lock_matches; + // Decode-mode match counter: increments once per detection tick where + // any phase confirms a code in the configured cyclic class. Analogous + // to ctcss_squelch_ff::win_ticks. Zero in search mode. + long d_configured_match_count; + // One-shot diagnostic log guard for decode mode. Member (not + // thread_local static) so each block instance has its own flag and + // GR's thread-pool scheduler cannot cause one recorder to suppress + // another's first-match log. Reset by reset(). + bool d_first_decode_match_logged; + // Stale-lock watchdog (search mode only). Counts detection-rate ticks // since the last successful re-confirmation while locked. If it exceeds // a timeout (~500 ms), the lock is treated as stale: d_locked_code is diff --git a/trunk-recorder/recorders/analog_recorder.cc b/trunk-recorder/recorders/analog_recorder.cc index 64535dbd3..c2bd756fb 100644 --- a/trunk-recorder/recorders/analog_recorder.cc +++ b/trunk-recorder/recorders/analog_recorder.cc @@ -361,24 +361,17 @@ void analog_recorder::stop() { } else if (tone_config.mode == TONE_DCS) { if (!dcs_det.empty()) { tone_result.detected = dcs_det; tone_result.confidence = dcs_conf; } } else if (tone_config.mode == TONE_SEARCH) { - // Search-mode tiebreak: simple max-confidence comparison. With the - // phase-diversity DCS confidence now in place, a CTCSS-aliased false - // DCS lock scores ~0.05-0.15 while a real DCS lock scores ~0.9-1.0; - // a CTCSS detector lock with all four verdict guards passed scores - // ~0.85-0.95. So whichever returns the higher confidence is genuinely - // the better identification — no need for a CTCSS priority override - // (an earlier version had one, which incorrectly forced CTCSS to win - // even when a real DCS keyup was correctly identified at conf 1.0). + // Search-mode tiebreak: DCS wins unconditionally when detected. + // CTCSS (continuous sub-audio tone) and DCS (134.4 baud NRZ digital) + // are mutually exclusive at the transmitter — a radio uses one or the + // other, never both. Any valid DCS detection therefore supersedes a + // CTCSS candidate, which must be a false positive caused by the 134.4 + // baud DCS baseband signal bleeding into the sub-audio frequency range. const bool have_ctcss = !ctcss_det.empty(); const bool have_dcs = !dcs_det.empty(); if (have_ctcss && have_dcs) { - if (ctcss_conf >= dcs_conf) { - tone_result.detected = ctcss_det; - tone_result.confidence = ctcss_conf; - } else { - tone_result.detected = dcs_det; - tone_result.confidence = dcs_conf; - } + tone_result.detected = dcs_det; + tone_result.confidence = dcs_conf; } else if (have_ctcss) { tone_result.detected = ctcss_det; tone_result.confidence = ctcss_conf; From 56b40d54da4c179b04a28fe5934b986f6d273a37 Mon Sep 17 00:00:00 2001 From: Taclane Date: Thu, 4 Jun 2026 23:13:42 -0400 Subject: [PATCH 17/17] narrow channelizer filters --- trunk-recorder/gr_blocks/xlat_channelizer.cc | 2 +- trunk-recorder/gr_blocks/xlat_channelizer.h | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/trunk-recorder/gr_blocks/xlat_channelizer.cc b/trunk-recorder/gr_blocks/xlat_channelizer.cc index 254735ed5..e214a6d22 100644 --- a/trunk-recorder/gr_blocks/xlat_channelizer.cc +++ b/trunk-recorder/gr_blocks/xlat_channelizer.cc @@ -69,7 +69,7 @@ xlat_channelizer::xlat_channelizer(double input_rate, int samples_per_symbol, do freq_xlat = make_freq_xlating_fft_filter(initial_decim, if_coeffs, 0, input_rate); // inital_lpf_taps, 0, input_rate); - std::vector channel_lpf_taps = gr::filter::firdes::low_pass_2(1.0, initial_rate, d_bandwidth / 2, d_bandwidth / 4, 60); + std::vector channel_lpf_taps = gr::filter::firdes::low_pass_2(1.0, initial_rate, channel_lpf_cutoff, channel_lpf_transition, 60); channel_lpf = gr::filter::fft_filter_ccf::make(decim, channel_lpf_taps); // BOOST_LOG_TRIVIAL(info) << "\t Xlating Channelizer single-stage decimator - Decim: " << decimation << " Resampled Rate: " << resampled_rate << " Lowpass Taps: " << if_coeffs.size(); diff --git a/trunk-recorder/gr_blocks/xlat_channelizer.h b/trunk-recorder/gr_blocks/xlat_channelizer.h index 72705e11e..cd1f4ad5f 100644 --- a/trunk-recorder/gr_blocks/xlat_channelizer.h +++ b/trunk-recorder/gr_blocks/xlat_channelizer.h @@ -57,6 +57,15 @@ class xlat_channelizer : public gr::hier_block2 { static constexpr double smartnet_symbol_rate = 3600; static constexpr double channel_bandwidth = 12500; + // Set via xlat_channelizer() the previous values below are likely to cause issues when + // there are active neighborinbg channels. + // cutoff = d_bandwidth / 2 = 6250 Hz + // traansition = d_bandwidth / 4 = 3125 Hz + // Narrowing the filter is needed to attenuate the effects of adjacent channels. + static constexpr double channel_lpf_cutoff = 5200; // 5200 Hz + static constexpr double channel_lpf_stopband = 6500; // 6500 Hz + static constexpr double channel_lpf_transition = channel_lpf_stopband - channel_lpf_cutoff; // 1300 Hz + int get_freq_error(); bool is_squelched(); double get_pwr();