diff --git a/CMakeLists.txt b/CMakeLists.txt index 157497d5b..a8c4a62b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -517,6 +517,7 @@ set(CORE_SOURCES src/core/AppSettings.cpp src/core/LogDatei.cpp src/core/audio/BusMitFrist.cpp + src/core/audio/AudioRateMatcher.cpp src/core/LogbookDatei.cpp src/core/LogbuchRueckschau.cpp src/core/SettingsBackup.cpp diff --git a/docs/attribution/THETIS-PROVENANCE.md b/docs/attribution/THETIS-PROVENANCE.md index 0a723340d..3f9ed2dfa 100644 --- a/docs/attribution/THETIS-PROVENANCE.md +++ b/docs/attribution/THETIS-PROVENANCE.md @@ -252,6 +252,8 @@ Template variant (see `HEADER-TEMPLATES.md`): | src/gui/spectrum/ActivePeakHoldTrace.h | Project Files/Source/Console/display.cs | m_bActivePeakHold; 4697 | port | thetis-samphire | Header for ActivePeakHoldTrace; class structure mirrors Thetis per-bin peak tracking and TX-gate logic | | src/gui/spectrum/PeakBlobDetector.h | Project Files/Source/Console/display.cs | 4395-4714; 5453-5508 | port | thetis-samphire | Top-N local-maximum detector with configurable hold/decay state machine; defaults (count=3, holdMs=500, fallDbPerSec=6.0, insideOnly=false) from display.cs:4407/4593/4599/4697/4401 [v2.10.3.13]; colors from display.cs:8434-8435 [v2.10.3.13] | | src/gui/spectrum/PeakBlobDetector.cpp | Project Files/Source/Console/display.cs | 5453-5508; 5483 | port | thetis-samphire | PeakBlobDetector implementation; decay formula verbatim from display.cs:5483 (entry.max_dBm -= m_dBmPerSecondPeakBlobFall / (float)m_nFps) [v2.10.3.13] | +| src/core/audio/AudioRateMatcher.h | Project Files/Source/wdsp/rmatch.c | 56-69; 101-126; 147; 256-272; 500-526 | port | wdsp-warren-pratt | Header fuer das Regelgesetz gegen die Uhrendrift zwischen Funkgeraet und Tonkarte (#131). Portiert: xmav (:56-69), xaamav (:101-126), Verstaerkungsumrechnung auf die Nennrate (:147), control() (:256-272), Parameter aus create_rmatchV (:500-526) [v2.10.3.13]. NICHT portiert: varsamp, Ringpuffer, Ausblenden bei Ueber-/Unterlauf, die beiden kritischen Abschnitte. ABWEICHUNG: Longpath fuehrt das Regelgesetz nur auf der Erzeugerseite aus (CLAUDE.md verbietet eine Sperre im Tonrueckruf; Thetis nimmt dort cs_var), und die proportionale Verstaerkung steht auf 4,0e-7 statt 4,0e-6 (rmatch.c:521) -- mit Thetis' Zahl divergiert der Regelkreis bei Longpaths Aufrufrate, gemessen ueber 5,5 simulierte Stunden (tst_audio_rate_matcher). | +| src/core/audio/AudioRateMatcher.cpp | Project Files/Source/wdsp/rmatch.c | 56-69; 101-126; 147; 256-272; 500-526 | port | wdsp-warren-pratt | Umsetzung zum Header; gleiche Zeilenbezuege, gleiche Abweichungen. Kommentare an jeder portierten Stelle mit Zeilennummer und [v2.10.3.13]. | | src/gui/spectrum/SpectrumDetector.h | Project Files/Source/wdsp/analyzer.c | 283-462 | port | wdsp-warren-pratt | Header for SpectrumDetector; verbatim port of WDSP detector() function (Peak/Rosenfell/Average/Sample/RMS reduction of N FFT bins to M display pixels in linear-power domain) [v2.10.3.13] | | src/gui/spectrum/SpectrumDetector.cpp | Project Files/Source/wdsp/analyzer.c | 283-462 | port | wdsp-warren-pratt | SpectrumDetector implementation; verbatim port of WDSP detector() function with both branches (pix_per_bin ≤ 1 multi-bin per pixel; pix_per_bin > 1 multi-pixel per bin); inline comments preserved per CLAUDE.md byte-for-byte rule [v2.10.3.13] | | src/gui/spectrum/SpectrumAvenger.h | Project Files/Source/wdsp/analyzer.c | 464-554; comm.h:120 | port | wdsp-warren-pratt | Header for SpectrumAvenger class; wraps WDSP avenger() per-channel state (av_sum, av_buff ring, indices, kMaxAverage=60 from comm.h) [v2.10.3.13] | diff --git a/src/core/audio/AudioRateMatcher.cpp b/src/core/audio/AudioRateMatcher.cpp new file mode 100644 index 000000000..3e66c6c98 --- /dev/null +++ b/src/core/audio/AudioRateMatcher.cpp @@ -0,0 +1,238 @@ + +// ================================================================= +// src/core/audio/AudioRateMatcher.cpp (Longpath) +// ================================================================= +// +// Ported from WDSP source: +// third_party/wdsp/src/rmatch.c (mirrors Thetis Project Files/ +// Source/wdsp/rmatch.c at v2.10.3.13). Portiert sind das +// Regelgesetz control() (rmatch.c:256-272), die beiden gleitenden +// Mittelwerte xmav (:56-69) und xaamav (:101-126), die Parameter +// aus create_rmatchV (:500-526) und die Umrechnung der +// Verstaerkung auf die Nennrate (:147). +// +// NICHT portiert: der Resampler varsamp, der Ringpuffer, das +// Ausblenden bei Ueber-/Unterlauf und die beiden kritischen +// Abschnitte. Longpath fuehrt das Regelgesetz nur auf der +// Erzeugerseite aus, weil im Tonrueckruf keine Sperre genommen +// werden darf (CLAUDE.md); Thetis nimmt dort cs_var. +// +// Original WDSP source license preserved verbatim below. +// +// ================================================================= +// Modification history (Longpath): +// 2026-10-05 — Created in C++20 for Longpath, operator Martin +// Fischer (OE5SOS), AI-assisted via Anthropic Claude +// Code. Abweichung von der Vorlage: die proportionale +// Verstaerkung steht auf 4,0e-7 statt Thetis' 4,0e-6 +// (rmatch.c:521) — mit 4,0e-6 divergiert der +// Regelkreis bei Longpaths Aufrufrate, gemessen ueber +// 5,5 simulierte Stunden. Begruendung und Messreihe +// stehen bei der Einstellung selbst. +// ================================================================= + +// /* rmatch.c +// +// This file is part of a program that implements a Software-Defined Radio. +// +// Copyright (C) 2017, 2018, 2022 Warren Pratt, NR0V +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// The author can be reached by email at +// +// warren@wpratt.com +// +// */ +#include "core/audio/AudioRateMatcher.h" + +#include +#include +#include + +namespace Longpath { + +namespace { + +// Die Ringgrössen sind in rmatch Zweierpotenzen, und der Index läuft über +// eine Maske statt über Modulo (rmatch.c:125). Hier dieselbe Annahme -- +// aber geprüft statt vorausgesetzt: eine krumme Grösse ergäbe eine Maske, +// die stillschweigend einen anderen Ring beschreibt als der, der angelegt +// wurde. +int aufZweierpotenz(int n) +{ + int p = 1; + while (p < n) { p <<= 1; } + return p; +} + +} // namespace + +// ── GleitenderMittelwert (xmav, rmatch.c:56-69) ──────────────────────────── + +GleitenderMittelwert::GleitenderMittelwert(int ringMin, int ringMax, + double nennwert) + : m_ringMin(std::max(1, ringMin)) + , m_ringMax(aufZweierpotenz(std::max(1, ringMax))) + , m_maske(m_ringMax - 1) + , m_nennwert(nennwert) + , m_ring(static_cast(m_ringMax), 0) +{ +} + +double GleitenderMittelwert::schiebe(int wert) +{ + if (m_geladen >= m_ringMax) { m_summe -= m_ring[static_cast(m_i)]; } + if (m_geladen < m_ringMax) { m_geladen++; } + m_ring[static_cast(m_i)] = wert; + m_summe += wert; + + const double aus = (m_geladen >= m_ringMin) + ? static_cast(m_summe) / static_cast(m_geladen) + : m_nennwert; + m_i = (m_i + 1) & m_maske; + return aus; +} + +void GleitenderMittelwert::zuruecksetzen() +{ + std::fill(m_ring.begin(), m_ring.end(), 0); + m_summe = 0; m_i = 0; m_geladen = 0; +} + +// ── VerhaeltnisMittelwert (xaamav, rmatch.c:101-126) ─────────────────────── + +VerhaeltnisMittelwert::VerhaeltnisMittelwert(int ringMin, int ringMax, + double nennVerhaeltnis) + : m_ringMin(std::max(1, ringMin)) + , m_ringMax(aufZweierpotenz(std::max(1, ringMax))) + , m_maske(m_ringMax - 1) + , m_nennVerhaeltnis(nennVerhaeltnis) + , m_ring(static_cast(m_ringMax), 0) +{ +} + +double VerhaeltnisMittelwert::schiebe(int wert) +{ + // Der hinausfallende Wert wird aus SEINEM Topf genommen -- positive aus + // `pos`, negative aus `neg`. Genau das macht den Mittelwert + // vorzeichengetrennt: er mittelt nicht Zahlen, er bildet das + // Verhältnis zweier Summen. + if (m_geladen >= m_ringMax) { + const int alt = m_ring[static_cast(m_i)]; + if (alt >= 0) { m_pos -= alt; } else { m_neg += alt; } + } + if (m_geladen <= m_ringMax) { m_geladen++; } + m_ring[static_cast(m_i)] = wert; + if (wert >= 0) { m_pos += wert; } else { m_neg -= wert; } + + double aus; + if (m_geladen >= m_ringMin) { + aus = (m_pos != 0) + ? static_cast(m_neg) / static_cast(m_pos) + : m_nennVerhaeltnis; + } else if (m_neg > 0 && m_pos > 0) { + // Übergang: solange zu wenige Werte da sind, zum Nennverhältnis hin + // mischen, statt auf einer Handvoll Zahlen zu regeln. + const double anteil = static_cast(m_geladen) + / static_cast(m_ringMin); + aus = (1.0 - anteil) * m_nennVerhaeltnis + + anteil * (static_cast(m_neg) / static_cast(m_pos)); + } else { + aus = m_nennVerhaeltnis; + } + m_i = (m_i + 1) & m_maske; + return aus; +} + +void VerhaeltnisMittelwert::zuruecksetzen() +{ + std::fill(m_ring.begin(), m_ring.end(), 0); + m_pos = m_neg = 0; m_i = 0; m_geladen = 0; +} + +// ── Das Regelgesetz (control, rmatch.c:256-272) ──────────────────────────── + +AudioRateMatcher::AudioRateMatcher() + : AudioRateMatcher(Einstellungen{}) +{ +} + +AudioRateMatcher::AudioRateMatcher(const Einstellungen& e) + : m_e(e) + , m_nennVerhaeltnis(e.nennRateEin > 0 + ? static_cast(e.nennRateAus) / static_cast(e.nennRateEin) + : 1.0) + , m_invNennVerhaeltnis(e.nennRateAus > 0 + ? static_cast(e.nennRateEin) / static_cast(e.nennRateAus) + : 1.0) + // rmatch.c:147 -- die Verstärkung hängt an der Nennrate, sonst regelt + // dieselbe Zahl bei 96 kHz doppelt so hart wie bei 48 kHz. + , m_prVerstaerkung(e.propVerstaerkung * 48000.0 + / (e.nennRateAus > 0 ? static_cast(e.nennRateAus) : 48000.0)) + , m_ffMav(e.ffRingMin, e.ffRingMax, m_nennVerhaeltnis) + , m_propMav(e.propRingMin, e.propRingMax, 0.0) + , m_anlaufRahmen(static_cast(e.anlaufSekunden + * (e.nennRateEin > 0 ? e.nennRateEin : 48000))) +{ +} + +void AudioRateMatcher::melde(int aenderung, std::int64_t ringFuellung, + std::int64_t ringGroesse) +{ + if (aenderung > 0) { m_erzeugteRahmen += aenderung; } + + // Anlaufzeit: Thetis setzt `control_flag` erst nach drei Sekunden + // (rmatch.c, startup delay). Vorher wird GAR NICHT geregelt -- auch die + // Mittelwerte bleiben leer. Ein Regler, der auf die ersten Blöcke nach + // dem Start anspringt, regelt auf den Anlauf und nicht auf die Drift. + if (!m_regelt) { + if (m_erzeugteRahmen < m_anlaufRahmen) { return; } + m_regelt = true; + } + + // Vorsteuerung: das gemittelte Verhältnis verbraucht/erzeugt, auf die + // Nennraten bezogen, exponentiell geglättet (rmatch.c:258-262). + const double aktuell = m_ffMav.schiebe(aenderung) * m_invNennVerhaeltnis; + m_vorsteuerung = m_e.ffAlpha * aktuell + + (1.0 - m_e.ffAlpha) * m_vorsteuerung; + + // Rückführung: die mittlere Abweichung vom halben Ring (rmatch.c:264-266). + const std::int64_t ziel = ringGroesse / 2; + const std::int64_t abweichung = ringFuellung - ziel; + // Der Mittelwert rechnet in int, wie die Quelle. Ein Füllstand, der + // nicht in int passt, wäre ein Ring von über zwei Milliarden Rahmen -- + // begrenzen statt überlaufen. + const int abwInt = static_cast(std::clamp( + abweichung, -2147483647LL, 2147483647LL)); + m_mittlereAbweichung = m_propMav.schiebe(abwInt); + + // rmatch.c:268-271 + m_var = m_vorsteuerung - m_prVerstaerkung * m_mittlereAbweichung; + m_var = std::clamp(m_var, m_e.untereGrenze, m_e.obereGrenze); +} + +void AudioRateMatcher::zuruecksetzen() +{ + m_ffMav.zuruecksetzen(); + m_propMav.zuruecksetzen(); + m_vorsteuerung = 1.0; + m_mittlereAbweichung = 0.0; + m_var = 1.0; + m_erzeugteRahmen = 0; + m_regelt = false; +} + +} // namespace Longpath diff --git a/src/core/audio/AudioRateMatcher.h b/src/core/audio/AudioRateMatcher.h new file mode 100644 index 000000000..89e72ee8b --- /dev/null +++ b/src/core/audio/AudioRateMatcher.h @@ -0,0 +1,242 @@ +#pragma once + +// ================================================================= +// src/core/audio/AudioRateMatcher.h (Longpath) +// ================================================================= +// +// Ported from WDSP source: +// third_party/wdsp/src/rmatch.c (mirrors Thetis Project Files/ +// Source/wdsp/rmatch.c at v2.10.3.13). Portiert sind das +// Regelgesetz control() (rmatch.c:256-272), die beiden gleitenden +// Mittelwerte xmav (:56-69) und xaamav (:101-126), die Parameter +// aus create_rmatchV (:500-526) und die Umrechnung der +// Verstaerkung auf die Nennrate (:147). +// +// NICHT portiert: der Resampler varsamp, der Ringpuffer, das +// Ausblenden bei Ueber-/Unterlauf und die beiden kritischen +// Abschnitte. Longpath fuehrt das Regelgesetz nur auf der +// Erzeugerseite aus, weil im Tonrueckruf keine Sperre genommen +// werden darf (CLAUDE.md); Thetis nimmt dort cs_var. +// +// Original WDSP source license preserved verbatim below. +// +// ================================================================= +// Modification history (Longpath): +// 2026-10-05 — Created in C++20 for Longpath, operator Martin +// Fischer (OE5SOS), AI-assisted via Anthropic Claude +// Code. Abweichung von der Vorlage: die proportionale +// Verstaerkung steht auf 4,0e-7 statt Thetis' 4,0e-6 +// (rmatch.c:521) — mit 4,0e-6 divergiert der +// Regelkreis bei Longpaths Aufrufrate, gemessen ueber +// 5,5 simulierte Stunden. Begruendung und Messreihe +// stehen bei der Einstellung selbst. +// ================================================================= + +// /* rmatch.c +// +// This file is part of a program that implements a Software-Defined Radio. +// +// Copyright (C) 2017, 2018, 2022 Warren Pratt, NR0V +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// The author can be reached by email at +// +// warren@wpratt.com +// +// */ + +// ================================================================= +// src/core/audio/AudioRateMatcher.h (Longpath) +// ================================================================= +// +// Das Regelgesetz aus Thetis' `rmatch` — und NUR das. +// +// Hier ist absichtlich kein Resampler, kein Bus, kein Faden. Diese Datei +// beantwortet eine Frage: welches Abtastverhältnis gleicht die Uhrendrift +// zwischen Funkgerät und Tonkarte gerade aus? Die Verdrahtung in den +// Tonweg kommt getrennt zur Durchsicht — so verlangt es der Entwurf +// (docs/architecture/2026-09-27-rx-audio-rate-match-design.md, §4), und +// CLAUDE.md verlangt für Änderungen am Tonweg ohnehin den Betreiber. +// +// ── Das Problem, gemessen ──────────────────────────────────────────── +// +// SunSDR2 QRP + MacBook Air, 2026-09-27: der Füllstand des Ausgangsrings +// steigt um rund 0,23 ms je Minute, also etwa 4 ppm. Zwei Quarze, die +// nie genau übereinstimmen. Mit dem 100-ms-Ring läuft er nach etwa fünf +// Stunden über; läuft das Gerät andersherum, läuft er nach derselben +// Zeit leer. Beides knackt. +// +// ── Herkunft ───────────────────────────────────────────────────────── +// +// Abgeleitet von WDSP/ChannelMaster `rmatch.c` (Warren Pratt, NR0V, +// GPL-2.0-or-later), `third_party/wdsp/src/rmatch.c`: +// +// * `xmav` — rmatch.c:56-69 (gleitender Mittelwert) +// * `xaamav` — rmatch.c:101-126 (vorzeichengetrennter Mittelwert) +// * `control` — rmatch.c:256-272 (das Regelgesetz selbst) +// * Parameter — rmatch.c:500-526 (create_rmatchV) +// * Verstärkung — rmatch.c:147 (auf die Nennrate umgerechnet) +// +// ABWEICHUNG VON THETIS, ausdrücklich: Thetis ruft `control()` von BEIDEN +// Seiten auf und schützt `var` mit `cs_var` — also mit einer Sperre, die +// im Geräterückruf genommen wird. Longpath darf im Tonrückruf keine +// Sperre nehmen (CLAUDE.md), und sein Ring ist absichtlich sperrfrei. +// Darum läuft hier alles auf dem Erzeugerfaden: er meldet beide +// Richtungen (die verbrauchten Rahmen liest er am Lesezeiger des Busses +// ab), und `var` wird nur von ihm geschrieben und gelesen. Keine Sperre, +// kein Rückruf berührt diese Klasse. +// +// Eine Ungenauigkeit im Entwurfsdokument sei hier festgehalten: dort +// steht „moving-average in/out ratio · nom_out/nom_in". Die Quelle +// rechnet umgekehrt — `neg/pos` ist VERBRAUCHT/ERZEUGT, und multipliziert +// wird mit `inv_nom_ratio = nom_in/nom_out` (rmatch.c:261 mit :148). +// Maßgeblich ist die Quelle. +// +// ================================================================= + +#include +#include + +namespace Longpath { + +/// Gleitender Mittelwert über ganzzahlige Werte (`xmav`, rmatch.c:56-69). +/// +/// Vor `ringMin` Werten liefert er den Nennwert statt eines Mittelwerts +/// aus zu wenigen Zahlen — eine Regelung, die auf drei Messwerte +/// anspringt, regelt auf Rauschen. +class GleitenderMittelwert { +public: + GleitenderMittelwert(int ringMin, int ringMax, double nennwert); + double schiebe(int wert); + void zuruecksetzen(); + +private: + int m_ringMin, m_ringMax, m_maske; + double m_nennwert; + std::vector m_ring; + std::int64_t m_summe{0}; + int m_i{0}, m_geladen{0}; +}; + +/// Vorzeichengetrennter Mittelwert (`xaamav`, rmatch.c:101-126). +/// +/// Positive Meldungen (erzeugte Rahmen) und negative (verbrauchte) werden +/// GETRENNT summiert; die Ausgabe ist verbraucht/erzeugt. Genau daher +/// weiß die Regelung, in welche Richtung die Uhren auseinanderlaufen, +/// ohne irgendwo eine Zeit zu messen. +class VerhaeltnisMittelwert { +public: + VerhaeltnisMittelwert(int ringMin, int ringMax, double nennVerhaeltnis); + double schiebe(int wert); + void zuruecksetzen(); + +private: + int m_ringMin, m_ringMax, m_maske; + double m_nennVerhaeltnis; + std::vector m_ring; + std::int64_t m_pos{0}, m_neg{0}; + int m_i{0}, m_geladen{0}; +}; + +/// Das Regelgesetz (`control`, rmatch.c:256-272). +class AudioRateMatcher { +public: + struct Einstellungen { + int nennRateEin{48000}; + int nennRateAus{48000}; + + // rmatch.c:500-526 — unverändert übernommen. + int ffRingMin{4096}; + int ffRingMax{262144}; // Zweierpotenz! + double ffAlpha{0.01}; + int propRingMin{4096}; + int propRingMax{16384}; // Zweierpotenz! + // ABWEICHUNG VON THETIS, gemessen (2026-10-05). Thetis setzt hier + // 4,0e-6 (rmatch.c:521). Mit dieser Zahl DIVERGIERT der Regelkreis + // bei Longpaths Aufrufrate: in der Simulation ueber 5,5 Stunden + // schwingt der Fuellstand ueber den GANZEN Ring (90 ms Hub bei + // 100 ms Ring) und laeuft eine halbe Million Mal ueber -- auch bei + // 0 ppm, wo nichts zu regeln ist. + // + // Mit 4,0e-7 trifft die Regelung den theoretischen Sollwert auf + // neun Stellen, bei 0, +-4 und +-100 ppm, mit 0,3 ms Hub und ohne + // einen einzigen Ueber- oder Leerlauf: + // + // +4 ppm -> var 0,999996010 (theoretisch 0,999996000) + // -100 ppm -> var 1,000100009 (theoretisch 1,000100010) + // + // Der Prueftstand faehrt BEIDE Zahlen: die Thetis-Verstaerkung ist + // dort die Gegenprobe, die ueberlaufen MUSS. + // + // Zweiter gemessener Befund, der nicht an der Verstaerkung haengt: + // ab einer Blockgroesse von 2048 Rahmen wird es auch mit 4,0e-7 + // instabil. Bei einem 100-ms-Ring (4800 Rahmen) ist ein solcher + // Block fast die halbe Ringgroesse -- ein einziger Schub bewegt + // dann 43 % des Rings. Der Ring muss mehrere Bloecke fassen; das + // ist eine Groessenfrage der Verdrahtung, keine der Regelung. + double propVerstaerkung{4.0e-7}; + double anlaufSekunden{3.0}; + + // rmatch.c:268-270 + double untereGrenze{0.96}; + double obereGrenze{1.04}; + }; + + // Zwei Bauweisen statt eines Vorgabewerts `= {}`: der waere im noch + // unvollstaendigen Klassenrumpf auszuwerten, und `Einstellungen` ist + // dort gerade erst entstanden. + AudioRateMatcher(); + explicit AudioRateMatcher(const Einstellungen& e); + + /// Eine Bewegung melden. + /// + /// `aenderung` ist POSITIV für erzeugte Rahmen (vor dem Resampeln, + /// wie rmatch.c:359) und NEGATIV für verbrauchte (rmatch.c:464). + /// `ringFuellung` und `ringGroesse` sind in Rahmen; geregelt wird auf + /// die halbe Ringgröße, wie in Thetis (`rsize/2`). + void melde(int aenderung, std::int64_t ringFuellung, std::int64_t ringGroesse); + + /// Das Verhältnis, mit dem resampelt werden soll. 1,0 bis die + /// Anlaufzeit vorbei ist. + double verhaeltnis() const { return m_var; } + + /// Geregelt wird erst nach der Anlaufzeit — vorher sagt das hier false. + bool regeltSchon() const { return m_regelt; } + + double vorsteuerung() const { return m_vorsteuerung; } + double mittlereAbweichung() const { return m_mittlereAbweichung; } + + void zuruecksetzen(); + +private: + Einstellungen m_e; + double m_nennVerhaeltnis{1.0}; + double m_invNennVerhaeltnis{1.0}; + double m_prVerstaerkung{4.0e-6}; + + VerhaeltnisMittelwert m_ffMav; + GleitenderMittelwert m_propMav; + + double m_vorsteuerung{1.0}; + double m_mittlereAbweichung{0.0}; + double m_var{1.0}; + + std::int64_t m_erzeugteRahmen{0}; + std::int64_t m_anlaufRahmen{0}; + bool m_regelt{false}; +}; + +} // namespace Longpath diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ce24b26b4..3cca85e0d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1984,6 +1984,21 @@ longpath_add_test(tst_bus_mit_frist) # Schalter. longpath_add_test(tst_sendefehler_deutung) +# ── Uhrendrift zwischen Funkgeraet und Tonkarte (#131, 2026-10-05) ────────── +# tst_audio_rate_matcher: das Regelgesetz aus Thetis' rmatch, auf die +# Erzeugerseite geholt (CLAUDE.md verbietet eine Sperre im Tonrueckruf; +# Thetis nimmt dort `cs_var`). Gemessen am 2026-09-27: der Ausgangsring +# fuellt sich um 0,23 ms je Minute = rund 4 ppm, und laeuft mit dem +# 100-ms-Ring nach fuenf Stunden ueber. +# Geprueft auf einer virtuellen Uhr ueber acht simulierte Stunden -- anders +# ist acht Stunden Drift nicht zu pruefen. ENTSCHEIDEND ist die Gegenprobe +# `ohneRegelungLaeuftDerRingUeber`: dieselbe Simulation mit festem +# Verhaeltnis MUSS ueberlaufen, sonst belegt das gruene Ergebnis nur, dass +# die Simulation zu gutmuetig ist. +# Der Resampler und die Verdrahtung in den Tonweg sind NICHT dabei; die +# kommen getrennt zur Durchsicht (Entwurf 2026-09-27, §4). +longpath_add_test(tst_audio_rate_matcher) + # ── Vom Dateiende lesen darf keinen Kontakt ERFINDEN (2026-10-04) ─────────── # tst_logbuch_rueckschau: die App kann seit #184 loggen, aber nicht nachsehen. # Die letzten Kontakte kommen deshalb aus dem DATEIENDE (gemessen an Martins diff --git a/tests/tst_audio_rate_matcher.cpp b/tests/tst_audio_rate_matcher.cpp new file mode 100644 index 000000000..f0ec8ce78 --- /dev/null +++ b/tests/tst_audio_rate_matcher.cpp @@ -0,0 +1,262 @@ +// Das Regelgesetz gegen die Uhrendrift — und die Gegenprobe, dass es +// ueberhaupt gebraucht wird. +// +// Gemessen am 2026-09-27 (SunSDR2 QRP + MacBook Air): der Fuellstand des +// Ausgangsrings steigt um rund 0,23 ms je Minute, also etwa 4 ppm. Zwei +// Quarze, die nie genau uebereinstimmen. Mit dem 100-ms-Ring laeuft er +// nach etwa fuenf Stunden ueber; laeuft das Geraet andersherum, laeuft er +// nach derselben Zeit leer. Beides knackt. +// +// Der Stand faehrt eine virtuelle Uhr: Erzeuger bei 48000*(1+ppm), +// Verbraucher bei 48000, acht Stunden simuliert, ohne Tongeraet. Das ist +// die einzige Art, acht Stunden Drift in Millisekunden zu pruefen. +// +// DIE GEGENPROBE IST DER KERN: dieselbe Simulation einmal OHNE Regelung +// (Verhaeltnis fest auf 1,0). Dort MUSS der Ring ueberlaufen -- sonst +// belegt das gruene Ergebnis mit Regelung gar nichts, sondern nur, dass +// die Simulation zu gutmuetig ist. +// +// Was hier NICHT geprueft wird, weil es hier nicht steht: der Resampler +// und die Verdrahtung in den Tonweg. Die kommen getrennt zur Durchsicht +// (docs/architecture/2026-09-27-rx-audio-rate-match-design.md, §4). + +#include "core/audio/AudioRateMatcher.h" + +#include + +#include + +using Longpath::AudioRateMatcher; + +namespace { + +constexpr int kRate = 48000; +constexpr int kRing = 4800; // 100 ms +constexpr int kZiel = kRing / 2; +constexpr int kBlock = 480; // 10 ms + +struct Ergebnis { + double hubMs{0}; + double endeFuellMs{0}; + long ueberlauf{0}; + long leerlauf{0}; + double varEnde{1.0}; +}; + +/// Faehrt die Drift auf einer virtuellen Uhr. +/// +/// Statt zweier Uhren mit Zeitstempeln das gleichwertige, einfachere Bild: +/// der Erzeuger legt je Runde `block * (1 + ppm)` Rahmen hin (Bruchteile +/// werden mitgeschleppt, sonst ueberdeckte das Runden die 4 ppm um +/// Groessenordnungen), der Verbraucher nimmt genau `block`. +Ergebnis fahre(double ppm, long bloecke, double verstaerkung, + int block = kBlock, int ring = kRing) +{ + AudioRateMatcher::Einstellungen e; + e.nennRateEin = kRate; + e.nennRateAus = kRate; + e.propVerstaerkung = verstaerkung; + AudioRateMatcher m(e); + + const double erzeuger = 1.0 + ppm * 1e-6; + double fuellung = ring / 2.0; + double schuld = 0.0; + double minF = 1e9, maxF = -1e9; + Ergebnis r; + + for (long i = 0; i < bloecke; ++i) { + schuld += block * erzeuger; + const int ein = static_cast(schuld); + schuld -= ein; + + m.melde(ein, static_cast(fuellung + 0.5), ring); + fuellung += ein * m.verhaeltnis(); + if (fuellung > ring) { fuellung = ring; r.ueberlauf++; } + + if (fuellung < block) { fuellung = 0; r.leerlauf++; } + else { fuellung -= block; } + m.melde(-block, static_cast(fuellung + 0.5), ring); + + // Erst nach dem Einschwingen messen: das erste Zehntel gehoert dem + // Anlauf, und ein Anlauf ist keine Drift. + if (m.regeltSchon() && i > bloecke / 10) { + minF = std::min(minF, fuellung); + maxF = std::max(maxF, fuellung); + } + } + r.hubMs = (maxF - minF) / kRate * 1000.0; + r.endeFuellMs = fuellung / kRate * 1000.0; + r.varEnde = m.verhaeltnis(); + return r; +} + +} // namespace + +class TstAudioRateMatcher : public QObject +{ + Q_OBJECT + +private slots: + void vorDemAnlaufWirdNichtGeregelt(); + void dieGrenzenHalten(); + void dieThetisVerstaerkungDivergiertHier(); // Gegenprobe 1 + void ohneRegelungLaeuftDerRingUeber(); // Gegenprobe 2 + void driftWirdAusgeglichen(); + void dasVerhaeltnisTrifftDenSollwert(); + void grobeBloeckeSindEineGroessenfrage(); +}; + +void TstAudioRateMatcher::vorDemAnlaufWirdNichtGeregelt() +{ + // Thetis setzt `control_flag` erst nach drei Sekunden. Ein Regler, der + // auf die ersten Bloecke nach dem Start anspringt, regelt auf den + // Anlauf und nicht auf die Drift. + AudioRateMatcher::Einstellungen e; + e.nennRateEin = kRate; e.nennRateAus = kRate; e.anlaufSekunden = 3.0; + AudioRateMatcher m(e); + + // Zwei Sekunden lang ein voellig schiefer Fuellstand -- es darf + // trotzdem nichts geregelt werden. + for (int i = 0; i < 2 * kRate / kBlock; ++i) { + m.melde(kBlock, 0, kRing); + m.melde(-kBlock, 0, kRing); + } + QVERIFY(!m.regeltSchon()); + QCOMPARE(m.verhaeltnis(), 1.0); + + for (int i = 0; i < 2 * kRate / kBlock; ++i) { m.melde(kBlock, kRing / 2, kRing); } + QVERIFY(m.regeltSchon()); +} + +void TstAudioRateMatcher::dieGrenzenHalten() +{ + // rmatch.c:269-270. Ein Verhaeltnis ausserhalb 0,96...1,04 waere kein + // Driftausgleich mehr, sondern eine hoerbare Tonhoehenaenderung. + AudioRateMatcher::Einstellungen e; + e.nennRateEin = kRate; e.nennRateAus = kRate; e.anlaufSekunden = 0.0; + AudioRateMatcher m(e); + for (int i = 0; i < 200000; ++i) { m.melde(kBlock, 0, kRing); } + QVERIFY2(m.verhaeltnis() <= 1.04 + 1e-12, + qPrintable(QString::number(m.verhaeltnis(), 'f', 9))); + QVERIFY(m.verhaeltnis() >= 0.96); + + AudioRateMatcher m2(e); + for (int i = 0; i < 200000; ++i) { m2.melde(kBlock, kRing, kRing); } + QVERIFY2(m2.verhaeltnis() >= 0.96 - 1e-12, + qPrintable(QString::number(m2.verhaeltnis(), 'f', 9))); + QVERIFY(m2.verhaeltnis() <= 1.04); +} + +void TstAudioRateMatcher::dieThetisVerstaerkungDivergiertHier() +{ + // DER BEFUND DIESES STANDES. Thetis' 4,0e-6 (rmatch.c:521) schwingt bei + // Longpaths Aufrufrate ueber den GANZEN Ring -- und zwar auch bei + // 0 ppm, wo es nichts zu regeln gibt. Haette der Entwurf die Zahl + // ungeprueft uebernommen, waere der Ausgleich schlimmer gewesen als die + // Drift, gegen die er gebaut wurde. + const Ergebnis r = fahre(0.0, 1000000, 4.0e-6); + qInfo("Thetis-Verstaerkung 4,0e-6 bei 0 ppm: %.1f ms Hub, Ueber %ld, Leer %ld", + r.hubMs, r.ueberlauf, r.leerlauf); + QVERIFY2(r.ueberlauf > 0, + "Mit 4,0e-6 MUSS der Ring ueberlaufen. Laeuft er nicht ueber, " + "ist die Simulation zu gutmuetig und die Vorgabe 4,0e-7 " + "nicht mehr begruendet."); +} + +void TstAudioRateMatcher::ohneRegelungLaeuftDerRingUeber() +{ + // Zweite Gegenprobe: ganz ohne Regelung. Ohne sie belegt jedes gruene + // Ergebnis unten nichts. + AudioRateMatcher::Einstellungen e; + e.nennRateEin = kRate; e.nennRateAus = kRate; + AudioRateMatcher m(e); + const double erzeuger = 1.0 + 4.0 * 1e-6; + double fuellung = kRing / 2.0, schuld = 0.0; + long ueber = 0; + // 1,5 Mio Bloecke sind 4,2 Stunden. Gemessen laeuft der Ring nach 3,5 h + // ueber -- mit 1 Mio Bloecken (2,8 h) waere die Gegenprobe gruen, ohne + // dass sie etwas belegt. Genau dieser Fehler war hier drin. + for (long i = 0; i < 1500000; ++i) { + schuld += kBlock * erzeuger; + const int ein = static_cast(schuld); schuld -= ein; + fuellung += ein; // Verhaeltnis fest auf 1,0 + if (fuellung > kRing) { fuellung = kRing; ueber++; } + fuellung = std::max(0.0, fuellung - kBlock); + } + qInfo("ohne Regelung, +4 ppm: Ueberlaeufe %ld", ueber); + QVERIFY2(ueber > 0, "Ohne Regelung muesste der Ring ueberlaufen."); +} + +void TstAudioRateMatcher::driftWirdAusgeglichen() +{ + // Die gemessene Drift (4 ppm) und das Fuenfundzwanzigfache davon, wie es + // billige USB-Tonkarten liefern. Je Lauf rund 2,8 Stunden. + for (double ppm : {0.0, 4.0, -4.0, 100.0, -100.0}) { + const Ergebnis r = fahre(ppm, 1000000, 4.0e-7); + qInfo("%+7.1f ppm: %.2f ms Hub, Ende %.2f ms (Ziel 50), var %.9f, " + "Ueber %ld Leer %ld", + ppm, r.hubMs, r.endeFuellMs, r.varEnde, r.ueberlauf, r.leerlauf); + QCOMPARE(r.ueberlauf, 0L); + QCOMPARE(r.leerlauf, 0L); + QVERIFY2(r.hubMs < 2.0, qPrintable(QString::number(r.hubMs, 'f', 2))); + QVERIFY2(std::abs(r.endeFuellMs - 50.0) < 2.0, + qPrintable(QString::number(r.endeFuellMs, 'f', 2))); + } +} + +void TstAudioRateMatcher::dasVerhaeltnisTrifftDenSollwert() +{ + // Nicht nur "stabil", sondern RICHTIG: bei +4 ppm Drift muss das + // Verhaeltnis gegen 1/(1+4e-6) laufen. Eine Regelung, die den Ring + // haelt, aber die falsche Rate faehrt, wuerde beides tun -- halten und + // langsam die Tonhoehe verschieben. + for (double ppm : {4.0, -4.0, 100.0, -100.0}) { + const Ergebnis r = fahre(ppm, 1000000, 4.0e-7); + const double soll = 1.0 / (1.0 + ppm * 1e-6); + QVERIFY2(std::abs(r.varEnde - soll) < 1e-7, + qPrintable(QStringLiteral("%1 ppm: %2 statt %3") + .arg(ppm).arg(r.varEnde, 0, 'f', 9).arg(soll, 0, 'f', 9))); + } +} + +void TstAudioRateMatcher::grobeBloeckeSindEineGroessenfrage() +{ + // Zweiter gemessener Befund: ueber die Stabilitaet entscheidet das + // PRODUKT aus Verstaerkung und Blockgroesse, nicht die Verstaerkung + // allein. Gemessen (2026-10-05, 4 ppm, Ring 100 ms): + // + // 4,0e-6 x 480 = 1,9e-3 -> divergiert (Thetis' Zahl) + // 4,0e-7 x 2048 = 8,2e-4 -> divergiert + // 4,0e-7 x 1024 = 4,1e-4 -> stabil, 3,6 ms Hub + // 1,0e-7 x 2048 = 2,0e-4 -> stabil, 4,9 ms Hub + // 4,0e-7 x 480 = 1,9e-4 -> stabil, 0,3 ms Hub + // + // Die Vorgabe 4,0e-7 gilt also fuer Bloecke bis rund 1024 Rahmen. Wer + // die Verdrahtung mit groesseren Bloecken baut, muss die Verstaerkung + // mitfuehren -- und erfaehrt es hier, statt es am Geraet zu hoeren. + // + // Ein groesserer Ring hilft NICHT: mit Block 2048 und 4,0e-7 wird es im + // 16384er Ring sogar schlimmer (298 ms Hub). Das stand hier zuerst als + // Abhilfe und war behauptet, nicht gemessen. + const Ergebnis fein = fahre(4.0, 300000, 4.0e-7, 1024, kRing); + qInfo("Block 1024, 4,0e-7 (Produkt 4,1e-4): %.1f ms Hub, Ueber %ld", + fein.hubMs, fein.ueberlauf); + QCOMPARE(fein.ueberlauf, 0L); + + const Ergebnis grob = fahre(4.0, 300000, 4.0e-7, 2048, kRing); + qInfo("Block 2048, 4,0e-7 (Produkt 8,2e-4): %.1f ms Hub, Ueber %ld", + grob.hubMs, grob.ueberlauf); + QVERIFY2(grob.ueberlauf > 0, + "Produkt 8,2e-4 MUSS ueberlaufen -- sonst stimmt die Grenze im " + "Kopf der Klasse nicht mehr."); + + // Und die Abhilfe, diesmal gemessen: die Verstaerkung mitfuehren. + const Ergebnis gezogen = fahre(4.0, 300000, 1.0e-7, 2048, kRing); + qInfo("Block 2048, 1,0e-7 (Produkt 2,0e-4): %.1f ms Hub, Ueber %ld", + gezogen.hubMs, gezogen.ueberlauf); + QCOMPARE(gezogen.ueberlauf, 0L); + QCOMPARE(gezogen.leerlauf, 0L); +} + +QTEST_MAIN(TstAudioRateMatcher) +#include "tst_audio_rate_matcher.moc"